第二章: this
豁然开朗!
在第一章中,我们摒弃了种种对 this
的误解,并且知道了 this
是一个完全根据调用点(函数是如何被调用的)而为每次函数调用建立的绑定。
1 调用点(Call-site)
为了理解 this
绑定,我们不得不理解调用点:函数在代码中被调用的位置(不是被声明的位置)。我们必须考察调用点来回答这个问题:这个 this
指向什么?
一般来说寻找调用点就是:“找到一个函数是在哪里被调用的”,但它不总是那么简单,比如某些特定的编码模式会使 真正的 调用点变得不那么明确。
考虑 调用栈(call-stack) (使我们到达当前执行位置而被调用的所有方法的堆栈)是十分重要的。我们关心的调用点就位于当前执行中的函数 之前 的调用。
我们来展示一下调用栈和调用点:
function baz() {
// 调用栈是: `baz`
// 我们的调用点是 global scope(全局作用域)
console.log("baz");
bar(); // <-- `bar` 的调用点
}
function bar() {
// 调用栈是: `baz` -> `bar`
// 我们的调用点位于 `baz`
console.log("bar");
foo(); // <-- `foo` 的 call-site
}
function foo() {
// 调用栈是: `baz` -> `bar` -> `foo`
// 我们的调用点位于 `bar`
console.log("foo");
}
baz(); // <-- `baz` 的调用点
复制代码
在分析代码来寻找(从调用栈中)真正的调用点时要小心,因为它是影响 this
绑定的唯一因素。
注意: 你可以通过按顺序观察函数的调用链在你的大脑中建立调用栈的视图,就像我们在上面代码段中的注释那样。但是这很痛苦而且易错。另一种观察调用栈的方式是使用你的浏览器的调试工具。大多数现代的桌面浏览器都内建开发者工具,其中就包含 JS 调试器。在上面的代码段中,你可以在调试工具中为 foo()
函数的第一行设置一个断点,或者简单的在这第一行上插入一个 debugger
语句。当你运行这个网页时,调试工具将会停止在这个位置,并且向你展示一个到达这一行之前所有被调用过的函数的列表,这就是你的调用栈。所以,如果你想调查this
绑定,可以使用开发者工具取得调用栈,之后从上向下找到第二个记录,那就是你真正的调用点。
2 this
指向规则
你必须考察调用点并判定 4 种规则中的哪一种适用。我们将首先独立地解释一下这 4 种规则中的每一种,之后我们来展示一下如果有多种规则可以适用于调用点时,它们的优先顺序。
与这四种绑定规则不同,ES6 的箭头方法使用词法作用域来决定 this
绑定,这意味着它们采用封闭他们的函数调用作为 this
绑定(无论它是什么)。它们实质上是 ES6 之前的 self = this
代码的语法替代品。
2.1 默认绑定(Default Binding)
我们要考察的第一种规则源于函数调用的最常见的情况:独立函数调用。可以认为这种 this
规则是在没有其他规则适用时的默认规则。
考虑这个代码段:
function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2
复制代码
在我们的代码段中,foo()
是被一个直白的,毫无修饰的函数引用调用的。没有其他的我们将要展示的规则适用于这里,所以 默认绑定 在这里适用。
如果 strict mode
在这里生效,那么对于 默认绑定 来说全局对象是不合法的,所以 this
将被设置为 undefined
。
function foo() {
"use strict";
console.log(this.a);
}
var a = 2;
foo(); // TypeError: `this` is `undefined`
复制代码
一个微妙但是重要的细节是:即便所有的 this
绑定规则都是完全基于调用点的,但如果 foo()
的 内容 没有在 strict mode
下执行,对于 默认绑定 来说全局对象是 唯一 合法的;foo()
的调用点的 strict mode
状态与此无关。
function foo() {
console.log(this.a);
}
var a = 2;
(function() {
"use strict";
foo(); // 2
})();
复制代码
2.2 隐含绑定(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
绑定。
因为 obj
是 foo()
调用的 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
似乎是 obj.foo
的引用,但实际上它只是另一个 foo
本身的引用而已。另外,起作用的调用点是 bar()
,一个直白,毫无修饰的调用,因此 默认绑定 适用于这里。
这种情况发生的更加微妙,更常见,而且更意外的方式,是当我们考虑传递一个回调函数时:
function foo() {
console.log(this.a);
}
function doFoo(fn) {
// `fn` 只不过 `foo` 的另一个引用
fn(); // <-- 调用点!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // `a` 也是一个全局对象的属性
doFoo(obj.foo); // "oops, global"
复制代码
那么如果接收你所传递回调的函数不是你的,而是语言内建的呢?没有区别,同样的结果。
另外一个要注意的是,你可以(有意或无意地!)创建对函数的“间接引用(indirect reference)”,在那样的情况下,当那个函数引用被调用时,默认绑定 规则也会适用。
一个最常见的 间接引用 产生方式是通过赋值:
function foo() {
console.log(this.a);
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2
复制代码
赋值表达式 p.foo = o.foo
的 结果值 是一个刚好指向底层函数对象的引用。如此,起作用的调用点就是 foo()
,而非你期待的 p.foo()
或 o.foo()
。根据上面的规则,默认绑定适用。
2.3 明确绑定(Explicit Binding)
函数拥有 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
。
如果你传递一个简单基本类型值(string
,boolean
,或 number
类型)作为 this
绑定,那么这个基本类型值会被包装在它的对象类型中(分别是 new String(..)
,new Boolean(..)
,或 new Number(..)
)。这通常称为“封箱(boxing)”。
硬绑定(Hard Binding)
解决 this
指向隐含丢失的问题, 利用明确绑定的变种确实可以实现这个技巧。考虑这段代码:
function foo() {
console.log(this.a);
}
var obj = {
a: 2
};
var bar = function() {
foo.call(obj);
};
bar(); // 2
setTimeout(bar, 100); // 2
// `bar` 将 `foo` 的 `this` 硬绑定到 `obj`
// 所以它不可以被覆盖
bar.call(window); // 2
复制代码
用 硬绑定 将一个函数包装起来的最典型的方法,是为所有传入的参数和传出的返回值创建一个通道:
function foo(something) {
console.log(this.a, something);
return this.a + something;
}
var obj = {
a: 2
};
var bar = function() {
return foo.apply(obj, arguments);
};
var b = bar(3); // 2 3
console.log(b); // 5
复制代码
另一种表达这种模式的方法是创建一个可复用的帮助函数:
function foo(something) {
console.log(this.a, something);
return this.a + something;
}
// 简单的 `bind` 帮助函数
function bind(fn, obj) {
return function() {
return fn.apply(obj, arguments);
};
}
var obj = {
a: 2
};
var bar = bind(foo, obj);
var b = bar(3); // 2 3
console.log(b); // 5
复制代码
由于 硬绑定 是一个如此常用的模式,它已作为 ES5 的内建工具提供:Function.prototype.bind
,像这样使用:
function foo(something) {
console.log(this.a, something);
return this.a + something;
}
var obj = {
a: 2
};
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5
复制代码
bind(..)
返回一个硬编码的新函数,它使用你指定的 this
环境来调用原本的函数。
2.4 new
绑定(new
Binding)
首先,让我们重新定义 JavaScript 的“构造器”是什么。在 JS 中,构造器 仅仅是一个函数,它们偶然地与前置的 new
操作符一起调用。它们不依附于类,它们也不初始化一个类。它们甚至不是一种特殊的函数类型。它们本质上只是一般的函数,在被使用 new
来调用时改变了行为。
可以说任何函数,包括像 Number(..)
(见第三章)这样的内建对象函数都可以在前面加上 new
来被调用,这使函数调用成为一个 构造器调用(constructor call)。这是一个重要而微妙的区别:实际上不存在“构造器函数”这样的东西,而只有函数的构造器调用。
当在函数前面被加入 new
调用时,也就是构造器调用时,下面这些事情会自动完成:
- 一个全新的对象会凭空创建(就是被构建)
- 这个新构建的对象会被接入原形链([[Prototype]]-linked)
- 这个新构建的对象被设置为函数调用的
this
绑定 - 除非函数返回一个它自己的其他对象,否则这个被
new
调用的函数将自动返回这个新构建的对象。
考虑这段代码:
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2
复制代码
通过在前面使用 new
来调用 foo(..)
,我们构建了一个新的对象并把这个新对象作为 foo(..)
调用的 this
。 new 是函数调用可以绑定 this 的最后一种方式,我们称之为 new 绑定(new binding)。
3 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
和 call
/apply
不能同时使用,所以 new foo.call(obj1)
是不允许的,也就是不能直接对比测试 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
复制代码
如果你传递 null
或 undefined
作为 call
、apply
或 bind
的 this
绑定参数,那么这些值会被忽略掉,取而代之的是 默认绑定 规则将适用于这个调用。
所以我们可以按照优先顺序来总结一下从函数调用的调用点来判定 this
的规则了。按照这个顺序来问问题,然后在第一个规则适用的地方停下。
-
函数是通过
new
被调用的吗(new 绑定)?如果是,this
就是新构建的对象。var bar = new foo()
-
函数是通过
call
或apply
被调用(明确绑定),甚至是隐藏在bind
硬绑定 之中吗?如果是,this
就是那个被明确指定的对象。var bar = foo.call( obj2 )
-
函数是通过环境对象(也称为拥有者或容器对象)被调用的吗(隐含绑定)?如果是,
this
就是那个环境对象。var bar = obj1.foo()
-
否则,使用默认的
this
(默认绑定)。如果在strict mode
下,就是undefined
,否则是global
对象。var bar = foo()