JavaScript之This浅谈

在 JavaScript 的对象和函数内部,如果需要再次调用对象或者函数时,有时候可以使用 this 代替这个对象或者函数。

目录
  1. 关于 this
  2. this的指向
    1. 对象的this指向
    2. 函数的this指向
  3. this 的灵活运用
  4. 改变 this 的指向
  5. 手动实现
    1. 手动实现call
    2. 手动实现apply
    3. 手动实现bind
    4. 测试
  6. 总结

关于 this#

在 JavaScript 的对象和函数内部,如果需要再次调用对象或者函数时,有时候可以使用this代替这个对象或者函数。

const user = {
  name: "Hale",
  sayHi: function () {
    // console.log('Hi', user.name)
    console.log("Hi", this.name);
  },
};
 
user.sayHi(); // Hi Hale

上面的this代替对象user,即指向了user。但是this并不一定就是代替其作用域所在的对象或者函数。

function fn() {
  const name = "Hale";
  console.log(this.name);
}
 
var name = "Amy";
// window.name = 'Amy'
 
fn(); // Amy

上面的this并不是指向函数fn,而是浏览器的全局对象window,又因为使用var声明的变量都会存储在全局对象window中,最后函数输出的值为Amy

那么,this到底指向哪里呢?

this的指向#

对象的this指向#

对象里面的方法调用this一定指向该对象。不管时普通的对象还是通过构造函数或者类生成的实例对象,只要通过是obj.fn()调用方法,那么 方法fn里面的 this 指向obj对象。

// 普通对象
let user = {
  name: "Hale",
  printName: function () {
    console.log(this.name); // this 指向对象 user
  },
};
 
user.printName(); // Hale
 
// 类
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
 
  printName() {
    console.log(this.name); // this 指向实例对象 p
  }
}
 
// 实例对象
const p = new Person("Hale", 18); // 生成实例对象 { name: 'Hale', age: 18, printName: function() { console.log(this.name) } }
p.printName(); // Hale

函数的this指向#

普通函数

普通函数的this指向全局对象window或者global(Node.js 环境)。如果函数里面已经声明了this.xxx,则会把xxx属性或者方法挂载到全局对象中,并覆盖原来的值。另外函数里面使用var声明的变量并不是全局变量,不会挂载到全局对象中。如果没有声明this.xxx,则会在全局对象中去寻找xxx的值,找不到则为undefined

// 浏览器环境
var name = "Amy"; // 全局变量
var age = 20; // 全局变量
console.log(name); // 'Amy'
const fn = function () {
  this.name = "Hale";
  var age = 18; // 函数中使用 var 声明的变量是块级变量,不是全局变量
  console.log(this.name); // 'Hale'
  console.log(this.age); // 20
  console.log(this.score); // undefined
  console.log(this === window); // true
  console.log(window.name === "Hale"); // true
};
fn();
console.log(name); // 'Hale'

全局变量一定要使用var来声明,只要用var声明的全局变量挂载到全局对象window中,而使用const或者let声明的变量挂载到全局对象。也可以直接在全局对象上添加值,比如window.name = 'Amy'

构造函数

构造函数其实也可以当成是普通函数,此时它里面的this也是指向全局对象。但是构造函数的主要还是用来生成实例对象的,是通过new来生成,而不是调用它。构造函数其实也可以直接像普通函数一样调用,但这样就是没有意义了。当构造函数生成实例对象时,this指向的就是生成的实例对象。

  • 构造函数生成实例对象
function Person(name, age) {
  this.name = name;
  this.age = age;
  this.printName = function () {
    console.log(this.name);
  };
  console.log(this); // { name: 'Hale', age: 18, printName: function() {console.log(this.name)} } ,指向实例对象
  console.log(this === window); // false
}
 
const p = new Person("Hale", 18);
p.printName(); // Hale
  • 调用构造函数,和普通函数一样
function Person(name, age) {
  this.name = name;
  this.age = age;
  this.printName = function () {
    console.log(this.name);
  };
  console.log(this); // 打印 window 对象,指向全局对象 window
  console.log(this === window); // true
}
 
Person("Hale", 18);
 
// 如果硬要搞个返回值
const p = Person("Hale", 18);
console.log(p); // 结果很明显,函数没有 return 值,所以是 undefined
p.printName(); // 无输出

类 Class

在 ES6 中新增了类 Class,类其实是一个语法糖,本质还是 JavaScript 函数的原型的实现。在一些面向对象的编程语言中,类的一个主要作用就是生成实例对象。JavaScript 类也不例外,通过new生成实例对象,类是不能直接调用的,这时类的this指向生成的实例对象,类里面的属性和方法都会变成实例的属性和方法,这个和构造函数生成实例相似。

另外 class 里面的方法前面如果加上关键字static,而会把实例方法变成静态方法,静态方法是可以直接通过类来调用的,调用方法为className.staticMethod(),这时静态方法里面的this指向的就是类本身,有点类似于对象调用方法。目前类只有静态方法而没有静态属性,所以静态方法里面的this只能调用其他的静态方法,而不能获取实例属性。除非通过new生成实例后,this指向变成实例对象,才可以获取实例的属性和方法。

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
    this.score = 100;
  }
 
  printName() {
    console.log(this.name); // this 指向实例对象
  }
  static printAge() {
    this.getAge(); // this 指向类
  }
 
  static getAge() {
    console.log(18);
  }
 
  static printScore() {
    console.log(this.score);
  }
}
 
// 实例对象
const p = new Person("Hale", 18);
p.printName(); // Hale
Person.printAge(); // 18 直接调用类的静态方法
Person.printScore(); // undefined 无法获取属性

箭头函数

箭头函数没有this,在箭头函数内部访问的this都是来自外部作用域的this

function sayName() {
  this.name = "Hale";
  setTimeout(() => {
    console.log(this.name); // this === 外面的 this
  }, 1000);
}
 
sayName(); // Hale

通过上面代码,我们发现this只有在普通函数里面才有效,即使是对象必须在对象的普通函数里面才能生效。

const obj = {
  a: 1,
  b: this.a,
  fn: function () {
    console.log(this.a);
  },
};
 
console.log(obj.b); // undefined,this.a 获取不到 a 的值
obj.fn(); // 1 在函数里面才能获取到 a

我们可以得出一条规律,this的指向由其所在的函数调用时(运行时)的上下文环境决定的。

this 的灵活运用#

知道了this的指向判定后,其实还不够,还需要在实际的使用中灵活运用才行。

下面举一个在小程序的例子:

class Token {
  constructor() {
    this.verifyUrl = "/token/verify"; // this1
  }
 
  _verifyFromServer(token) {
    // this2
    const self = this;
    wx.request({
      url: self.verifyUrl, // this3
      method: "POST",
      data: {
        token,
      },
      success: function (res) {
        const valid = res.data.data.is_valid;
        if (!valid) {
          self.getTokenFromServer();
          // this4
        }
      },
    });
  }
 
  getTokenFromServer() {}
}

这是一个小程序项目中的辅助内,用来验证token是否合法。在上面代码的不同位置上标注了 4 个 this,下面分析下这 4 个 this 的指向。

  • this1 和 this2 的指向判定比较简单,它们都在类的方法中,指向的是类Token生成的实例对象。

  • this3 的位置是在函数request传入的参数对象中,而且不是在对象的函数里面,这个this是在对象里面是不存在的。但是因为它外面包裹了一个函数request,所以它实际上是函数requestthis,而这个函数又是对象wx的调用的方法,所以this指向的是对象wx。但是因为对象wx中并没有verifyUrl这个属性,而属性verifyUrl是类的实例的属性,所以把 this3 替换成了 this2 的指向才能拿到这个属性值。

  • this4 的位置在函数request传入的参数对象里面的方法中,所以this的指向为作为参数的对象本身。但是因为这个对象没有getTokenFromServer这个方法,而方法getTokenFromServer是类的实例的方法,所以把 this4 替换成了 this2 的指向才能拿到这个方法。

改变 this 的指向#

上面所展示的所有代码中this的指向,都是由函数运行时的上下文决定的,而不是由我们自己来决定的。

那么我们可以指定 this 的指向吗?答案是肯定的。

可以通过 JavaScript 函数实例方法中callapplybind的这 3 个方法来改变this的指向。

示例代码如下:

const obj = {
  value: "obj",
};
 
window.value = "val";
 
function getValue(name, age) {
  console.log(name, age, this.value); //普通函数 this 指向全局对象
}
 
getValue("Hale", 18); // Hale 18 val 。this 指向 window
 
// 改变 this 指向为 obj 对象
// 通过 call 改变
getValue.call(obj, "Hale", 18); // 改变指向后直接调用函数,打印结果为 Hale 18 obj
 
// 通过 apply 改变
getValue.apply(obj, ["Hale", 18]); // 改变指向后直接调用函数,打印结果为 Hale 18 obj
 
// 通过 bind 改变
const fn = getValue.bind(obj, "Amy", 20); // 改变指向并传入参数,生成一个新的函数
fn(); // Amy 20 obj
fn("Hale", 18); // Amy 20 obj 传入的参数无效,绑定时的参数不会被覆盖
 
const fn2 = getValue.bind(obj); // 只改变指向而不传参数,生成一个新的函数
fn2(); // undefined undefined 'obj'
fn2("Hale", 18); // Hale 18 obj 传入参数有效

上面代码中,函数getValuethis一般情况下指向是全局对象window。但是通过使用callapplybind方法之后,函数getValuethis指向已经改变了,变成了指向传入的第一个参数obj对象了,而从第二个参数开始的参数则为getValue函数所要传入的参数,按照顺序传入。

对于函数传入的参数类型,是一个个传入,还是传入参数集合的数组,apply是传入数组的,而callbind是一个个参数传入的。其实这个规则很好记忆。apply方法的首字母和array数组的首字母都是a,所以传入的是参数是一个数组,而其它 2 个的方法callbind的首字母不是a,所以传入的不是数组,而是一个个参数了。

补充

其实,callapplybind方法改变了this指向,把函数中this的指向由一个对象换成了另一个对象。比如某个函数fn中的this的指向原来是指向对象a的,现在换成了指向对象b,那么是不是意味着原来对象b中根本就没有fn这个方法,但是当fnthis指向b的时候,对象b就有了对象a的这个方法了,相当于对象b“借用”a的方法。

const a = {
  val: 1,
  fn: function () {
    console.log(this.val);
  },
};
 
const b = {
  val: 2,
};
 
a.fn(); // 1
a.fn.call(b); // 2

这样的话,某个对象就不用添加一些方法了,而是通过改变其它对象的方法的this指向,“借用” 其它对象的方法。

举一个非常简单的场景。假如我们获取的多个 DOM 的集合,然后想要使用forEach来遍历这些集合,并打印出每个 DOM 的内容。可是因为这个集合不是 JavaScript 规范(严格意义上)的数组,无法使用数组的forEach方法了。怎么办?其实可以 “借用” 数组的这个方法。

<body>
  <ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
  </ul>
  <script>
    const lis = document.getElementsByTagName("li");
    // lis.forEach(item => console.log(item.innerText)) // 报错
 
    Array.prototype.forEach.call(lis, (item) => console.log(item.innerText));
  </script>
</body>

通过document.getElementsByTagName('tag')获取到的lis的类型是HTMLCollection类型,这不是一个规范的数组,而是一个类数组,无法使用数组的 API。这时候就可以通过call使得Array.prototype.forEach方法的this指向lis,让lis“借用” 这个方法了。

这种 “借用” 方法的手段在一些设计模式中用得很广,以后有机会再介绍下。

手动实现#

callapplybind方法的改变this指向原理到底是什么呢?

其实很简单,我们知道对象里面的方法的this一定指向该对象本身,只要我们把函数放入到所需的对象里面,然后像调用方法一样调用这个函数,就可以使这个函数的this指向这个对象了。

下面分别实现callapplybind方法。

手动实现call#

实现原理:

  • 把需要改变this指向的函数放入到指向的对象中。
  • 然后把放入的函数使用对象的方法去调用,调用时记得传入函数所需的参数,并且存储调用的结果。
  • 调用完成之后在删除这个函数。
  • 最后返回函数调用后的结果。
Function.prototype.selfCall = function (context = window) {
  // 给 context 添加一个属性 fn ,赋值为 this,即赋值为需要改变指向的函数本身,即 getValue
  // getValue.call(context, 'hale', 18) => context.fn = getValue
  context.fn = this;
  const args = [...arguments].slice(1); // 取出 context 后面的参数
  const result = context.fn(...args); // => getValue('hale', 18)
  delete context.fn; // 删除 fn
  return result; // 返回结果
};

手动实现apply#

apply的实现原理和call是一样的,但是因为它的参数是一个数组,所以需要额外处理一下参数,即把参数组成的数组展开。

Function.prototype.selfApply = function (context = window) {
  context.fn = this;
  const result = arguments[1] ? context.fn(...arguments[1]) : context.fn();
  delete context.fn;
  return result;
};

手动实现bind#

bind的实现稍微复杂点,需要考虑更多的因素。因为bind不是改变指向后直接调用函数,而是生成一个新的函数,所以需要判断生成的函数的原型和原来的函数是否相同。如果相同,函数返回原来函数的实例对象,如果不相同,则和上面的callapply原理一样。 这里的参数有两种,第一种是改变this指向时传入的参数,第二种是调用生成的新函数时传入的参数,第一种参数优先级更高,优先使用它,如果没有第一种参数,而使用第二种参数。

Function.prototype.selfBind = function (context = window) {
  const self = this;
  const args = [...arguments].slice(1);
 
  // 返回一个函数
  return function F() {
    return this instanceof F
      ? new self(...args, ...arguments)
      : self.apply(context, args.concat(...arguments));
  };
};

测试#

测试上面的手动函数,你会发现和 JavaScript 原生的方法效果是一样的。

const obj = {
  value: "obj",
};
 
window.value = "val";
 
function getValue(name, age) {
  console.log(name, age, this.value);
}
 
const obj = {
  value: "obj",
};
 
window.value = "val";
 
function getValue(name, age) {
  console.log(name, age, this.value);
}
 
getValue("Hale", 18); // Hale 18 val 。this 指向 window
 
// 改变 this 指向为 obj 对象
// 通过 selfCall 改变
getValue.selfCall(obj, "Hale", 18); // 直接调用 Hale 18 obj
 
// 通过 selfApply 改变
getValue.selfApply(obj, ["Hale", 18]); // 直接调用 Hale 18 obj
 
// 通过 selfBind 改变
const fn = getValue.selfBind(obj, "Amy", 20); // 传入参数,返回一个新的函数
fn(); // Amy 20 obj
fn("Hale", 18); // Amy 20 obj 参数不会被覆盖
 
const fn2 = getValue.selfBind(obj); // 不传入参数
fn2(); // undefined undefined 'obj'
fn2("Hale", 18); // Hale 18 obj 参数为传入的参数

总结#

this算是 JavaScript 语言的一个知识难点,如果想要用好它,请记住一点:this的指向由其所在的函数调用时(运行时)的上下文决定。

另外,箭头函数没有this,箭头函数内部的this指向的是自外部函数的this值。最后具体情况具体分析,根据实际情况判断this的指向,灵活使用this

本文都是个人使用 JavaScript 后的一些见解,如有错误,欢迎指正。