JavaScript 的 this 全面解析

241 阅读5分钟

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

常见误解

  • 指向自身

    思考以下代码:

    function  foo(num) {
        console.log( "foo: " + num );
        // 记录 foo 被调用次数
        this.count++;
    }
    
    foo.count = 0;
    
    var i;
    for( i=0; i<10; i++) {
        if(i > 5) foo(i);
    }
    // foo: 6
    // foo: 7
    // foo: 8
    // foo: 9
    
    // foo 被调用了多少次
    console.log( foo.count ); // 0 ----> what!?
    
    

    console.log 输出了4条语句,说明 foo(...) 确实被调用了四次,但是 foo.count 为0,说明指向自身的理解是错误的。

    那为什么会是 0 呢,实际上上面这段代码创建了一个全局变量 count,它的值为 NAN

  • 它的作用域

    this 指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下确实错误的

    需要明确的是, this 在任何情况下都不指向函数的词法作用域

    思考以下代码:

    function foo() {
        var a = 2;
        this.bar();
    }
    function bar() {
        console.log( this.a );
    }
    
    foo(); // ReferenceError: a is not defined
    

    它试图跨越边界,使用 this 来隐式应用函数的词法作用域(失败)

    this 到底是什么

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

    当一个函数被调用,会创造一个活动记录(上下文)。该记录会包含函数在哪里被调用(调用栈)、调用的方式、传入的参数等。this 就是这个记录的属性。接下来让我们学习一下如何寻找函数的调用位置吧

    this 调用位置

    通常来说寻找调用位置就是寻找“函数被调用的位置”,可是有些编程模式可能会隐藏真正的调用位置,分析过程就不会那么简单。最重要的是分析调用栈。我们关心的调用位置就在当前正在执行的的函数的前一个调用中

    代码演示如下:

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

    this 的绑定规则

    • 默认绑定

      无法应用其他规则是的默认规则

      代码如下:

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

      foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,因此 this 指向全局

      但是,如果使用严格模式( strict mode ),不能将全局对象用于默认绑定,因此 this 会绑定到undefined

      代码如下:

      function foo() {
          "use strict";
          console.log( this.a );
      }
      var a = 2;
      
      foo(); // TypeError: this is undefined
      
    • 隐式绑定

      调用位置是否有上下文,或者说是否被某个对象拥有或者包含

      代码如下:

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

      当 foo() 被调用时,它的前面有对 obj 的引用,从而有了上下文对象,隐式绑定规则会将 this 绑定到这个上下文对象中。因此 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();
      }
      
      var obj = {
          a: 1,
          foo: foo
      }
      
      var a = "oops, global";
      
      doFoo( obj.foo );	// 会输出什么? 欢迎在评论区讨论
      
    • 显示绑定

      在分析隐式绑定时,我们必须在一个对象内部包含一个执行函数的属性, 并通过这个属性间接引用函数,从而把 this 间接绑定到这个对象上

      如果不想在对象内部包含函数的引用,而想在某个对象上强制调用函数,怎么办?

      JavaScript 中函数有一些有用的特性可以解决这个问题:call(...) 和 apply(...) 方法。他们的第一个参数是一个对象,给 this 准备的,接着在调用时将其绑定到 this。 因为可以直接指定 this 的绑定对象,因此称之为显示绑定

      示例代码:

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

      如果 call(...) 传入的是一个原始值(字符串、布尔、数字)来当作 this 的绑定对象,这个原始值会被转化成为它的对象形式。这通常被称为“装箱”

      可是,显示绑定仍然无法解决之前提出的绑定丢失问题

      • 硬绑定

      显示绑定的一个变种可以解决这个问题

      思考以下代码:

      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
      
  • new 绑定

    在传统的面向类的语言中, “构造函数” 是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。通常形式为:

    something = new Myclass(...);

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

    ES5.1 这样描述 Number(...) 作为构造函数的行为: 当 Number在 new 表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。

    使用 new 来调用函数,会自动执行以下操作

    • 创建(构造)一个全新的对象
    • 这个新对象会被执行 [[Prototype]] 连接
    • 这个新对象会绑定到函数调用的 this
    • 如何函数没有返回其他对象, new 表达式中的函数调用会自动返回这个新对象

    思考以下代码:

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

    使用 new 调用 foo(..) 时,构造一个新对象 bar 并把它绑定到 foo(...) 调用的 this 上。

    绑定规则的优先级

    new 绑定 > 显示绑定 > 隐士绑定 > 默认绑定

    想要了解具体证明细节可翻阅《你不知道的JavaScript上》

    ES6 箭头函数

    箭头函数并不是使用 function 关键字定义的,而是使用 " => " 操作符定义的,不适用上述四种规则,而是根据外层(函数或者全局)作用域来决定 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,箭头函数绑定无法被修改。