this 的绑定方式以及优先级

1,388 阅读7分钟

书接上回,我们知道了 this 是什么为什么之后,我们还有一个问题没有解决,那就是我们如何修改 this。

先来看看几种绑定规则

默认绑定

在默认情况下,即独立函数调用的情况下,this 会被绑定到 window 或者 global(node 环境)。

window.a = 1
function foo() {
  console.log(this.a)
}
foo(); // 1

这里没有使用 var,虽然 var 声明的全局变量会成为全局对象的一个属性,但是我觉得直接使用 window 会来得更直观一点,另外 let 和 const 声明的全局变量并不会成为全局对象的属性。

但是有一种情况例外,就是严格模式下,this 会被绑定到 undefined

window.a = 1
function foo() {
  console.log(this.a)
}
foo(); // error

隐式绑定

当我们通过一个对象调用一个函数的时候,这个函数的 this 就会指向这个对象。

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

当我们通过 obj 调用 foo 时,调用点使用 obj 环境来引用函数,因为 obj 是 foo() 调用的 this,所以这个时候的 this 会等于 obj:

const obj = {
  a: 1,
  foo
}
function foo() {
  console.log(this === obj)
}
obj.foo() // true

绑定丢失

关于绑定丢失问题,就关系到引用对象的实质了。

还是以这段代码为例:

function foo() {
  console.log(this.a)
}
const obj = {
  a: 1,
  foo
}
const obj2 = {
  a: 2,
  bar: function() {
    console.log(this.a)
  }
}
obj.foo() // 2

无论函数是声明后作为引用属性添加到对象上,还是直接在对象上声明,这个函数都不属于这个对象,对象上仅仅只是保存一个引用地址。

那么,这段代码就可以解释了:

window.a = 'in window'
function foo() {
  console.log(this.a)
}
const obj = {
  a: 'in obj',
  foo
}
obj.foo() // 'in obj'
const bar = obj.foo // {1}
bar() // 'in window'

上面我们已经知道了 obj 上的 foo 保存的只是一个引用地址,我们再在 {1} 处将这个引用地址直接赋值给 bar,那么 bar 就直接指向函数 foo,这个时候执行 bar 就等于直接执行 foo,调用点来到了 window/global/undefined,this 自然就指向 window/global/undefined。

明确绑定

明确绑定与默认绑定相对,是由我们给 this 明确指明运行时的绑定对象,强制函数调用时使用某个特定对象作为 this 绑定,而不是在这个对象上放置这个函数。

明确绑定的方式有很多种,比如我们常见的 call 方法,它接受的第一个参数是用于 this 的对象,之后的参数都是传给这个函数的参数。

window.a = 'in window'
function foo() {
  console.log(this.a)
}
const obj = {
  a: 'in obj'
}
foo() // 'in window'
foo.call(obj) // 'in obj'

如果我们传递的是一个值类型(string、boolean、number)作为 this 绑定,那么这个值类型会被包装到它的对象类型中(分别是 new String()、new Boolean()、new Number())。这通常叫做封箱

硬绑定

由于明确绑定并不能完全解决我们函数“丢失”自己原本的 this 绑定,或者被第三方模块覆盖等问题,聪明的程序员便使用了明确绑定的一种变种来解决这个问题:

widnow.a = 10
function foo(otherNum = 0) {
  console.log(this.a + otherNum)
}

function bind(fn, obj){
  return function(...args) {
    fn.call(obj, ...args)
  }
}

const obj = {
  a: 1,
}
const bar = bind(foo, obj)
bar(2) // 3

// `bar` 将 `foo` 的 `this` 硬绑定到 `obj`
// 所以它不可以被覆盖
bar.call(window) // 1

现如今硬绑定已经作为 ES5 的内建工具提供:Function.prototype.bing,像这样使用:

function foo(otherNum = 0) {
  console.log(this.a + otherNum)
}

const obj = {
  a: 1,
}
const bar = foo.bind(obj)
bar(2) // 3

bind(..) 返回一个硬编码的新函数,它使用你指定的 this 环境来调用原本的函数。

 在 ES6 中,bind(..) 生成的硬绑定函数有一个名为 .name 的属性,它源自于原始的 目标函数(target function) 。举例来说:bar = foo.bind(..) 应该会有一个 bar.name 属性,它的值为 "bound foo",这个值应当会显示在调用栈轨迹的函数调用名称中。

new 绑定

先说明哈,JS 只是恰巧拥有 new 操作符,实际上 JS 的机制与 new 在面向类语言的功能并没有任何联系。

在 JS 中,构造器仅仅只是一个函数,它们偶然地与前置的 new 操作符一起调用。他们不依附于类,也不初始化一个类,甚至不是一种特殊的函数类型。他们本质上只是一般的函数,在被 new 调用时改变了行为。

当在函数前面被加入 new 调用时,也就是被构造器调用时,会自动完成下面的操作:

  1. 一个全新的对象被凭空创建
  2. 这个新创建的对象会被接入原型链([[Prototype]] - linked)
  3. 这个新构建的对象被设置为函数调用的 this 绑定
  4. 除非函数返回他自己的其他对象,否则这个被 new 调用的函数将自动返回这个新构建的对象
function foo(a) {
  this.a = a
}
const bar = new foo(2)
console.log(bar.a) // 2

优先级

首先默认规则的优先权最低,因为默认规则的定义就是当没有其他规则匹配时使用默认规则

那么我们就只需要明确剩下三个规则的优先级了。

思考以下代码:

function foo() {
  console.log(this.a)
}
const obj = {
  a: 'in obj',
  foo
}
const obj2 = {
  a: 'in obj2'
}
obj.foo() // 'in obj'
obj.foo.call(obj2) // 'in obj2'

由此可得,明确绑定的优先级要高于隐式绑定。

这个时候我们就能得到这个一个顺序

{1} > 明确绑定 > {2} > 隐式绑定 > {3} > 默认绑定

现在我们只需要搞清楚 new 绑定的优先级到底是 1、2、3 中的那个位置。

function foo(a) {
  this.a = a
}

const obj1 = {
  foo
}
 
const obj2 = {}

obj1.foo(2)
console.log(obj1.a) // 2

obj1.foo.call(obj2, 3)
console.log(obj2.a) // 3

const bar = new obj1.foo(4)
console.log(obj1.a) // 2
console.log(bar.a) // 4

现在我们明确了 new 绑定的优先级要高于隐式绑定,也就是说我们排除了 3 这个选项。

接下来我们要确认到底时 1 还是 2 了

注意:  new 和 call/apply 不能同时使用,所以 new foo.call(obj1) 是不允许的,也就是不能直接对比测试 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

在后面的 polyfill 中,bind 允许 new 进行覆盖的部分是这里:

this instanceof fNOP &&
oThis ? this : oThis

// ... 和:

fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();

那么我们可以得到最后的优先级排序

new 绑定 > 明确绑定 > 隐式绑定 > 默认绑定

确定 this

有了以上优先级的排序,我们可以很轻松的确定 this 了

  1. 函数是通过 new 被调用的吗(new 绑定)?如果是,this 就是新构建的对象。
  2. 函数是通过 call 或 apply 被调用(明确绑定),甚至是隐藏在 bind 硬绑定 之中吗?如果是,this 就是那个被明确指定的对象。
  3. 函数是通过环境对象(也称为拥有者或容器对象)被调用的吗(隐含绑定)?如果是,this 就是那个环境对象。
  4. 否则,使用默认的 this默认绑定)。如果在 strict mode 下,就是 undefined,否则是 global 对象。

bind 的 polyfill

if (!Function.prototype.bind) {
  Function.prototype.bind = function (oThis) {
    if (typeof this !== 'function') {
      // 可能的与 ECMAScript 5 内部的 IsCallable 函数最接近的东西,
      throw new TypeError(
        'Function.prototype.bind - what ' +
          'is trying to be bound is not callable'
      );
    }

    var aArgs = Array.prototype.slice.call(arguments, 1),
      fToBind = this,
      fNOP = function () {},
      fBound = function () {
        return fToBind.apply(
          this instanceof fNOP && oThis ? this : oThis,
          aArgs.concat(Array.prototype.slice.call(arguments))
        );
      };

    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();

    return fBound;
  };
}

小 tips

为什么箭头函数不能用 new,是什么原因

new 的原理是执行原型链接和给新对象绑定 this,再给它赋值。

  1. 没有 prototype,就不能执行原型链接
  2. 箭头函数没有自己的 this,也不能通过 callbind 等改变 this 指向,就不能给新对象绑定。

参考

第二章: this 豁然开朗! · 你不知道的JavaScript@Js中文网-前端进阶资源教程 (javascriptc.com)