灏天阁

一文带你理解this到底指向谁

· Yin灏

知识点

  • this 的绑定是在什么阶段完成的
  • this 的绑定规则
  • this 绑定的优先级
  • 特殊环境的 this 指向

this 的绑定是在什么阶段完成的

如果你了解 JavaScript 执行上下文(对 JS 执行上下文不了解的同学推荐先看我的另一篇文章《理解执行上下文》),那么你一定能脱口而出:this 的绑定是在执行阶段完成。我们先来回顾一下不同的执行上下文是如何被创建出来的:

  1. JavaScript 引擎先创建全局执行上下文(this 在此阶段被绑定到 GlobalObject)
  2. 全局执行上下文入栈
  3. 代码开始执行,遇到函数调用,创建函数执行上下文(this 在此阶段根据上下文动态绑定)
  4. 执行函数内部代码,执行完毕,函数执行上下文出栈

我们都知道全局执行上下文的 this 指向的是 window 对象(浏览器下)。那函数的执行上下文,this 绑定的依据是什么?答案是:调用位置

调用位置就是函数在代码中被调用的位置(而不是声明的位置)。

接下来我们看看在函数的执行过程中,调用位置如何决定 this 的绑定对象。

this 的绑定规则

函数执行上下文中,this 的绑定遵循四条绑定规则:默认绑定、隐式绑定、显示绑定以及 new 绑定。我们分别来看一下:

默认绑定

最常见的函数调用方式是:独立函数调用。这条规则可以看作是无法应用其他规则时的默认规则。在非严格模式下:函数中的 this 默认指向 window

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

上面的代码中,foo()函数是不带任何修饰的函数引用进行调用的(独立函数调用),所以只能使用默认绑定,因此指向 window。而全局执行上下文中的变量,实际上都挂在 window 对象上,所以这里的 this.a === window.a,结果是 2。

如果使用严格模式,则不能将全局对象用于默认绑定,因此this 会绑定到 undefined

function foo() {
  "use strict";
  console.log(this.a);
}
var a = 2;
foo(); // undefined

另外需要注意的是:虽然 this 的绑定规则完全取决于调用位置,但是只有函数运行在非严格模式下时,默认绑定才能绑定到全局对象。在严格模式下调用则不影响默认绑定。

function foo() {
  console.log(this.a); // 在非严格模式下运行
}
var a = 2(function () {
  "use strict";
  foo(); // 2  在严格模式下调用
})();

这个例子中,函数 foo()虽然是在严格模式下调用的,但是 JS 文件头部或者 foo 函数内部并没有声明严格模式,所以 this 其实还是运行在非严格模式下,因此 this 仍然会应用默认绑定规则,绑定到全局对象上。

小结:默认规则下,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。

隐式绑定

不知道你有没有对下面代码中的 this 指向有过疑问?其实这就是最常见的隐式绑定规则。

function foo() {
  console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo,
};
obj.foo(); // 2

// 或者这么写,但结果其实都一样
var obj = {
  a: 2,
  foo: function () {
    console.log(this.a);
  },
};
obj.foo(); // 2

了解过执行上下文的同学都知道,函数是存在单独开辟的内存中,变量只是指向了这个函数的引用地址。我们观察一下上面两种写法中 foo 函数的声明方式:无论 foo 函数是被当作引用属性添加到 obj 中,还是直接在 obj 中定义再添加为引用属性。实际上都是一回事:函数在单独的内存中存放,obj 的 foo 属性只是指向了它的引用地址。

但是 this 的指向是由调用位置决定的,当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。上面的代码中,当 foo()被调用时,它的前面有对 obj 的引用,所以 this 会被绑定到 obj 上。

需要注意的是:对象属性引用链中只有最后一层在调用位置中起作用。比如下面的例子:

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

隐式丢失

隐式绑定规则下,最容易遇到的问题就是隐式丢失,这个问题也是面试当中经常考察的一个知识点。被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定规则,把 this 绑定到全局对象或者 undefined 上(取决于是否是严格模式)

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

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

var bar = obj.foo;
var a = "oops, global";
bar(); // 'oops, global'

观察上面的代码,虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,所以此时的 bar()其实是一个不带任何修饰的函数调用,因此它应用的还是默认绑定规则。

再来看另一种常见的隐式丢失现象,下面的代码在初学前端时,很容易做错:

function foo() {
  console.log(this.a);
}
function doFoo(fn) {
  fn(); // 调用位置
}
var obj = {
  a: 2,
  foo: foo,
};
var a = "oops,global";
doFoo(obj.foo); // 'oops,global'

参数的传递其实就是一种隐式赋值,fn 其实就是 foo 函数的引用,跟上面一样,它引用的是 foo 函数本身。而 fn()调用时依然是一个不带任何修饰的函数,所以应用的仍然是默认绑定规则。

另外如果把函数传入到内置函数中,例如定时器函数,也是一样的道理。下面的代码和上面的例子结果一样:

function foo() {
  console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo,
};
var a = "oops,global";
setTimeout(obj.foo, 100); // 'oops,global'

显示绑定

在上面的隐式绑定例子里,我们必须在一个对象(obj)内部包含一个指向函数的属性(foo),并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。那有没有一种办法可以不用在对象内部包含函数引用,又能在某个对象上强制调用函数呢?JavaScript 给我们提供了 call、apply、bind 方法来实现。

这三个函数的第一个参数是一个对象,它指定了 this 的绑定对象。我们看下面的代码:

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

var obj = {
  a: 2,
};
foo.call(obj); // 2
// 或者使用apply
foo.apply(obj); // 2
// 或者使用bind
foo.bind(obj)(); // 2

通过 call、apply、bind 这种强制指定 this 的方式,叫做显示绑定。通过上面的代码我们可以观察到一个明显的区别,就是 bind 函数后面有一对括号。在 JavaScript 当中,当我们调用一个函数时,我们习惯称之为函数调用,函数处于一个被动的状态。而 call 和 apply 让函数从被动变主动,函数能主动选择自己的上下文,这种写法我们称之为函数应用

需要注意的是:如果使用上面三种函数来改变 this 指向时,如果第一个参数为 null 或者 undefined,那么 this 将指向全局对象

call、apply、bind 的异同

是否立即执行 入参形式
call (thisArg, arg1,arg2,…)
apply (thisArg, [arg1,arg2,…])
bind 否,返回一个全新的绑定函数,所以需要加()才能调用 (thisArg, arg1,arg2,…)

上面是 call、apply、bind 三种函数的异同点,也是面试时的高频考点,大家还是要牢记于心的。

new 绑定

this 绑定规则的最后一条则是 new 绑定。new 操作符,会把 this 指向构造函数所创建的实例对象上。在《JavaScript new 运算符做了什么》这篇文章里,有讲过 new 的作用和实现原理。其中有一步就是绑定 this 的指向。应用的就是 new 绑定规则。感兴趣的小伙伴可以借此机会复习一下 new 相关的知识点。

this 绑定的优先级

如果一个函数调用存在多种绑定方式,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 绑定和显示绑定的优先级。由于 call 和 apply 的特性,new 和 call/apply 无法一起使用,所以我们使用 bind 测试它俩的优先级:

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

通过代码我们发现,new 操作符改变了通过 bind 函数显示绑定的 this。所以 new 绑定 > 显示绑定。

最终结论:new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定

特殊环境的 this 指向

上面的四条规则,可以判断所有正常的函数。但是 ES6 中出现了一个特殊的函数类型:箭头函数箭头函数中不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this。最重要的一点:箭头函数的 this 无法被修改

function foo() {
  return (a) => {
    // 返回一个箭头函数
    console.log(this.a); // this继承自foo()
  };
}
var obj1 = { a: 2 };
var obj2 = { a: 3 };
var bar = foo.call(obj1);
bar(); // 2
bar.call(obj2); // 2

foo 函数内部 return 了一个箭头函数。当 foo 被调用时,箭头函数会自动将 this 绑定到外层作用域 foo 上,由于 foo 函数的 this 通过显示绑定规则绑定到了 obj1 上,所以 bar(引用箭头函数)的 this 也会绑定到 obj1 上,并且无法通过其他方式修改。

小结

有些文章会列举一大堆不同场景下 this 的指向,让人记得眼花缭乱,虽然能应付面试,但实际上并不能让人真正掌握 this。其实 this 的指向,并没有想象中的那么复杂,也没有传说中的神乎其神,只要掌握绑定规则,就能在面试题的各种障眼法下,找到它的真身。全文总结成三条如下,希望能帮到正在学习 JavaScript 的你。

  1. 全局执行上下文,this 指向全局对象
  2. 函数执行上下文,根据四条标准规则(new 绑定>显示绑定>隐式绑定>默认绑定)判断
  3. 箭头函数下,指向它的外层作用域

引用

本文内容参考了以下书籍,感兴趣的同学可以购买正版图书进行阅读。

《你不知道的 JavaScript——上卷》——作者:KYLE SIMPSON

- Book Lists -