关于this

205 阅读8分钟

1 我们印象中的this

this的指向在函数定义的时候是确定不了的,只有函数执行的时候才能确定this到底指向谁,实际上this的最终指向的是那个调用它的对象(我们在下面慢慢分析)

2  this到底是什么

之前我们说过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的 其中一个属性,会在函数执行的过程中用到。

3 分析this的调用位置

在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的 位置(而不是声明的位置)。

最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的 调用位置就在当前正在执行的函数的前一个调用中

例子1:

  function fn () {
      console.log(this.name)//此时this指向obj
    }
    var obj={
      name:'llj',
      fn:fn
    }
     //我们关心的 调用位置就在当前正在执行的函数的前一个调用中。fn函数的调用前一个指的是obj
    window.obj.fn()

例子2:

 function baz() {
      // 当前调用栈是:baz     
      // 因此,当前调用位置是全局作用域 
      console.log("baz");
      bar(); // <-- bar 的调用位置 
    }

    function bar() {
      // 当前调用栈是 baz -> bar    
      // 因此,当前调用位置在 baz 中 
      console.log("bar");
      foo(); // <-- foo 的调用位置
    }

    function foo() {
      debugger
      // 当前调用栈是 baz -> bar -> foo     
      // 因此,当前调用位置在 bar 中 
      console.log("foo");
    }
    baz(); // <-- baz 的调用位置
  • 你可以把调用栈想象成一个函数调用链,就像我们在前面代码段的注释中所 写的一样。但是这种方法非常麻烦并且容易出错
  • 另一个查看调用栈的方法 是使用浏览器的调试工具。就本例来说,你可以在工具中给 foo() 函数的 第一行代码设置一个断点,或者直接在第一行代码之前插入一条 debugger; 语句。运行代码时,调试器会在那个位置暂停,同时会展示当前位置的函数 调用列表,这就是你的调用栈。因此,如果你想要分析 this 的绑定,使用开 发者工具得到调用栈,然后找到栈中第二个元素,这就是真正的调用位置。
调用栈图片
调用栈图片

4 绑定规则

4.1 绑定到全局对象window(默认绑定)

例子1:

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

    foo(); // 2

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

例子2:

function foo() {
      "use strict"; 
      console.log(this.a);//this指向undefined
    }
    var a = 2;

    foo(); // TypeError: this is undefined

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

4.2 隐式绑定

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

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

    obj.foo(); // 2

当函数引 用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调 用 foo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的。

4.3 显示绑定

JavaScript 提供的绝大多数函数以及你自 己创建的所有函数都可以使用 call(..) 和 apply(..) 方法来改变this指向,关于call和apply和bind的介绍可以看阮一峰老师文章

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

通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。

4.4 new绑定(构造函数)

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

  1. 创建(或者说构造)一个全新的对象。
  2. 这个新对象会被执行 [[ 原型 ]] 连接。
  3. 这个新对象会绑定到函数调用的 this。
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

例子

  /* 根据上面说的第4小点,有下面几种情况 */
    function foo(a) {
      this.a = a;

      //情况一:返回空对象时,this指向该空对象,该对象没有a属性,所以的到undefined
      // return {}

      //情况二:this指向实例对象bar,与默认情况一致
      return null
    }

    var bar = new foo(2);
    console.log(bar.a); // 2;默认情况绑定到bar对象

简单总结new:

  1. 当构造函数中没有return时, this指向新建的对象并返回新建的对象
  2. 当构造函数中return null时, this指向新建的对象并返回新建的对象
  3. 当构造函数中return 对象时, this指向新建的对象,但返回return 后的对象

4.5 箭头函数中的this

函数体内的this对象,就是定义时所在的对象,而不是使用时所在的对象。箭头函数根本没有自己的this,导致内部的this就是外层代码块的this

function test(){
  let x = 1;
  console.log(this.x); //2
  return ()=>{
    console.log(this.x); //2 箭头函数将this指向当前环境上下文,即this指向test中的this,即obj
  }
}
let obj = {
  x:2,
  fn:test,
  fn1:()=>{
    console.log(this.x); //undefined 箭头函数将this指向当前环境上下文,即this指向全局环境中的this,即window
  }
};
var fn=obj.fn();
fn();
obj.fn1();

不适用场合

例子1.

const cat = {
  lives: 9,
  jumps: () => {
    this.lives--;
  }
}

上面代码中,cat.jumps()方法是一个箭头函数,这是错误的。调用cat.jumps()时,如果是普通函数,该方法内部的this指向cat;如果写成上面那样的箭头函数,使得this指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致jumps箭头函数定义时的作用域就是全局作用域。

例子二.

var button = document.getElementById('press');
button.addEventListener('click', () => {
  this.classList.toggle('on');
});

上面代码运行时,点击按钮会报错,因为button的监听函数是一个箭头函数,导致里面的this就是全局对象。如果改成普通函数,this就会动态指向被点击的按钮对象。

5 被忽略的this

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

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

    var a = 2;

    foo.call(null); // 2

那么什么情况下你会传入 null 呢? 一种非常常见的做法是使用 apply(..) 来“展开”一个数组,并当作参数传入一个函数。 类似地,bind(..) 可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:

function foo(a,b) {     console.log( "a:" + a + ", b:" + b ); } 
 
// 把数组“展开”成参数 foo.apply( null, [2, 3] ); // a:2, b:3 
 
// 使用 bind(..) 进行柯里化 var bar = foo.bind( null, 2 );  bar( 3 ); // a:2, b:3

这两种方法都需要传入一个参数当作 this 的绑定对象。如果函数并不关心 this 的话,你 仍然需要传入一个占位值,这时 null 可能是一个不错的选择,就像代码所示的那样。

然而: 总是使用 null 来忽略 this 绑定可能产生一些副作用。如果某个函数确实使用了 this(比如第三方库中的一个函数),那默认绑定规则会把 this 绑定到全局对象(在浏览 器中这个对象是 window),这将导致不可预计的后果(比如修改全局对象)。 显而易见,这种方式可能会导致许多难以分析和追踪的 bug。

更安全的this

一种“更安全”的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序 产生任何副作用。由于这个对象完全是一个空对象,我自己喜欢用变量名 ø(这是数学中表示空集合符号的 小写形式)来表示它。在 JavaScript 中创建一个空对象最简单的方法都是 Object.create(null)。Object.create(null) 和 {} 很像,但是并不会创建 Object. prototype 这个委托,所以它比 {}“更空”:

function foo(a,b) {     
  console.log( "a:" + a + ", b:" + b );
  } 
 
// 我们的 DMZ 空对象 var ø = Object.create( null ); 
 
// 把数组展开成参数 foo.apply( ø, [2, 3] ); // a:2, b:3 
 
// 使用 bind(..) 进行柯里化 var bar = foo.bind( ø, 2 );  
bar( 3 ); // a:2, b:3

使用变量名 ø 不仅让函数变得更加“安全”,而且可以提高代码的可读性,因为 ø 表示 “我希望 this 是空”,这比 null 的含义更清楚。不过再说一遍,你可以用任何喜欢的名字 来命名 DMZ 对象。

6 总结

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

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

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

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

声明:以上多数内容引用来自书籍"你不知道的JavaScript".

本文使用 mdnice 排版