JavaScript 中的老朋友: this 的绑定

475 阅读3分钟

this会在执行的上下文中绑定一个对象,有时候绑定全局对象,有时绑定的是某个对象,所以在什么情况下进行什么绑定,比较迷惑,于是打算写这篇文章梳理一下脉络。

先说结论:this的绑定取决于函数的直接调用位置

1. 调用位置

首先要理解什么是调用位置:调用位置就是函数在代码中调用的位置,而不是函数声明的位置

function foo{
    console.log('foo');
    bar(); // <-- bar()的调用位置
}
function bar{
    console.log('bar');
    baz(); // <-- baz()的调用位置
}

function baz{
    console.log('baz');
}

foo(); // <-- foo()的调用位置

2. 绑定规则

判断this是如何绑定,首先找到函数的调用位置,然后对比下面的规则,看符合哪一条,且这些规则具有不同的优先级。

2.1 默认绑定

首先,最常用的函数调用类型是:独立函数调用。这条规则可以看作是不符合其他规则时的默认规则。

场景 1:独立函数的调用

因为this没有绑定到任何对象,所以默认绑定到全局。

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

const a = 2;

foo(); // 2

场景 2:将函数作为参数传入另一个函数时

这样的绑定,本质上仍然是独立函数的调用。

function foo(fn) {
  fn();
}

function bar() {
  console.log(this.a); // window
}
var a = 8;

foo(bar); // 8

但,如果使用letconst,或是严格模式下,隐式绑定会丢失。

let a = 8;

function foo(fn) {
  fn();
}

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

foo(bar); // undefined

2.2 隐式绑定

第二条需要考虑的规则是调用位置是否由上下文对象,或者说,是否被某个对象拥有或包含

场景 1:通过对象调用函数

foo()被调用时,它的落脚点指向obj对象,调用位置会使用obj上下文来引用函数,因此也可以称为函数被调用时obj对象“拥有”或“包含”它。
当函数引用有上下文时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象,因此this.aobj.a是一样的。

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

const obj = {
  a: 2,
  foo: foo  // foo 指向 foo(),被 obj 包含/拥有
}

obj.foo();  // 2

场景 2:多层对象调用函数

对象属性的引用链中只有最顶层(最后一层)会影响调用位置。

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

const obj2 = {
  a: 42,
  foo: foo
}

const obj1 = {
  a: 2,
  obj2: obj2
}

obj1.obj2.foo();  // 42

场景 3:隐式丢失

隐式绑定的this很容易丢失绑定对象。

下面这个例子,虽然barobj.foo的一个引用,但实际上它引用的是foo()函数本身,函数调用位置是bar,它没有绑定任何对象,因此是默认绑定。

// 非严格模式
function foo() {
  console.log(this.a);
}

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

// 函数别名
var bar = obj.foo;
var a = 'hello';

bar();  // hello

另外一种丢失的情况,发生在传入回调函数时,这也包括我们常用的定时setTimeout

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

function bar(fn) {
   // fn引用的是foo
  fn();  // <-- 调用位置
}

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

// 函数别名

var a = 'hello??';

bar(obj.foo);  // hello??

回调函数丢失this绑定非常常见,甚至还有可能修改this的绑定。

2.3 显式绑定

隐式绑定的实现,必须是在一个对象内部包含一个指向函数的属性,然后通过该属性间接地引用函数

如果我们不想这样做呢?可以使用显式绑定。

方法1:call(..) 和 apply(..)

第一个参数是对象,它们会把this绑定到这个对象,然后调用函数时指向这个对象。

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

var obj={
  a: 2
}

foo.call(obj);  // 输出:2  恭喜,成功绑上

方法2:硬绑定

如果我们希望一个函数总是显示的绑定到一个对象上,可以怎么做呢?

可以用硬绑定的方法。这种绑定是一种显式的强制绑定,之后无论如何调用函数,它的this指向都不会修改,所以称之为硬绑定。

  • 方法1:创建一个包裹函数,传入参数并返回这些值
function foo(arg) {
  console.log(this.a, arg);
  return this.a + arg;
}

obj = {
  a: 5
};

var bar = function () {
  return foo.apply(obj, arguments)
}

var b = bar(3);  // 5, 3
console.log(b);  // 8
  • 方法2:利用辅助函数手动硬绑定:
function foo() {
  console.log(this.name);
}

var obj = {
  name: "快来绑定我"
}

function bind(fn, obj) {
  return function() {
    return fn.apply(obj, arguments);
  }
}

var bar = bind(foo, obj);

bar(); // 快来绑定我

因为硬绑定非常常见,所以ES5也提供了内置的方法Function.prototype.bind()

  • 方法3:使用Function.prototype.bind
function foo() {
  console.log(this.name);
}

var obj = {
  name: "快来绑定我"
}

var bar = foo.bind(obj);

bar(); // 快来绑定我
bar(); // 快来绑定我

API调用的“上下文”

第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供一个可选的参数,通常成为"上下文"(context),它的作用和bind(..)一样,确保你的回调函数使用指定的this

比如forEach(..)函数:

function foo(el) {
  console.log(el, this.id);
}
var obj = {
  id: "个"
};

[1, 2, 3].forEach(foo, obj);
// 1 '个' 
// 2 '个' 
// 3 '个'

2.4 new 绑定

new关键字很容易让人想到“类”,但确切来说,它的作用不是初始化一个类,也不会实例化一个类。实际上,被new调用的函数,只是一个被new操作符调用的普通函数而已。

使用new关键字来调用函数时,会执行如下的操作:

  1. 创建(或者说构造)一个全新的对象
  2. 这个新对象会被执行[[ 原型 ]]连接
  3. 这个新对象会绑定到函数调用的this上(this的绑定在这个步骤完成)
  4. 如果函数没有返回其他对象,new表达式中的函数调用会返回这个新对象
function foo(name) {
  console.log(this);  // foo {}
  this.name = name
  console.log(this.name);  // Nic
}

var bar = new foo('Nic');  // 绑定完成
console.log(bar.name);  // Nic

3. 优先级

优先级总结:

new绑定 > 显示绑定(bind)> 隐式绑定 > 默认绑定

  1. 默认规则的优先级最低
  2. 显示绑定 > 隐式绑定
  3. new 绑定优 > 隐式绑定
  4. new 绑定 > bind绑定

论证过程参考:
《你不知道的JavaScript》上卷 p91-95

coderwhy 的文章 《前端面试之彻底搞懂this指向》

(题外话,他这篇文章的内容,除了例子,基本上都来自上面那本书hh )

4. 绑定的例外

当然,总是会有规则之外的例外。

忽略显示绑定

如果在显示绑定中,我们传入一个null或者undefined,那么这个显示绑定会被忽略,而会使用默认绑定:

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

var a = 2;
foo.call(null); // 2
foo.call(undefined); // 2

为什么会想要传入一个null呢?

在某些情况下,我们要用aplly(..)来展开一个数组,或是用bind(..)做点什么。但这俩函数都需要传入一个this的绑定对象,但我们不太关心this绑定点啥,于是需要null这么一个绝妙的占位符。

间接引用

另外一种情况,当创建一个函数的间接引用时,会应用默认绑定规则。

间接引用最容易发生在赋值时:

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

var a = 2;
var o = { a: 3, foo: foo};
var p = { a: 4};

o.foo(); // 3

// foo函数被直接调用,那么是默认绑定 👇
(p.foo = o.foo)();  // 2 

ES6箭头函数

ES6中的箭头函数并不会使用以上这四条绑定规则,它有自己的想法。它会根据当前的词法作用域来决定this。具体地说,箭头函数会继承外层调用的this绑定(无论this绑定到什么)。这和之前常用的self = this机制一致。

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

var obj1 = {
  a: 1
}

var obj2 = {
  a: 2
}

var bar = foo.call(obj1);  // 1

bar.call(obj2); // output还是 1

foo()内部创建的箭头函数会捕获调用foo()时的this。因为foo()绑定到obj1,相应地,bar(引用箭头函数)的this也会绑定到obj1,且硬绑定无法修改。

箭头函数也经常用在回调函数中,比如计时器:

function foo() {
  setTimeout (()  => {
    console.log(this.a);
  },1000)
}

var obj = {
  a: 2
}

foo.call(obj);  // 1秒后打印结果: 2

练手题目

题目摘自 coderwhy 的文章 《前端面试之彻底搞懂this指向》

  1. 第一题
var name = "window";
var person = {
  name: "person",
  sayName: function () {
    console.log(this.name);
  }
};
function sayName() {
  var sss = person.sayName;
  sss(); 
  person.sayName(); 
  (person.sayName)(); 
  (b = person.sayName)(); 
}
sayName();
  1. 第二题
var name = 'window'
var person1 = {
  name: 'person1',
  foo1: function () {
    console.log(this.name)
  },
  foo2: () => console.log(this.name),
  foo3: function () {
    return function () {
      console.log(this.name)
    }
  },
  foo4: function () {
    return () => {
      console.log(this.name)
    }
  }
}

var person2 = { name: 'person2' }

person1.foo1(); 
person1.foo1.call(person2); 

person1.foo2();
person1.foo2.call(person2);

person1.foo3()();
person1.foo3.call(person2)();
person1.foo3().call(person2);

person1.foo4()();
person1.foo4.call(person2)();
person1.foo4().call(person2);

反正目的就是要把人头搞晕