你真的会 JavaScript 中 this 的指向?

本文参考[美]辛普森(Kyle Simpson 的《你不知道的 JavaScript(上卷)》,喜欢的同学可以去看原著哦,更加精细。

学习 this 的第一步是明白 this 既不指向函数自身,也不指向函数的词法作用域。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

绑定规则

1.默认绑定

首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

var a = 2;

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

foo(); // 2
复制代码

分析foo()的调用位置,可以看到,在代码中,foo()是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。声明在全局作用域中的变量(比如 var a = 2 )就是全局对象的一个属性。 当调用foo()时,this.a 被解析成了全局变量 a。

下面会讲严格模式下的 this,对严格模式不理解的童鞋,可以先去MDN先看看严格模式相关的语法,这里先不赘述。 Strict mode applies to entire scripts or to individual functions.

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

function foo() {
  'use strict';
  console.log('this in foo', this); // this in foo undefined
  console.log(this.a);
}

var a = 2;
console.log(this); // window
console.log(this.a); // 2

foo(); // TypeError: this is undefined
复制代码

虽然 this 的绑定规则完全取决于调用位置,但是只有foo() 运行在非strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo() 的调用位置无关:

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

var a = 2;

(function () {
  'use strict';

  console.log(this); // undefined
  foo(); // 2
})();
复制代码

通常来说你不应该在代码中混合使用 strict mode 和 non-strict mode。整个程序要么严格要么非严格。

2.隐式绑定

另一个规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。

思考下面的代码:

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

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

obj.foo(); // 2
复制代码

首先需要注意的是foo()的声明方式,及其之后是如何被当作引用属性添加到 obj 中的。但是无论是直接在 obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于 obj 对象。 然而,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥有”或者“包含”它。无论你如何称呼这个模式,当 foo() 被调用时,它的落脚点确实指向 obj 对象。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo() 时 this 被绑定到 obj ,因此 this.a 和 obj.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);
}

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

var bar = obj.foo; // 函数别名!
var a = 'oops, global'; // a是全局对象的属性

bar(); // "oops, global”
复制代码

虽然 bar 是 obj.foo 的一个引用,但实际上,它引用的是 foo 函数本身,因此此时的 bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

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

function doFoo(fn) {
  // fn其实引用的是foo
  console.log(fn === foo); //true
  fn(); // <-- 调用位置!
}

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

var a = 'oops, global'; // a是全局对象的属性

doFoo(obj.foo); // "oops, global"
复制代码

参数传递其实就是一种隐式赋值,所以我们传入函数时也会被隐式赋值,函数中的 fn 参数就是对 foo 的引用,foo() 是不带任何修饰的函数调用,所以结果和上一个例子一样。

如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一样的,没有区别:

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

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

var a = 'oops, global'; // a是全局对象的属性

setTimeout(obj.foo, 100); // "oops, global”
复制代码

就像我们看到的那样,回调函数丢失this绑定是非常常见的。除此之外,还有一种情况 this 的行为会出乎我们意料:调用回调函数的函数可能会修改 this 。在一些流行的 JavaScript 库中事件处理器常会把回调函数的this强制绑定到触发事件的 DOM 元素上。这在一些情况下可能很有用,但是有时它可能会让你感到非常郁闷。遗憾的是,这些工具通常无法选择是否启用这个行为。

无论是哪种情况,this 的改变都是意想不到的,实际上你无法控制回调函数的执行方式,因此就没有办法控制会影响绑定的调用位置。之后我们会介绍如何通过固定 this 来修复这个问题。

3.显式绑定

在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接(隐式)绑定到这个对象上。 那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?

可以使用函数的call(..)apply(..)方法。不熟悉使用方法的童鞋,可以先学习一下

严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自己创建的所有函数都可以使用call(..)apply(..)方法。

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

思考下面的代码:

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

var obj = {
  a: 2,
};

foo.call(obj); // 2
复制代码

通过foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..) 、new Boolean(..) 或者 new Number(..))。可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。

  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 。无论之后如何调用函数 bar ,它总会手动在 obj 上调用 foo 。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定

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

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
console.log(b); // 5
复制代码

另一种使用方法是创建一个可以重复使用的辅助函数:

function foo(something) {
  console.log(this.a, something);
  return this.a + something;
}

// 简单的辅助绑定函数
function bind(fn, obj) {
  return function () {
    return fn.apply(obj, arguments);
  };
}

var obj = {
  a: 2,
};

var bar = bind(foo, obj);

var b = bar(3); // 2 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(..)方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

  1. API 调用的“上下文”

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

举例来说:(不知道的童鞋,戳这里)

function foo(el) {
  console.log(el, this.id);
}

var obj = {
  id: 'awesome',
};

// 调用foo(..)时把this绑定到obj
[1, 2, 3].forEach(foo, obj); // 1 awesome 2 awesome 3 awesome
复制代码

这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定,这样你可以少写一些代码。

4.new 绑定

在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。通常的形式是这样的:JavaScript 也有一个 new 操作符,使用方法看起来也和那些面向类的语言一样,绝大多数开发者都认为 JavaScript 中 new 的机制也和那些语言一样。然而,JavaScript 中 new 的机制实际上和面向类的语言完全不同。

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

举例来说,思考一下 Number(..) 作为构造函数时的行为,ES5.1 中这样描述它:

15.7.2 Number 构造函数
复制代码

当 Number 在 new 表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。

所以,包括内置对象函数在内的所有函数都可以用 new 来调用,这种函数调用被称为构造函数调用。这里有一个重要但是非常细微的区别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

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

  1. 创建(或者说构造)一个全新的对象。
  2. 这个新对象会被执行[[原型]]连接。
  3. 这个新对象会绑定到函数调用的 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 绑定的四条规则,你需要做的就是找到函数的调用位置并判断应当应用哪条规则。但是,如果某个调用位置可以应用多条规则该怎么办?为了解决这个问题就必须给这些规则设定优先级,这就是我们接下来要介绍的内容。

毫无疑问,默认绑定的优先级是四条规则中最低的,所以我们可以先不考虑它。

隐式绑定和显式绑定哪个优先级更高?我们来测试一下:

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 绑定和显式绑定谁的优先级更高呢?

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

var obj1 = {};

var bar = foo.bind(obj1);
bar(2);
console.log(obj1.a); // 2

var baz = newbar(3);
console.log(obj1.a); // 2
console.log(baz.a); // 3
复制代码

bar 被硬绑定到 obj1 上,但是 new bar(3) 并没有像我们预计的那样把 obj1.a 修改为 3。相反,new 修改了硬绑定(到 obj1 的)调用 bar(..) 中的 this 。因为使用了 new 绑定,我们得到了一个名字为 baz 的新对象,并且 baz.a 的值是 3。

下面是 MDN 提供的一种 bind(..) 实现:

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;
  };
}
复制代码

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

判断 this

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
  2. 函数是否通过 call 、apply (显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined ,否则绑定到全局对象。

就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。不过……凡事总有例外。

绑定例外

被忽略的 this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 call 、apply 或者 bind ,这些值在调用时会被忽略,实际应用的是默认绑定规则:

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

var a = 2;

foo.call(null); // 2
复制代码

一种“更安全”的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序产生任何副作用。在 JavaScript 中创建一个空对象最简单的方法都是 Object.create(null)。Object.create(null) 和 {} 很像,但是并不会创建 Object.prototype 这个委托,所以它比{} “更空”。

间接引用

另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

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

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 的引用,因此调用位置是 foo() 而不是 p.foo() 或者 o.foo() 。根据我们之前说过的,这里会应用默认绑定。

注意:对于默认绑定来说,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this 会被绑定到 undefined ,否则 this 会被绑定到全局对象。

this 词法

箭头函数并不是使用 function 关键字定义的,而是使用 () => {} 定义的。箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this 。

我们来看看箭头函数的词法作用域:

function foo() {
  // 返回一个箭头函数
  return (a) => {
    //this继承自foo()
    console.log(this.a);
  };
}

var obj1 = {
  a: 2,
};

var obj2 = {
  a: 3,
};

var bar = foo.call(obj1);
bar.call(obj2); // 2, 不是3!
复制代码

foo() 内部创建的箭头函数会捕获调用时 foo() 的 this 。由于 foo() 的 this 绑定到 obj1 ,bar(引用箭头函数)的 this 也会绑定到 obj1 ,箭头函数的绑定无法被修改。(new 也不行!)

箭头函数最常用于回调函数中,例如事件处理器或者定时器:

function foo() {
  setTimeout(() => {
    // 这里的this在此法上继承自foo()
    console.log(this.a);
  }, 100);
}

var obj = {
  a: 2,
};

foo.call(obj); // 2
复制代码

箭头函数可以像 bind(..) 一样确保函数的 this 被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的 this 机制。实际上,在 ES6 之前我们就已经在使用一种几乎和箭头函数完全一样的模式。

function foo() {
  var self = this; // lexical capture of this
  setTimeout(function () {
    console.log(self.a);
  }, 100);
}

var obj = {
  a: 2,
};

foo.call(obj); // 2
复制代码

虽然 self = this 和箭头函数看起来都可以取代 bind(..) ,但是从本质上来说,它们想替代的是 this 机制。

小结

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

  1. 由 new 调用?绑定到新创建的对象。
  2. 由 call 或者 apply (或者 bind )调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式下绑定到 undefined ,否则绑定到全局对象。

一定要注意,有些调用可能在无意中使用默认绑定规则。如果想“更安全”地忽略 this 绑定,你可以使用一个 DMZ 对象,比如 ø = Object.create(null) ,以保护全局对象。

ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this ,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。“这其实和 ES6 之前代码中的 self = this 机制一样。

猜你喜欢

转载自juejin.im/post/7037281615873048584