【译】this 是什么?JavaScript 对象的内部工作原理

2019-04-18 | JavaScript

JavaScript 是一种支持面向对象编程和动态绑定的多范式语言。动态绑定是其一个强大的特性,它允许 JavaScript 代码在运行时更改 this,但是这一强大而且灵活的特性却会给开发者带来一些困惑,这些困惑集中在 JavaScript 代码运行时的表现上。

# 动态绑定(Dynamic Binding)

动态绑定指的是在运行时才确定调用函数的方式,而不是更早的编译阶段。JavaScript 通过 this 和原型链来体现动态绑定。也就是说,函数内部的 this 是在运行时确定,并且定义函数的方式不同,确定 this 的规则也不同。

先来玩个游戏。这个游戏叫 “this 是什么?”

const a = {
  a: 'a'
};
const obj = {
  getThis: () => this,
  getThis2 () {
    return this;
  }
};
obj.getThis3 = obj.getThis.bind(obj);
obj.getThis4 = obj.getThis2.bind(obj);
const answers = [
  obj.getThis(),
  obj.getThis.call(a),
  obj.getThis2(),
  obj.getThis2.call(a),
  obj.getThis3(),
  obj.getThis3.call(a),
  obj.getThis4(),
  obj.getThis4.call(a)
];

在继续之前,请先写下你的答案。完成后,console.log()你的答案,你答对了吗?

译者注:本文的示例需要运行在 ES6 Module 下才符合文章所说的结果,你可以在 HTML 页面中指定脚本标签类型 <scirpt type="module">...</script> 让代码运行在 ES6 Module 下。如果你将代码直接复制到浏览器控制台运行,下文说的 undefined 实际上是 window

让我们从第一个结果开始。obj.getThis() 返回 undefined,但为什么呢?箭头函数永远不会绑定属于自己的 this,它们的 this 总是绑定在定义时所在的作用域上。本例中的定义时所在的作用域,就是 ES6 模块的根作用域,这里的 thisundefined。因为相同的原因,obj.getThis.call(a) 同样也是 undefined。对于箭头函数,它的 this 不能被重新分配赋值,即使使用 .call().bind(),它的 this 总是绑定在定义时所在的作用域上,而不会指向运行时所在的作用域。

obj.getThis2() 通过一般的函数调用获取其绑定。如果函数之前没有绑定 this(就是说,它不是箭头函数),那该函数就可以拥有自己的 this 绑定,具体绑定到使用 .[] 调用该方法的对象上。

obj.getThis2.call(a) 做了点小动作,call() 方法提供给定的 this 值和可选参数调用函数。换句话说,函数从 .call() 参数获取 this 的绑定,因此 obj.getThis2.call(a) 返回 a 对象。

使用 obj.getThis3 = obj.getThis.bind(obj);,我们尝试绑定一个箭头函数,前面我们已经讨论过绑定箭头函数是不起作用的,所以 obj.getThis3()obj.getThis3.call(a) 都得到 undefined

我们可以绑定一般的函数,所以 obj.getThis4() 按预期返回 obj,因为它已经使用 obj.getThis4 = obj.getThis2.bind(obj); 绑定了。而 obj.getThis4.call(a) 遵从第一个的绑定,所以返回 obj 而不是 a

# 加大难度(Curve Ball)

同样的挑战,不过这一次,使用到了 class 的公共字段语法(public fields syntax) (写这篇文章的时候,该语法提案处于 Stage3 阶段。Chrome 和 @babel/plugin-proposal-class-properties 已经支持):

译者注:公共字段语法(public fields syntax),如果不知道是什么的话,可以看阮一峰 ES6 | Class 的基本语法: 实例属性的新写法

class Obj {
  getThis = () => this
  getThis2 () {
    return this;
  }
}
const obj2 = new Obj();
obj2.getThis3 = obj2.getThis.bind(obj2);
obj2.getThis4 = obj2.getThis2.bind(obj2);
const answers2 = [
  obj2.getThis(),
  obj2.getThis.call(a),
  obj2.getThis2(),
  obj2.getThis2.call(a),
  obj2.getThis3(),
  obj2.getThis3.call(a),
  obj2.getThis4(),
  obj2.getThis4.call(a)
];

在继续之前写下你的答案。

准备好了?

除了 obj2.getThis2.call(a) 返回 a 对象外,其它都返回对象实例。箭头函数的 this 仍然绑定在定义时所在的作用域上,区别在于定义时所在作用域的 this 已然不是 undefined。这段代码的底层,会将类的属性赋值编译成:

class Obj {
  constructor() {
    this.getThis = () => this;
  }
...

也就是说,箭头函数是在构造函数(constructor)的上下文中定义的。由于它是一个类,创建实例的唯一方法是使用 new 关键字(省略 new 会抛出错误)。

new 关键字会实例化一个新的对象实例,并在执行构造函数时将 this 指向该实例。这种行为,加上我们上面已经提到的其他行为,就能解释清楚结果。

# 总结

你做得怎样?有没有做对呢?理解了 this 的表现行为,在调试棘手的问题时能节省大量时间。如果你做错了任意一道,那你需要多加练习。研究上面这些例子,然后回来再做一次,直到你都能做对,并向其他人解释为什么这些方法会返回这些值。

如果这些题比你想象的要难,你并不是一个人。针对这个主题,我已经问过了不少开发者,我认为到目前为止只有一位开发人员掌握了。

加上类和箭头函数的行为,会使 .call(). bind().apply() 的动态绑定开始变得复杂。请记住,箭头函数总是将 this 绑定在定义时所在的作用域上,第二个例子 class 中的 this 实际绑定在执行构造函数时的作用域上。如果你还有疑问,请记住使用 debugger 工具来验证是否符合你的想法。

还要记住,在JavaScript中,即使不用 this,你也可以做很多事情。根据我的经验,几乎任何东西都可以使用纯函数重新实现,纯函数接收所有传递给它们的显式参数(你可以将 this 看成是可变的隐式参数)。封装在纯函数中的逻辑具有确定性,这使得它更易于测试,并且没有副作用。这意味着与操作 this 不同,你不可能破坏其他任何东西。而每当你修改 this 时,依赖于 this 的行为就可能被破坏。

也就是说,this 有时很有用,例如:在大量对象之间共享方法。即使在函数式编程中,this 对于访问其他对象上的函数,以实现在现有函数之上构建新函数是很有用的,例如:.flatMap() 可以通过组合 this.map()this.constructor.of() 来实现。