[核心概念] 一文说透 JS 中的 this 绑定规则

631 阅读10分钟

this 绑定规则

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

面试题

  • 说说你了解的如何分析 this 指向
  • 箭头函数的 this 了解吗
  • call/apply/bind/new 分别跟 this 的关联
  • 你能实现上述原生函数并讲解原理吗

这是干什么的?为什么要用?

上一篇我们介绍了下什么是 this 概念,并简单介绍了如何分析this指向

简单总结如下:

  • this 是 javascript 中的一个关键字,它提供了一种更优雅的方式来 隐式“传递” 一个对象引用,因此可以将 API 设计得更加简洁并且易于复用
  • this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
  • 当一个函数被调用时,会创建一个执行上下文【关联概念】。这个记录会包含函数在哪里被调用(调用栈,执行栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。执行上下文的创建过程的一环就是 this Binding
  • this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被如何调用。
  • 另外我们可以用 console.trace() 来输出一个堆栈跟踪。

记住有些方法看不懂跳过没关系,文章不是线性的,你可以记录下不清楚的地方,先看下面的,未知永远存在,别害怕。

下面来详细介绍下各种绑定规则

1. 默认绑定

独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

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

这是使用了默认绑定 this指向全局对象 浏览器是 window; node是 global或 {}

foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。

2. 隐式绑定

这条规则主要看如下几个关键点。

  • 2.1 调用位置是否有上下文对象 当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象 观察下面代码
function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
obj.foo(); // 2

因为调用 foo()this 被绑定到 obj,因此 this.aobj.a 是一样的。

  • 2.2 对象属性引用链中只有最后一层会影响调用位置 如下例
function foo() {
    console.log(this.a);
}
var obj2 = {
    a: 42,
    foo: foo
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo();     // 42
  • 2.3 隐式丢失 一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上。
function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
var bar = obj.foo;                // 函数别名!
var a = "global";                 // a 是全局对象的属性 
bar(); // "global"

虽然 bar 是 obj.foo 的一个引用, 但是实际上,它引用的是 foo 函数本身,原因是栈内存和堆内存【关联概念】因此此时的 bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

  • 2.3 回调函数丢失this绑定的常见问题
function foo() {
    console.log(this.a);
}
function doFoo(fn) {
    // fn 其实引用的是 foo 
    fn();                         // <-- 调用位置!
}
var obj = {
    a: 2,
    foo: foo
};
var a = "global";                 // a 是全局对象的属性 
doFoo(obj.foo);                   // "global"

====================================================
// setTimeout() 等回调函数同样会发生类似问题
function setTimeout(fn, delay) {
    // 等待delay毫秒
    fn();                         // <-- 调用位置!
}

这里把函数引用作为形式参数的传递,是回调函数常用方式。

3. 显式绑定

就像我们刚才看到的那样,在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上

那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?

具体点说,可以使用函数的 call(..)apply(..) 方法这两个方法是如何工作的呢? 它们的第一个参数是一个对象,它们会把这个对象绑定到 this,接着在调用函数时指定这个 this。因为你可以直接指定 this 的绑定对象,因此我们称之为显式绑定

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

通过 foo.call(..), 我们可以在调用 foo强制把它的 this 绑定到 obj 上。 可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。

下面提供2种解决方案:

  • 3.1 硬绑定
function foo() {
    console.log( this.a );
}

var obj = {
    a: 2
};

var bar = function() {
    foo.call( obj );
};

bar();                           // 2
setTimeout( bar, 100 );          // 2

// 硬绑定的bar不可以再修改它的this
bar.call( window );              // 2

我们创建函数bar(),并在它的内部手动调用foo.call(obj),强制把foo的this绑定到了obj。这样就不可以再用call修改它的this。

硬绑定的典型应用场景就是创建一个包裹函数传入所有的参数并返回接收到的所有值:

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     a是obj的a, 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 的上下文并调用原始函数。 这样便实现了硬绑定。

  • 3.2 API调用的"上下文"

JS许多内置函数提供了一个可选参数,被称之为“上下文”(context),其作用和bind(..)一样,确保回调函数使用指定的this

这些函数实际上通过call(..)apply(..)实现了显式绑定

注意这里说的『上下文』并不是 执行上下文 只是参数的叫法。

4. new绑定

JavaScript 中 new 的机制实际上和面向类的语言完全不同。首先我们重新定义一下 JavaScript 中的“构造函数”。

在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。

使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  1. 创建一个空的简单JavaScript对象(即{});
  2. 这个新对象会被执行 [[原型]] 连接 【关联概念】。或者说就是链接该对象(设置该对象的constructor)到另一个对象 ;
  3. 步骤1 新创建的对象作为this的上下文
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象

见下面例子:

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

使用 new来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定

this 绑定优先级

上面说了4种绑定规则,下面介绍下这4种同时出现时的优先级。重点是以下几点

1. 毫无疑问,默认绑定的优先级是四条规则中最低的

2. 隐式绑定 VS 显式绑定 => 显式绑定优先级更高

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   显式绑定优先级更高

3. new 绑定 VS 隐式绑定 => new 绑定优先级高

function foo(something) {   // sth 是 something的简写
    this.a = something;
}
var obj1 = {
    foo: foo
};
var obj2 = {};
obj1.foo(2);                //    this指向obj1 this.a = sth 相当于obj.a = 2
console.log(obj1.a);        // 2

obj1.foo.call(obj2, 3);     //    显示将this绑定到obj2上并传入参数3
console.log(obj2.a);        // 3  由于显示 > 隐式 this.a = sth 相当于 obj2.a = 3

var bar = new obj1.foo(4);  //    发生new调用 this指向返回的新对象 bar
console.log( bar.a );       // 4  this.a = sth 相当于 bar.a = 4

4. new 绑定 VS 显式绑定

new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接 进行测试。但是我们可以使用硬绑定来测试它俩的优先级。

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

var baz = new bar(3);
console.log(obj1.a);       // 2 
console.log( baz.a );      // 3 new 修改了硬绑定(到obj1的)调用bar(..)中的this

简单来说,bind中这段代码会判断硬绑定函数是否是被 new 调用,如果是的话就会使用新创建的 this 替换硬绑定的 this。

小结

判断this可以按照下面的顺序来进行判断:

  1. 函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。var bar = new foo() this 指向 bar

  2. 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是 指定的对象。var bar = foo.call(obj2)

  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。var bar = obj1.foo()

  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到 全局对象。var bar = foo()

绑定例外

  1. 如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则 如:
function foo() {
    console.log(this.a);
}
var a = 2;
foo.call(null);          // 2
  1. 间接引用 你有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。如
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()。根据我们之前说过的,这里会应用默认绑定。

  1. 软绑定 之前我们已经看到过,硬绑定这种方式可以把 this 强制绑定到指定的对象(除了使用 new 时),防止函数调用应用默认绑定规则。

问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this。

如果可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相 同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。这就是软绑定

this词法 箭头函数

这也是绑定例外的一种

箭头函数不仅仅是编写简洁代码的“捷径”。它还具有非常特殊且有用的特性。

JavaScript 充满了我们需要编写在其他地方执行的小函数的情况。例如:

  • arr.forEach(func) —— forEach 对每个数组元素都执行 func。
  • setTimeout(func) —— func 由内建调度器执行。

JavaScript 的精髓在于创建一个函数并将其传递到某个地方。在这样的函数中,我们通常不想离开当前上下文。这就是箭头函数的主战场啦。

箭头函数没有 this。如果访问 this,则会从外部获取。

let group = {
  title: "Our Group",
  students: ["John", "Pete", "Alice"],

  showList() {
    this.students.forEach(
      student => alert(this.title + ': ' + student)
    );
  }
};
group.showList();

这里 forEach 中使用了箭头函数,所以其中的 this.title 其实和外部方法 showList 的完全一样。那就是:group.title。

那么我们就要注意几点

  • 不能对箭头函数进行 new 操作。不具有 this 自然也就意味着另一个限制:箭头函数不能用作构造器(constructor)。不能用 new 调用它们。
  • 箭头函数也没有 arguments 变量。
  • 箭头函数是针对那些没有自己的“上下文”,但在当前上下文中起作用的短代码的。如回调函数中,例如事件处理器或者定时器

总结

如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象

  1. 由new调用? 绑定到新创建的对象。

  2. 由call或者apply(或者bind)调用? 绑定到指定的对象。

  3. 由上下文对象调用? 绑定到那个上下文对象。

  4. 默认: 在严格模式下绑定到undefined,否则绑定到全局对象。

  5. ES6 中的箭头函数并不会使用四条标准的绑定规则而是根据当前的词法作用域来决定 this,具体来说,箭头函数会继承外层函数调用的 this 绑定( 无论 this 绑定到什么)。

原理是什么?

我们深刻理解了这个概念之后,可以探究下它的实现(面试也经常问到这方面源码),可能有人觉得没啥用,我觉得它的用处是拓展出其他相关联的【必知】概念,也可以看看你的硬编码能力,再不济看看你的记忆力如何也是好的。(^-^)

bind/call/apply/new方法的实现原理

面试是不是经常写? 其实我认为实现方法是要在深刻理解定义的基础上才可能写出来,有的同学虽然知道怎么实现这些方法,但却对概念模糊不清,即使是很清楚的定义,说出来讲明白才能真正理解并运用到你的开发中去,写原理实现代码不是目的。你深刻理解定义,即使你之前没看过相关实现文章,在面试中依据面试官的提示,把自己知道概念的说清楚,并运用自己的代码能力现场实现下,效果会更好。

因为这里实现篇幅较长,单分一篇写比较好,这里先列下MDN的方法链接:

  • bind 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

  • call 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。

  • apply apply() 方法调用一个具有给定this值的函数,以及以一个数组(或类数组对象)的形式提供的参数。

注意:call()方法的作用和 apply() 方法类似,区别就是call()方法接受的是参数列表,而apply()方法接受的是一个参数数组

  • new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例

bind/call/apply/new方法的实现原理 【手写实现】

参考