阅读 你不知道的 JavaScript 上卷 整理的关于 this 问题的总结

前言

this 关键字是 JavaScript 中最复杂的机制之一,它不是一个特殊的关键字,被自动定义在所有的函数作用域中。

来看一个例子

function identify() {
  console.log(this.name);
  return this.name.toUpperCase();
}

function speak() {
  var gretting = 'Hello I am ' + identify.call(this);
  console.log(gretting);
}

var me = {
  name: 'Liusixin'
};

var you = {
  name: 'xinxin'
};
identify.call(me);
identify.call(you);

speak.call(me);
speak.call(you);

运行结果如下

关于 this 的误解

this 值得是它自己

通常人们都会认为 this 就是指向函数本身,至于为什么在函数中引用他自己呢,可能就是因为递归这种情况的存在吧。但是这里,我想说,this 并不是指向函数本身的

function foo(num) {
  console.log('foo:' + num);
  this.count++;
}

foo.count = 0;

for (var i = 0; i < 10; i++) {
  foo(i);
}

console.log(foo.count);

通过运行上面的代码我们可以看到,foo 函数的确是被调用了十次,但是 this.count 似乎并没有加到 foo.count 上。也就是说,函数中的 this.count 并不是 foo.count。

所以,这里我们一定要记住一个,就是函数中的 this 并不是指向函数本身的。上面的代码修改如下:

function foo(num) {
  console.log('foo:' + num);
  this.count++;
}

foo.count = 0;

for (var i = 0; i < 10; i++) {
  foo.call(foo, i);
}

console.log(foo.count);

运行如上代码,此时我们就可以看到 foo 函数中的 count 的确已经变成 10 了

this 值得是他的作用域

另一种对 this 的误解是它不知怎么指向函数的作用域,其实从某种意义上来说他是正确的,但是从另一种意义上来说,这的确是一种误解。

明确的说,this 不会以任何方式指向函数的词法作用域,作用域好像是一个将所有可用标识符作为属性的对象,这从内部来说他是对的,但是 JavaScript 代码不能访问这个作用域“对象”,因为它是引擎内部的实现。

function foo() {
  var a = 2;
  this.bar();
}

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

foo(); // undefined

首先,this.bar()访问 bar 函数,的确他做到了。虽然只是碰巧而已。然而,写下这段代码的开发者试图使用 this 在 foo 和 bar 的词法作用域中建立一座桥,是的 bar 可以访问 foo 内部变量作用域 a。当然,这是不可能的,不可能使用 this 引用在词法作用域中查找东西。

什么是 this

记住,this 不是在编写时候绑定的,而是在运行时绑定的上下文执行环境。this 绑定和函数声明无关,反而和函数被调用的方式有关系。

当一个函数被调用的时候,会建立一个活动记录,也成为执行环境。这个记录包含函数是从何处(call-stack)被调用的,函数是 如何 被调用的,被传递了什么参数等信息。这个记录的属性之一,就是在函数执行期间将被使用的 this 引用。

彻底明白 this 到底指向谁

调用点

为了彻底弄明白 this 的指向问题,我们还必须明白什么是调用点,即一个函数被调用的位置。考虑调用栈(即使我们到达当前执行位置而被调用的所有方法堆栈)是非常重要的,我们关心的调用点就是当前执行函数的之前的调用

function baz() {
  // 调用栈是: `baz`
  // 我们的调用点是global scope(全局作用域)

  console.log('baz');
  bar(); // <-- `bar`的调用点
}

function bar() {
  // 调用栈是: `baz` -> `bar`
  // 我们的调用点位于`baz`

  console.log('bar');
  foo(); // <-- `foo`的call-site
}

function foo() {
  // 调用栈是: `baz` -> `bar` -> `foo`
  // 我们的调用点位于`bar`

  console.log('foo');
}

baz(); // <-- `baz`的调用点

来点规则,有规可寻

我们必须考察调用点,来判断下面即将要说的四中规则哪一种适用。先独立解释下四中规则的每一种,然后再来说明下如果多种规则适用调用点时他们的优先级。

默认绑定

所谓的默认绑定,就是独立函数的调用形式。

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

var a = 2;

foo(); // 2

为什么会是 2 呢,因为在调用 foo 的时候,JavaScript 对 this 实施了默认绑定,所以 this 就指向了全局对象。

我们怎么知道这里适用 默认绑定 ?我们考察调用点来看看 foo()是如何被调用的。在我们的代码段中,foo()是被一个直白的,毫无修饰的函数引用调用的。没有其他的我们将要展示的规则适用于这里,所以 默认绑定 在这里适用。

需要注意的是,对于严格模式来说,默认绑定全局对象是不合法的,this 被置为 undefined。但是一个很微妙的事情是,即便是所有的 this 绑定规则都是基于调用点的,如果 foo 的内容没有严格模式下,默认绑定也是合法的。

隐含绑定

调用点是否有一个环境对象,也成为拥有者和容器对象。

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

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

obj.foo(); // 2

foo 被声明,然后被 obj 添加到其属性上,无论foo()是否一开始就在 obj 上被声明,还是后来作为引用添加(如上面代码所示),都是这个 函数 被 obj 所“拥有”或“包含”。

这里需要注意的是,只有对象属性引用链的最后一层才影响调用点

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

var obj2 = {
  a: 42,
  foo: foo
};

var obj1 = {
  a: 2,
  obj2: obj2
};

obj1.obj2.foo(); // 42

隐含绑定丢死

this 绑定最让人头疼的地方就是隐含绑定丢失了他的绑定,其实明确了调用位置,这个也不是难点。直接看代码

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

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

var bar = obj.foo; // 函数引用!

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

bar(); // "oops, global"

所以如上的调用模式,我们又退回到了默认绑定模式。

接着看

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

function doFoo(fn) {
  // `fn` 只不过`foo`的另一个引用

  fn(); // <-- 调用点!
}

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

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

doFoo(obj.foo); // "oops, global"

参数传递,仅仅是一种隐含的赋值,而且因为我们是传递一个函数,他是一个隐含的引用赋值,所以最终结果和我们前一段代码一样。

所以,在回调函数中丢失 this 绑定是一件很常见的事情,但是还有另一种情况,接受我们回调的函数故意改变 this 的值。那些很受欢迎的事件处理 JavaScript 包就十分喜欢强制你的回调的 this 指向触发事件的 DOM 元素。

不管哪一种意外改变 this 的方式,你都不能真正地控制你的回调函数引用将如何被执行,所以你(还)没有办法控制调用点给你一个故意的绑定。我们很快就会看到一个方法,通过 固定 this 来解决这个问题。

如上,我们一定要清除的是引用和调用。记住,找 this,我们只看调用,别被引用所迷惑

明确绑定

在 JavaScript 中,我们可以强制制定一个函数在运行时候的 this 值。是的,call 和 apply,他们的作用就是扩充函数赖以生存的作用域。

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

var obj = {
  a: 2
};

foo.call(obj); // 2

上面代码,我们使用 foo,强制将 foo 的 this 指定为 obj

如果你传递一个简单原始类型值(string,boolean,或 number 类型)作为 this 绑定,那么这个原始类型值会被包装在它的对象类型中(分别是 new String(..),new Boolean(..),或 new Number(..))。这通常称为“boxing(封箱)”。

但是,单独的依靠明确绑定仍然不能为我们先前提到的问题,提供很好的解决方案,也就是函数丢失自己原本的 this 绑定。

硬性绑定

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

var obj = {
  a: 2
};

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

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

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

我们创建了一个函数bar(),在它的内部手动调用foo.call(obj),由此强制 this 绑定到 obj 并调用 foo。无论你过后怎样调用函数 bar,它总是手动使用 obj 调用 foo。这种绑定即明确又坚定,所以我们称之为 硬绑定(hard binding)

new 绑定

这个比较简单,当函数前面加入 new 关键字调用的时候,其实就是当做构造函数调用的。其内部其实完成了如下事情:

  • 一个新的对象会被创建
  • 这个新创建的对象会被接入原型链
  • 这个新创建的对象会被设置为函数调用的 this 绑定
  • 除非函数返回一个他自己的其他对象,这个被 new 调用的函数将自动返回一个新创建的对象

总结

  • 函数是否在 new 中调用,如果是的话 this 绑定的是新创建的对象
var bar = new Foo();
  • 函数是否通过 call、apply 或者其他硬性调用,如果是的话,this 绑定的是指定的对象
var bar = foo.call(obj);
  • 函数是否在某一个上下文对象中调用,如果是的话,this 绑定的是那个上下文对象
var bar = obj.foo();
  • 如果都不是的话,使用默认绑定,如果在严格模式下,就绑定到 undefined,注意这里是方法里面的严格声明。否则绑定到全局对象
var bar = foo();

绑定例外

第一种情况就是将 null 和 undefined 传给 call、apply、bind 等函数,然后此时 this 采用的绑定规则是默认绑定

第二种情况这里举个例子,也是面试中常常会出现的例子

function foo() {
  console.log(this.a);
}
var a = 2;
var o = {
  a: 3,
  foo: foo
};
var p = {
  a: 4
};
(p.foo = o.foo)(); // 2

如上调用,其实 foo 采用的也是默认绑定,这里我们需要知道的是,p.foo = o.foo 的返回值是目标函数的引用,所以最后一句其实就是 foo()

es6 中的箭头函数

es6 中的箭头函数比较简单,由于箭头函数并不是 function 关键字定义的,所以箭头函数不适用 this 的这四中规则,而是根据外层函数或者全局作用域来决定 this

function foo() {
  // 返回一个arrow function
  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

这里 foo 内部创建的箭头函数会自动获取 foo 的 this。

经典面试题

如果能把这两个题搞清楚的话,可以说所有的关于 this 指向的问题都难不倒你了,go on!

  • 第一题
var a = 10;
var foo = {
  a: 20,
  bar: function() {
    var a = 30;
    console.log(this);
    return this.a;
  }
};
foo.bar();
foo.bar();
(foo.bar = foo.bar)();
(foo.bar, foo.bar)();

这个题的答案是怎么分析的呢:

  • 第一个不多说了
  • 第二个也不多做解释,引用没有变
  • 第三个需要解释一下,经过赋值,运算符运算后,都是纯粹的函数,不是对象方法的引用。所以函数指向的 this 都是 windows 的
  • 第四个,首先要清楚逗号表示什么,一张图说明一切

答案:20 20 10 10

  • 第二题
var number = 2;
var obj = {
  number: 4,
  /*匿名函数自调*/
  fn1: (function() {
    var number;
    this.number *= 2;

    number = number * 2;
    number = 3;
    return function() {
      var num = this.number;
      this.number *= 2;
      console.log(num);
      number *= 3;
      alert(number);
    };
  })(),

  db2: function() {
    this.number *= 2;
  }
};

var fn1 = obj.fn1;

alert(number);

fn1();

obj.fn1();

alert(window.number);

alert(obj.number);

这个题目有点长,解释这个题,我们需要分步骤去理解它,我们先给题目做个标注

var number = 2;
var obj = {
  number: 4,
  fn1: (function() {
    // 匿名函数1
    var number;
    this.number *= 2; // (1)
    number = number * 2; // (2)
    number = 3;
    return function() {
      // 匿名函数(2)
      var num = this.number;
      this.number *= 2;
      console.log(num);
      number *= 3;
      alert(number);
    };
  })(),
  db2: function() {
    this.number *= 2;
  }
};
var fn1 = obj.fn1; // (3)
alert(number); // (4)
fn1(); // (5)
obj.fn1(); // (6)
alert(window.number);
alert(obj.number);
  • 当定义obj的时候执行了匿名函数1,此时处于全局作用域内,因此上下文 this 是 window。执行完语句(1)导致全局变量number的值变为 4;执行语句(2)临时变量number还没有被赋值,所以是 NaN,但下一句会将其赋值为 3;最后,匿名函数1返回了匿名函数2,因此obj.fn1=匿名函数2。(注意匿名函数 2 里面会用到临时变量number
  • 来到语句(3),这句会把fn1这个变量赋值为obj.fn1,也就是匿名函数2
  • 由于全局变量number已经在语句(1)中变为了 4,所以语句(4)弹出的对话框结果为 4
  • 语句(5)执行的是fn1(),它与执行obj.fn1()的区别是两者 this 不一样。前者为 null,而后者 this 为 obj。但是又由于 JS 规定,this 为 null 时相当于全局对象 window,所以这句代码执行时函数的 this 为 window。在匿名函数2里会将全局变量number更新为 8,同时将匿名函数 1 中被闭包的临时变量number更新为 9
  • 语句(6)的效果在上面已经分析过了,this 是 obj,所以obj.number更新为 8,闭包的number更新为 27

答案:弹出 4 9 27 8 8