你不知道的Javascriptthis

35次阅读

共计 5557 个字符,预计需要花费 14 分钟才能阅读完成。

this是什么?

JavaScript 中最令人困惑的机制之一就是 this 关键字。它是一个在每个函数作用域中自动定义的特殊标识符关键字,但即便是一些老练的 JavaScript 开发者也对它到底指向什么感到困扰。

我们要明白,this 不是编写时绑定,而是运行时绑定。它依赖于函数调用的上下文条件。this 绑定与函数声明的位置没有任何关系,而与函数被调用的方式紧密相连。

那该如何来判断函数的执行是如何绑定的 this?上面已经说名,this 与函数被调用的方式紧密相连,那这个被调用的方式就是一个判断的调用点。

调用点(Call-site)

调用点:函数在代码中被调用的位置(而不是被声明的位置)。一般来说寻找调用点就是:“找到一个函数是在哪里被调用的”,但它不总是那么简单,比如某些特定的编码模式会使 真正的 调用点变得不那么明确。

考虑 调用栈(call-stack)(使我们到达当前执行位置而被调用的所有方法的堆栈)是十分重要的。我们关心的调用点就位于当前执行中的函数 之前 的调用。

以下代码展示调用栈和调用点

function test1() {
    // 调用栈是: `test1`
    // 我们的调用点是 global scope(全局作用域)console.log("test1");
    test2(); // <-- `test2` 的调用点}

function test2() {
    // 调用栈是: `test1` -> `test2`
    // 我们的调用点位于 `test1`

    console.log("test2");
    test3(); // <-- `test3` 的 call-site}

function test3() {
    // 调用栈是: `test1` -> `test2` -> `test3`
    // 我们的调用点位于 `test2`

    console.log("test3");
}

test1(); // <-- `test1` 的调用点

从代码中分析寻找调用点时要小心,因为它是影响 this 绑定的唯一因素。

默认绑定(Default Binding)

第一种规则最常见的就是:独立函数调用。可以认为这种 this 规则是在没有其他规则适用时的默认规则。

function foo() {console.log( this.a);
}

var a = 2;

foo(); // 2

第一点: 在全局作用域中的声明变量var a = 2,是全局对象的同名属性的同义词。它们不是互相拷贝对方,它们就是彼此。

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

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

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

function foo() {
    "use strict";

    console.log(this.a);
}

var a = 2;

foo(); // TypeError: `this` is `undefined`

隐含绑定(Implicit Binding)

另一种规则是:调用点是否有一个环境对象(context object),也称为拥有者(owning)或容器(containing)对象。

考虑这段代码:

function foo() {console.log( this.a);
}

var obj = {
    a: 2,
    foo: foo
};

obj.foo(); // 2

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

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

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

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

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

function foo() {console.log( this.a);
}

var obj2 = {
    a: 42,
    foo: foo
};

var obj1 = {
    a: 2,
    obj2: obj2
};

obj1.obj2.foo(); // 42
隐含丢失(Implicitly Lost)

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

考虑这段代码:

function foo() {console.log( this.a);
}

var obj = {
    a: 2,
    foo: foo
};

var bar = obj.foo; // 函数引用!var a = "oops, global"; // `a` 也是一个全局对象的属性

bar(); // "oops, global"

调用点是 bar(),一个直白,毫无修饰的调用,因此 默认绑定 适用于这里。

当我们考虑传递一个回调函数时,会怎样了?

function foo() {console.log( this.a);
}

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // `a` 也是一个全局对象的属性

setTimeout(obj.foo, 100); // "oops, global"

没有区别,同样的结果。

正如我们刚刚看到的,我们的回调函数丢掉他们的 this 绑定是十分常见的事情。

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

明确绑定(Explicit Binding)

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

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

考虑这段代码:

function foo() {console.log( this.a);
}

var obj = {a: 2};

foo.call(obj); // 2

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

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

new 绑定(new Binding)

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

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

something = new MyClass(..);

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

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

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

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

考虑这段代码:

function foo(a) {this.a = a;}

var bar = new foo(2);
console.log(bar.a); // 2

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


一切皆有顺序

如此,我们已经揭示了函数调用中的四种 this 绑定规则。你需要做的 一切 就是找到调用点然后考察哪一种规则适用于它。但是,如果调用点上有多种规则都适用呢?这些规则一定有一个优先顺序,我们下面就来展示这些规则以什么样的优先顺序实施。

很显然,默认绑定 在四种规则中优先权最低的。所以我们先把它放在一边。

隐含绑定 明确绑定 哪一个更优先呢?我们来测试一下:

function foo() {console.log( this.a);
}
var obj1 = {
    a: 2,
    foo: foo
};
var obj2 = {
    a: 3,
    foo: foo
};
obj1.foo(); // 2
obj2.foo(); // 3
obj1.foo.call(obj2); // 3
obj2.foo.call(obj1); // 2

所以, 明确绑定 的优先权要高于 隐含绑定 ,这意味着你应当在考察 隐含绑定 之前 首先 考察 明确绑定 是否适用。

现在,我们只需要搞清楚 new 绑定 的优先级位于何处。

function foo(something) {this.a = something;}
var obj1 = {foo: foo};
var obj2 = {};
obj1.foo(2);
console.log(obj1.a); // 2

obj1.foo.call(obj2, 3);
console.log(obj2.a); // 3

var bar = new obj1.foo(4);
console.log(obj1.a); // 2
console.log(bar.a); // 4

好了,new 绑定 的优先级要高于 隐含绑定 。那么你觉得 new 绑定 的优先级较之于 明确绑定 是高还是低呢?

注意 newcall/apply 不能同时使用,所以 new foo.call(obj1) 是不允许的,也就是不能直接对比测试 new 绑定 明确绑定 。但是我们依然可以使用 硬绑定 来测试这两个规则的优先级。

在我们进入代码中探索之前,回想一下 硬绑定 物理上是如何工作的,也就是 Function.prototype.bind(..) 创建了一个新的包装函数,这个函数被硬编码为忽略它自己的 this 绑定(不管它是什么),转而手动使用我们提供的。

因此,这似乎看起来很明显,硬绑定(明确绑定的一种)的优先级要比 new 绑定 高,而且不能被 new 覆盖。

我们检验一下:

function foo(something) {this.a = something;}
var obj1 = {};
var bar = foo.bind(obj1);
bar(2);
console.log(obj1.a); // 2

var baz = new bar(3);
console.log(obj1.a); // 2
console.log(baz.a); // 3

哇!bar 是硬绑定到 obj1 的,但是 new bar(3) 并 没有 像我们期待的那样将 obj1.a 变为 3。反而,硬绑定(到 obj1)的 bar(..) 调用 可以 被 new 所覆盖。因为 new 被覆盖,我们得到一个名为 baz 的新创建的对象,而且我们确实看到 baz.a 的值为 3

为什么 new 可以覆盖 硬绑定 这件事很有用?

这种行为的主要原因是,创建一个实质上忽略 this硬绑定 而预先设置一部分或所有的参数的函数(这个函数可以与 new 一起使用来构建对象)。bind(..) 的一个能力是,任何在第一个 this 绑定参数之后被传入的参数,默认地作为当前函数的标准参数(技术上这称为“局部应用(partial application)”,是一种“柯里化(currying)”)。

例如:

function foo(p1,p2) {this.val = p1 + p2;}
// 在这里使用 `null` 是因为在这种场景下我们不关心 `this` 的硬绑定
// 而且反正它将会被 `new` 调用覆盖掉!var bar = foo.bind(null, "p1");
var baz = new bar("p2");
baz.val; // p1p2

判定 this

现在,我们可以按照优先顺序来总结一下从函数调用的调用点来判定 this 的规则了。按照这个顺序来问问题,然后在第一个规则适用的地方停下。

  1. 函数是通过 new 被调用的吗(new 绑定)?如果是,this 就是新构建的对象。

    var bar = new foo()

  2. 函数是通过 callapply 被调用(明确绑定),甚至是隐藏在 bind 硬绑定 之中吗?如果是,this 就是那个被明确指定的对象。

    var bar = foo.call(obj2)

  3. 函数是通过环境对象(也称为拥有者或容器对象)被调用的吗(隐含绑定)?如果是,this 就是那个环境对象。

    var bar = obj1.foo()

  4. 否则,使用默认的 this默认绑定)。如果在 strict mode 下,就是 undefined,否则是 global 对象。

    var bar = foo()

以上,就是理解对于普通的函数调用来说的 this 绑定规则。

正文完
 0