第二章: this 豁然开朗! - 仅仅是规则

优质
小牛编辑
126浏览
2023-12-01

仅仅是规则

现在我们将注意力转移到调用点 如何 决定在函数执行期间 this 指向哪里。

你必须考察调用点并判定4种规则中的哪一种适用。我们将首先独立地解释一下这4种规则中的每一种,之后我们来展示一下如果有多种规则可以适用于调用点时,它们的优先顺序。

默认绑定(Default Binding)

我们要考察的第一种规则源于函数调用的最常见的情况:独立函数调用。可以认为这种 this 规则是在没有其他规则适用时的默认规则。

考虑这个代码段:

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var a = 2;
  5. foo(); // 2

第一点要注意的,如果你还没有察觉到,是在全局作用域中的声明变量,也就是var a = 2,是全局对象的同名属性的同义词。它们不是互相拷贝对方,它们 就是 彼此。正如一个硬币的两面。

第二,我们看到当foo()被调用时,this.a解析为我们的全局变量a。为什么?因为在这种情况下,对此方法调用的 this 实施了 默认绑定,所以使 this 指向了全局对象。

我们怎么知道这里适用 默认绑定 ?我们考察调用点来看看 foo() 是如何被调用的。在我们的代码段中,foo() 是被一个直白的,毫无修饰的函数引用调用的。没有其他的我们将要展示的规则适用于这里,所以 默认绑定 在这里适用。

如果 strict mode 在这里生效,那么对于 默认绑定 来说全局对象是不合法的,所以 this 将被设置为 undefined

  1. function foo() {
  2. "use strict";
  3. console.log( this.a );
  4. }
  5. var a = 2;
  6. foo(); // TypeError: `this` is `undefined`

一个微妙但是重要的细节是:即便所有的 this 绑定规则都是完全基于调用点的,但如果 foo()内容 没有在 strict mode 下执行,对于 默认绑定 来说全局对象是 唯一 合法的;foo() 的调用点的 strict mode 状态与此无关。

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var a = 2;
  5. (function(){
  6. "use strict";
  7. foo(); // 2
  8. })();

注意: 在你的代码中故意混用 strict mode 和非 strict mode 通常是让人皱眉头的。你的程序整体可能应当不是 Strict 就是 非 Strict。然而,有时你可能会引用与你的 Strict 模式不同的第三方包,所以对这些微妙的兼容性细节要多加小心。

隐含绑定(Implicit Binding)

另一种要考虑的规则是:调用点是否有一个环境对象(context object),也称为拥有者(owning)或容器(containing)对象,虽然这些名词可能有些误导人。

考虑这段代码:

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var obj = {
  5. a: 2,
  6. foo: foo
  7. };
  8. obj.foo(); // 2

首先,注意 foo() 被声明然后作为引用属性添加到 obj 上的方式。无论 foo() 是否一开始就在 obj 上被声明,还是后来作为引用添加(如上面代码所示),这个 函数 都不被 obj 所真正“拥有”或“包含”。

然而,调用点 使用 obj 环境来 引用 函数,所以你 可以说 obj 对象在函数被调用的时间点上“拥有”或“包含”这个 函数引用

不论你怎样称呼这个模式,在 foo() 被调用的位置上,它被冠以一个指向 obj 的对象引用。当一个方法引用存在一个环境对象时,隐含绑定 规则会说:是这个对象应当被用于这个函数调用的 this 绑定。

因为 objfoo() 调用的 this,所以 this.a 就是 obj.a 的同义词。

只有对象属性引用链的最后一层是影响调用点的。比如:

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var obj2 = {
  5. a: 42,
  6. foo: foo
  7. };
  8. var obj1 = {
  9. a: 2,
  10. obj2: obj2
  11. };
  12. obj1.obj2.foo(); // 42

隐含丢失(Implicitly Lost)

this 绑定最常让人沮丧的事情之一,就是当一个 隐含绑定 丢失了它的绑定,这通常意味着它会退回到 默认绑定, 根据 strict mode 的状态,其结果不是全局对象就是 undefined

考虑这段代码:

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var obj = {
  5. a: 2,
  6. foo: foo
  7. };
  8. var bar = obj.foo; // 函数引用!
  9. var a = "oops, global"; // `a` 也是一个全局对象的属性
  10. bar(); // "oops, global"

尽管 bar 似乎是 obj.foo 的引用,但实际上它只是另一个 foo 本身的引用而已。另外,起作用的调用点是 bar(),一个直白,毫无修饰的调用,因此 默认绑定 适用于这里。

这种情况发生的更加微妙,更常见,而且更意外的方式,是当我们考虑传递一个回调函数时:

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. function doFoo(fn) {
  5. // `fn` 只不过 `foo` 的另一个引用
  6. fn(); // <-- 调用点!
  7. }
  8. var obj = {
  9. a: 2,
  10. foo: foo
  11. };
  12. var a = "oops, global"; // `a` 也是一个全局对象的属性
  13. doFoo( obj.foo ); // "oops, global"

参数传递仅仅是一种隐含的赋值,而且因为我们在传递一个函数,它是一个隐含的引用赋值,所以最终结果和我们前一个代码段一样。

那么如果接收你所传递回调的函数不是你的,而是语言内建的呢?没有区别,同样的结果。

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var obj = {
  5. a: 2,
  6. foo: foo
  7. };
  8. var a = "oops, global"; // `a` 也是一个全局对象的属性
  9. setTimeout( obj.foo, 100 ); // "oops, global"

把这个粗糙的,理论上的 setTimeout() 假想实现当做 JavaScript 环境内建的实现的话:

  1. function setTimeout(fn,delay) {
  2. // (通过某种方法)等待 `delay` 毫秒
  3. fn(); // <-- 调用点!
  4. }

正如我们刚刚看到的,我们的回调函数丢掉他们的 this 绑定是十分常见的事情。但是 this 使我们吃惊的另一种方式是,接收我们回调的函数故意改变调用的 this。那些很流行的 JavaScript 库中的事件处理器就十分喜欢强制你的回调的 this 指向触发事件的 DOM 元素。虽然有时这很有用,但其他时候这简直能气死人。不幸的是,这些工具很少给你选择。

不管哪一种意外改变 this 的方式,你都不能真正地控制你的回调函数引用将如何被执行,所以你(还)没有办法控制调用点给你一个故意的绑定。我们很快就会看到一个方法,通过 固定 this 来解决这个问题。

明确绑定(Explicit Binding)

用我们刚看到的 隐含绑定,我们不得不改变目标对象使它自身包含一个对函数的引用,而后使用这个函数引用属性来间接地(隐含地)将 this 绑定到这个对象上。

但是,如果你想强制一个函数调用使用某个特定对象作为 this 绑定,而不在这个对象上放置一个函数引用属性呢?

JavaScript 语言中的“所有”函数都有一些工具(通过他们的 [[Prototype]] —— 待会儿详述)可以用于这个任务。具体地说,函数拥有 call(..)apply(..) 方法。从技术上讲,JavaScript 宿主环境有时会提供一些(说得好听点儿!)很特别的函数,它们没有这些功能。但这很少见。绝大多数被提供的函数,当然还有你将创建的所有的函数,都可以访问 call(..)apply(..)

这些工具如何工作?它们接收的第一个参数都是一个用于 this 的对象,之后使用这个指定的 this 来调用函数。因为你已经直接指明你想让 this 是什么,所以我们称这种方式为 明确绑定(explicit binding)

考虑这段代码:

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var obj = {
  5. a: 2
  6. };
  7. foo.call( obj ); // 2

通过 foo.call(..) 使用 明确绑定 来调用 foo,允许我们强制函数的 this 指向 obj

如果你传递一个简单基本类型值(stringboolean,或 number 类型)作为 this 绑定,那么这个基本类型值会被包装在它的对象类型中(分别是 new String(..)new Boolean(..),或 new Number(..))。这通常称为“封箱(boxing)”。

注意:this 绑定的角度讲,call(..)apply(..) 是完全一样的。它们确实在处理其他参数上的方式不同,但那不是我们当前关心的。

不幸的是,单独依靠 明确绑定 仍然不能为我们先前提到的问题提供解决方案,也就是函数“丢失”自己原本的 this 绑定,或者被第三方框架覆盖,等等问题。

硬绑定(Hard Binding)

但是有一个 明确绑定 的变种确实可以实现这个技巧。考虑这段代码:

  1. function foo() {
  2. console.log( this.a );
  3. }
  4. var obj = {
  5. a: 2
  6. };
  7. var bar = function() {
  8. foo.call( obj );
  9. };
  10. bar(); // 2
  11. setTimeout( bar, 100 ); // 2
  12. // `bar` 将 `foo` 的 `this` 硬绑定到 `obj`
  13. // 所以它不可以被覆盖
  14. bar.call( window ); // 2

我们来看看这个变种是如何工作的。我们创建了一个函数 bar(),在它的内部手动调用 foo.call(obj),由此强制 this 绑定到 obj 并调用 foo。无论你过后怎样调用函数 bar,它总是手动使用 obj 调用 foo。这种绑定即明确又坚定,所以我们称之为 硬绑定(hard binding)

硬绑定 将一个函数包装起来的最典型的方法,是为所有传入的参数和传出的返回值创建一个通道:

  1. function foo(something) {
  2. console.log( this.a, something );
  3. return this.a + something;
  4. }
  5. var obj = {
  6. a: 2
  7. };
  8. var bar = function() {
  9. return foo.apply( obj, arguments );
  10. };
  11. var b = bar( 3 ); // 2 3
  12. console.log( b ); // 5

另一种表达这种模式的方法是创建一个可复用的帮助函数:

  1. function foo(something) {
  2. console.log( this.a, something );
  3. return this.a + something;
  4. }
  5. // 简单的 `bind` 帮助函数
  6. function bind(fn, obj) {
  7. return function() {
  8. return fn.apply( obj, arguments );
  9. };
  10. }
  11. var obj = {
  12. a: 2
  13. };
  14. var bar = bind( foo, obj );
  15. var b = bar( 3 ); // 2 3
  16. console.log( b ); // 5

由于 硬绑定 是一个如此常用的模式,它已作为 ES5 的内建工具提供:Function.prototype.bind,像这样使用:

  1. function foo(something) {
  2. console.log( this.a, something );
  3. return this.a + something;
  4. }
  5. var obj = {
  6. a: 2
  7. };
  8. var bar = foo.bind( obj );
  9. var b = bar( 3 ); // 2 3
  10. console.log( b ); // 5

bind(..) 返回一个硬编码的新函数,它使用你指定的 this 环境来调用原本的函数。

注意: 在 ES6 中,bind(..) 生成的硬绑定函数有一个名为 .name 的属性,它源自于原始的 目标函数(target function)。举例来说:bar = foo.bind(..) 应该会有一个 bar.name 属性,它的值为 "bound foo",这个值应当会显示在调用栈轨迹的函数调用名称中。

API 调用的“环境”

确实,许多库中的函数,和许多在 JavaScript 语言以及宿主环境中的内建函数,都提供一个可选参数,通常称为“环境(context)”,这种设计作为一种替代方案来确保你的回调函数使用特定的 this 而不必非得使用 bind(..)

举例来说:

  1. function foo(el) {
  2. console.log( el, this.id );
  3. }
  4. var obj = {
  5. id: "awesome"
  6. };
  7. // 使用 `obj` 作为 `this` 来调用 `foo(..)`
  8. [1, 2, 3].forEach( foo, obj ); // 1 awesome 2 awesome 3 awesome

从内部来说,几乎可以确定这种类型的函数是通过 call(..)apply(..) 来使用 明确绑定 以节省你的麻烦。

new 绑定(new Binding)

第四种也是最后一种 this 绑定规则,要求我们重新思考 JavaScript 中关于函数和对象的常见误解。

在传统的面向类语言中,“构造器”是附着在类上的一种特殊方法,当使用 new 操作符来初始化一个类时,这个类的构造器就会被调用。通常看起来像这样:

  1. something = new MyClass(..);

JavaScript 拥有 new 操作符,而且使用它的代码模式看起来和我们在面向类语言中看到的基本一样;大多数开发者猜测 JavaScript 机制在做某种相似的事情。但是,实际上 JavaScript 的机制和 new 在 JS 中的用法所暗示的面向类的功能 没有任何联系

首先,让我们重新定义 JavaScript 的“构造器”是什么。在 JS 中,构造器 仅仅是一个函数,它们偶然地与前置的 new 操作符一起调用。它们不依附于类,它们也不初始化一个类。它们甚至不是一种特殊的函数类型。它们本质上只是一般的函数,在被使用 new 来调用时改变了行为。

例如,引用 ES5.1 的语言规范,Number(..) 函数作为一个构造器来说:

15.7.2 Number 构造器

当 Number 作为 new 表达式的一部分被调用时,它是一个构造器:它初始化这个新创建的对象。

所以,可以说任何函数,包括像 Number(..)(见第三章)这样的内建对象函数都可以在前面加上 new 来被调用,这使函数调用成为一个 构造器调用(constructor call)。这是一个重要而微妙的区别:实际上不存在“构造器函数”这样的东西,而只有函数的构造器调用。

当在函数前面被加入 new 调用时,也就是构造器调用时,下面这些事情会自动完成:

  1. 一个全新的对象会凭空创建(就是被构建)
  2. 这个新构建的对象会被接入原形链([[Prototype]]-linked)
  3. 这个新构建的对象被设置为函数调用的 this 绑定
  4. 除非函数返回一个它自己的其他 对象,否则这个被 new 调用的函数将 自动 返回这个新构建的对象。

步骤 1,3 和 4 是我们当下要讨论的。我们现在跳过第 2 步,在第五章回过头来讨论。

考虑这段代码:

  1. function foo(a) {
  2. this.a = a;
  3. }
  4. var bar = new foo( 2 );
  5. console.log( bar.a ); // 2

通过在前面使用 new 来调用 foo(..),我们构建了一个新的对象并把这个新对象作为 foo(..) 调用的 thisnew 是函数调用可以绑定 this 的最后一种方式,我们称之为 new 绑定(new binding)