业界没有 当前执行上下文 的叫法,但是笔者私自把 this 的指向理解为执行时所指向的执行上下文。
在理解 this 的绑定过程之前,首先要理解 this 的调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。
而要理解 this 的调用位置,最重要的是要 分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。
function baz() {// 当前调用栈是:baz// 因此,当前调用位置是全局作用域console.log('baz');bar(); // <-- bar 的调用的位置}function bar() {// 当前调用栈是 baz -> bar// 因此,当前调用调用位置在 baz 中console.log('bar');foo(); // <-- foo 的调用位置}function foo() {// 当前调用栈是 baz -> bar -> foo// 因此,当前调用位置在 bar 中console.log('foo');}baz(); // <-- baz 的调用位置
注意我们是如何从调用栈中分析出真正的调用位置,因为它决定了 this 的绑定。
函数的执行过程中调用位置决定 this 的 绑定对象。
你必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释这四条规则,然后解释多条规则都可用时它们的优先级如何排列。
(调用栈) => (调用位置) => (绑定规则) => 规则优先级;
首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。
🌰 代码示例:
function foo() {console.log(this.a);}// 声明在全局作用域中的变量就是全局对象的一个同名属性// 相当于 window.a = 2var a = 2;// 调用 foo 函数时 this.a 被解析成了全局变量 a// 因为在本例中,函数调用时应用了 this 的默认绑定// 因此 this 指向全局对象 global objects 或 window objects// 分析调用位置来获知 foo 是如何调用的// foo 函数直接使用不带任何修饰的函数引用进行调用,因此只能使用默认绑定,无法应用其他规则foo();// 2
如果使用严格模式(Strict Mode),则不能将全局对象用于默认绑定,因此 this 会绑定到 undefined。
function foo() {'use strict';console.log(this.a);}var a = 2;foo();// TypeError:this is undefined
这里有一个微妙但是非常重要的细节,虽然 this 的绑定规则完全取决于调用位置,但是只有 foo() 运行在非严格模式下时,默认绑定才能绑定到全局对象;在严格模式下调用 foo 则不受默认绑定影响。
function foo() {console.log(this.a);}var a = 2;(function foo() {'use strict';foo(); // 2})();
⚠️ 注意:通常来说你不应该在代码中混合使用严格模式和非严格模式。整个程序要么严格要么非严格。然而,有时候你可能会用到第三方库,其严格程度和你代码有所不同,因此一定要注意这类兼容性细节。
另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。
🌰 代码示例:
function foo() {console.log(this.a);}const container = {a: 2,foo: foo,};container.foo(); // 2
首先需要注意的是 foo 的声明方式,及其之后是如何被当作引用属性添加到 container 中的。但是无论是直接在 container 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于 container 对象。
然而,调用位置会使用 container 上下文来引用函数,因此你可以说函数被调用时 container 对象 拥有 或者 包含 它。
无论你如何称呼这个模式,当 foo 被调用时,它的前面确实加上了对 container 的引用。当函数引用有上下文时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo 时 this 被绑定到 container 上,因此 this.a 和 container.a 是一样的。
💡 对象属性引用链中只有上一层或最后一层在调用位置中起作用。
function foo() {console.log(this.a);}var obj2 = {a: 42,foo: foo,};var obj1 = {a: 2,obj2: obj2,};obj1.obj2.foo(); // 42
一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上(这取决于是否是严格模式)。
🌰 代码示例:
function foo() {console.log(this.a);}const container = {a: 2,foo: foo,};// 函数别名const bar = container.foo;// a 是全局对象的属性const a = 'Hello world!';bar();// "Hello world!"
📍 虽然 bar 是 container.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的 bar 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时。
🌰 代码示例:
function foo() {console.log(this.a);}function bar(fn) {// fn 其实引用的是 foofn(); // <--调用位置}var container = {a: 2,foo: foo,};// a 是全局对象的属性var a = 'Hello world!';bar(container.foo);// "Hello world!"
参数传递其实是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上个示例一样。
如果把函数传入语言内置的函数而不是传入你自己声明的函数,结果是一样的,没有区别。
function foo() {console.log(this.a);}const container = {a: 2,foo: foo,};// a 是全局对象的属性var a = 'Hello world!';setTimeout(container.foo, 100);// 'Hello world!'
回调函数丢失 this 绑定是非常常见的。
除此之外,还有一种情况 this 的行为会出乎我们意料:调用回调函数的函数可能会修改 this。在一些流行的 JavaScript 库中事件处理器会把回调函数的 this 强制绑定到触发事件的 DOM 元素上。这在一些情况下可能很有用,但是有时它可能会让你感到非常郁闷。遗憾的是,这些工具通常无法选择是否启用这个行为。
无论是哪种情况,this 的改变都是意想不到的,实际上你无法控制回调函数的执行方式,因此就没有办法控制调用位置以得到期望的绑定。之后我们会介绍如何通过固定 this 来修复这个问题。
就像我们刚才看到的那样,在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 隐式绑定到该对象上。
JavaScript 提供了 apply、call 和 bind 方法,为创建的所有函数 绑定宿主环境。通过这些方法绑定函数的 this 指向称为 显式绑定。
硬绑定可以解决之前提出的丢失绑定的问题。
🌰 代码示例:
function foo() {console.log(ths.a);}const container = {a: 2,};var bar = function () {foo.call(container);};bar();// 2setTimeout(bar, 100);// 2// 硬绑定的 bar 不可能再修改它的 thisbar.call(window);// 2
我们创建了函数 bar,并在它的内部手动调用了 foo.call(container) ,因此强制把 foo 的 this 绑定到了 container 。无论之后如何调用函数 bar,它总会手动在 container 上调用 foo。这种绑定是一种显式(手动)的强制绑定,因此我们称之为硬绑定。
第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为 上下文(context),其作用和 bind 一样,确保你的回调函数使用指定的 this 。
function foo(item) {console.log(this.title, item);}const columns = {title: 'No:',}[// 调用 foo 时把 this 绑定到 columns(1, 2, 3)].forEach(foo, columns);// No:1 No:2 No:3
这些函数实际上就是通过 call 或者 apply 实现了显式绑定,这样代码会更加优雅。
在 JavaScript 中,构造函数只是使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类,实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。
举例来说,思考一下 Number() 作为构造函数时的行为,ES5.1 中这样描述它:
15.7.2 Number 构造函数
当 Number 在
new表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。
所以,包括内置对象函数在内的所有函数都可以用 new 来调用,这种函数调用被称为 构造函数调用。这里有一个重要但是非常细微的区别:实际上并不存在所谓的构造函数,只是对于函数的 构造调用。
🎉 使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
this 所引用,即 this 指向新构造的实例new 表达式中的函数调用会自动返回这个新对象🎯 模拟过程:
function objectFactory(constructor, ...rest) {// 创建空对象,空对象关联构造函数的原型对象const instance = Object.create(constructor.prototype);// 执行对象类的构造函数,同时该实例的属性和方法被 this 所引用,即 this 指向新构造的实例const result = constructor.apply(instance, rest);// 判断构造函数的运行结果是否对象类型if (result !== null && /^(object|function)$/.test(typeof result)) {return result;}return instance;}
解剖内部操作后,我们能得出结论 new 操作符是为了实现该过程的一种语法糖。
上文介绍了函数调用中 this 绑定的四条规则,你需要做的就是找到函数的调用位置并判断应用哪条规则。但是,如果某个调用位置应用多条规则,则必须给这些规则设定优先级。
毫无疑问,默认绑定的优先级是四条规则中最低的,所以我们先不考虑它。
显式绑定 > 构造调用绑定 > 隐式绑定;
function foo() {console.log(this.a);}const container1 = {a: 1,foo: foo,};const container2 = {a: 2,foo: foo,};container1.foo();// 1container2.foo();// 2container1.foo.call(container2);// 2container2.foo.call(container1);// 1
可以看到,显式绑定优先级更高,也就是说在判断时应当先考虑是否可以存在显式绑定。
function foo(something) {this.a = something;}const container1 = {foo: foo,};const container2 = {};container1.foo(2);console.log(container1.a);// 2container1.foo.call(container2, 3);console.log(container2.a);// 3var bar = new container1.foo(4);console.log(container1.a);// 2console.log(bar.a);// 4
可以看到 new 绑定比隐式绑定优先级高。但是 new 绑定和显式绑定谁的优先级更高呢?
new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行测试。但是我们可以使用硬绑定来测试他俩的优先级。
在看代码之前先回忆一下硬绑定是如何工作的。Function.prototype.bind 会创建一个新的包装函数,这个函数会忽略它当前的 this 绑定(无论绑定的对象是什么),并把我们提供的对象绑定到 this 上。
这样看起来硬绑定(也是显式绑定的一种)似乎比 new 绑定的优先级更高,无法使用 new 来控制 this 绑定。
function foo(something) {this.a = something;}var container1 = {};var bar = foo.bind(container1);bar(2);console.log(container1.a);// 2var baz = new bar(3);console.log(container1.a);// 2console.log(baz.a);// 3
如果将 null 或 undefined 作为 this 的绑定对象传入 call、apply 或 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。
function foo() {console.log(this.a);}const a = 2;foo.call(null);// 2
此类写法常用于 apply 来展开数组,并当作参数传入一个函数,类似地,bind 可以对参数进行柯里化(预先设置一些参数)。
function foo(a, b) {console.log('a:' + a + ',b:' + b);}// 把数组展开成参数foo.apply(null, [2, 3]);// a:2, b:3// 使用 bind 进行柯里化var bar = foo.bind(null, 2);bar(3);// a:2, b:3
这两种方法都需要传入一个参数当作 this 的绑定对象。如果函数并不关心 this 的话,你 仍然需要传入一个占位值,这时 null 可能是一个不错的选择。
硬绑定这种方式可以把 this 强制绑定到指定的对象(除了使用 new 时),防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this。
如果可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。
if (!Function.prototype.softBind) { Function.prototype.softBind = function(obj) {var fn = this;// 捕获所有 curried 参数var curried = [].slice.call( arguments, 1 ); var bound = function() {return fn.apply((!this || this === (window || global)) ?obj : thiscurried.concat.apply( curried, arguments )); };bound.prototype = Object.create( fn.prototype );return bound; };}
如下列出四种方法可以在编码中改变 this 指向。
_this = thisapply、call 和 bindnew 实例化一个对象箭头函数并不是使用 function 关键字定义的,而是使用被称为胖箭头的操作符 => 定义的。箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this 的指向。并且,箭头函数拥有静态的上下文,即一次绑定之后,便不可再修改。
this 指向的固定化,并不是因为箭头函数内部有绑定 this 的机制,实际原因是箭头函数根本没有自己的 this,导致内部的 this 就是外层代码块的 this。正是因为它没有 this,所以也就不能用作构造函数。
function foo() {// 返回一个箭头函数return (a) => {// this 继承自 foo()console.log(this.a);};}const container1 = { a: 1 };const container2 = { a: 2 };const bar = foo.call(container1);bar.call(container2);// 1
foo 内部创建的箭头函数会捕获调用时 foo 的 this。由于 foo 的 this 绑定到 container1,bar(引用箭头函数)的 this 也会绑定到 container1,箭头函数的绑定无法被修改。
箭头函数可以像 bind 一样确保函数的 this 被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的 this 机制。实际上,在 ES6 之前我们就已经在使用一种几乎和箭头函数完全一样的模式。
虽然 const self = this 和箭头函数看起来都可以取代 bind,但是从本质上来说,它们想替代的是 this 机制。
如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 const self = this 或者箭头函数来否定 this 机制,那你或许应当:
this 风格的代码this 风格,在必要时使用 bind,尽量避免使用 const self = this 和箭头函数call、apply、bind 间接调用