美文网首页JavaScript
JavaScript中this的原理

JavaScript中this的原理

作者: ERICOOLU | 来源:发表于2020-01-08 18:17 被阅读0次

    this是 JavaScript 语言的一个关键字。

    它是函数运行时,在函数体内部自动生成的一个对象,只能在函数体内部使用。

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

    上述调用foo()函数,输出不同的结果,这种差异的原因,就在于函数体内部使用了this关键字。this指的是函数运行时所在的环境。对于obj.foo()来说,foo运行在obj环境,所以this指向obj;对于foo()来说,foo运行在全局环境,所以this指向全局环境。所以,两者的运行结果不一样。

    内存的数据结构

    JavaScript 语言之所以有this的设计,跟内存里面的数据结构有关系。

    var obj = { foo:  5 };
    

    上面的代码将一个对象赋值给变量obj。JavaScript 引擎会先在内存里面,生成一个对象{ foo: 5 },然后把这个对象的内存地址赋值给变量obj


    1.png

    也就是说,变量obj是一个地址(reference)。后面如果要读取obj.foo,引擎先从obj拿到内存地址,然后再从该地址读出原始的对象,返回它的foo属性。

    原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面例子的foo属性,实际上是以下面的形式保存的。

    2.png
    {
      foo: {
        [[value]]: 5
        [[writable]]: true
        [[enumerable]]: true
        [[configurable]]: true
      }
    }
    

    注意,foo属性的值保存在属性描述对象的value属性里面。

    函数

    这样的结构是很清晰的,问题在于属性的值可能是一个函数。

    var obj = { foo: function () {} };
    

    这时,引擎会将函数单独保存在内存中,然后再将函数的地址赋值给foo属性的value属性。

    3.png
    {
      foo: {
        [[value]]: 函数的地址
        ...
      }
    }
    

    由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。

    var f = function () {};
    var obj = { f: f };
    
    // 单独执行
    f()
    
    // obj 环境执行
    obj.f()
    

    环境变量

    JavaScript 允许在函数体内部,引用当前环境的其他变量。

    var f = function () {
      console.log(x);
    };
    

    上面代码中,函数体里面使用了变量x。该变量由运行环境提供。

    现在问题就来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得当前的运行环境(context)。所以,this就出现了,它的设计目的就是在函数体内部,指代函数当前的运行环境。

    var f = function () {
      console.log(this.x);
    }
    

    上面代码中,函数体里面的this.x就是指当前运行环境的x。

    var f = function () {
      console.log(this.x);
    }
    
    var x = 1;
    var obj = {
      f: f,
      x: 2,
    };
    
    // 单独执行
    f() // 1
    
    // obj 环境执行
    obj.f() // 2
    

    上面代码中,函数f在全局环境执行,this.x指向全局环境的x。


    4.png

    在obj环境执行,this.x指向obj.x。

    5.png
    • obj.foo()是通过obj找到foo,所以就是在obj环境执行。
    • 一旦var foo = obj.foo,变量foo就直接指向函数本身,所以foo()就变成在全局环境执行。

    this的绑定方式

    默认绑定

    默认绑定是在不使用其他绑定规则时的规则,通常是独立函数的调用。

    function greeting() {
      console.log(`Hello, ${this.name}`);
    }
    
    var name = 'Eric';
    
    greeting();
    
    // Hello, Eric
    

    隐式绑定

    隐式绑定指的是在一个对象上调用函数。

    通过 obj 调用 greeting 方法,this 就指向了 obj

    将 obj.greeting 赋给了一个全局的变量 otherGreeting,所以在执行 otherGreeting 时,this 会指向 window.

    function  greeting() {
      console.log(`Hello,${this.name}`);
    };
    
    var name = 'Eric';
    
    var obj = {
      name: 'World',
      greeting,
    };
    
    var otherGreeting = obj.greeting;
    
    greeting(); // Hello,Eric
    obj.greeting(); // Hello,World
    otherGreeting().greeting(); // Hello,Eric
    

    异步操作时候隐式绑定丢失问题

    如果涉及到回调函数(异步操作),就要小心隐式绑定的丢失问题。

    function  greeting() {
      console.log(`Hello,${this.name}`);
    };
    
    var name = 'Eric';
    
    var obj1 = {
      name: 'Obj1',
      greeting() {
        setTimeout(function() {
          console.log(`Hello,${this.name}`);
        })
      }
    };
    
    var obj2 = {
      name: 'Obj2',
      greeting,
    };
    
    obj1.greeting(); //Hello,Eric
    
    obj2.greeting(); //Hello,Obj2
    
    setTimeout(obj2.greeting, 100); //Hello,Eric
    
    setTimeout(function() {
      obj2.greeting();
    }, 200); //Hello,Obj2
    
    • obj1.greeting()调用

    因为涉及到异步操作setTimeout。在JavaScript中,一段代码执行时,会先执行宏任务中的同步代码,当遇到setTimeout之类的宏任务,那么就把这个 setTimeout内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。当本轮宏任务调用结束后,下一轮宏任务执行时,此时函数位于内存中,this指向全局环境。此时 this.name 就是 Eric

    • obj2.greeting()调用

    greeting函数位于内存中,通过obj2来调用,那么this的指向环境变成了obj2。如前面讲述的图所示:


    5.png
    • setTimeout(obj2.greeting, 100)调用

    可以理解为将 obj2.greeting 赋值给一个新的变量(此时与obj1.greeting类似),所以此时 this 也是指向了 window。

    • setTimeout(function() {obj2.greeting();}, 200)调用

    此时setTimeout的function参数里面包含了obj2.greeting()方法,调用则是隐式绑定,此时 this 指向 obj2。我们可以做个实验来验证

    setTimeout(function() {
      console.log(this);
      obj2.greeting();
    }, 200); 
    

    其中,打印出来的this指向window,所以在200毫秒后,function回调函数里面的this环境指向window,这个与前面的实验obj1.greeting()调用是一致的,此时相当于在全局环境中,我们来调用了obj2.greeting(),这个与前面的实验obj2.greeting()调用是一致的,所以打印的结果是Hello,Obj2

    显式绑定

    显示绑定就是通过 call, apply, bind 来显式地指定 this 的绑定对象。三者的第一个参数都是传递 this 指向的对象,call 与 apply 的区别是前者从第二个参数起传递一个参数序列,后者传递一个数组,call, apply 和 bind 的区别是前两个都会立即执行对应的函数,而 bind 方法不会。

    我们通过 call 显式绑定 this 指向的对象来解决隐式绑定丢失的问题。

    function  greeting() {
      console.log(`Hello,${this.name}`);
    };
    
    var name = 'Eric';
    
    var obj = {
      name: 'Obj',
      greeting,
    };
    
    var otherGreeting = obj.greeting;
    
    // 强制将 this 绑定到 obj
    otherGreeting.call(obj); // Hello,Obj
    setTimeout(obj.greeting.call(obj), 100); // Hello,Obj
    

    在使用显式绑定时,如果将 null, undefined 作为第一个参数传入 call, apply 或者 bind,实际应用的是默认绑定。

    function greeting() {
      console.log(`Hello,${this.name}`);
    };
    
    var name = 'Eric';
    
    var obj = {
      name: 'Obj',
      greeting,
    };
    
    var otherGreeting = obj.greeting;
    // this 仍然指向 window
    otherGreeting.call(null); //Hello,Eric
    

    箭头函数

    • 函数体内的 this 对象,继承的是外层代码块的 this
    • 不可以当作构造函数,也就是说,不可以使用 new 命令,否则会抛出一个错误。
    • 不可以使用 arguments 对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
    • 不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。
    • 箭头函数没有自己的 this,因此不能使用 call()、apply()、bind()等方法改变 this 的指向。
    var obj = {
      hi: function() {
        console.log(this);
        return () => {
          console.log(this);
        };
      },
    
      sayHi: function() {
        return function() {
          console.log(this);
          return () => {
            console.log(this);
          };
        };
      },
    
      say: () => {
        console.log(this);
      }
    };
    
    let hi = obj.hi(); // 输出 obj 对象
    hi(); // 输出 obj 对象
    let sayHi = obj.sayHi();  // 输出 window
    fun1(); // 输出 window
    obj.say(); // 输出 window
    
    1. 第一步是隐式绑定,此时 this 指向 obj,所以打印出 obj 对象
    2. 第二步执行 hi() 方法,虽然看着像闭包,但这是一个箭头函数,它会继承上一层的 this,也就是 obj,所以打印出 obj 对象
    3. 因为 obj.sayHi() 返回一个闭包,所以 this 指向 window,因此打印出 window 对象
    4. 同样箭头函数继承上一层的 this,所以 this 指向 window,因此打印出 window 对象
    5. 最后一次输出,因为 obj 中不存在 this,因此按作用域链找到全局的 this,也就是 window,所以打印出 window 对象
    var obj = {
      name: 'Eric',
      greeting() {
        setTimeout(() => {
          console.log(`Hello, ${this.name}`);
        })
      },
      greeting2() {
        console.log(`Hello, ${this.name}`);
      },
    
      greeting3() {
        setTimeout(function() {
          console.log(`Hello, ${this.name}`);
        });
      }
    };
    
    var name = 'Global';
    obj.greeting();   //Hello, Eric
    obj.greeting2();  //Hello, Eric
    obj.greeting3();  //Hello, Global
    
    1. obj.greeting(),虽然 setTimeout 会将 this 指向全局,但箭头函数继承上一层的 this,也就是 obj.greeting() 的 this,因为这是一个隐式绑定,所以 this 指向 obj,所以箭头函数的 this 也会指向 obj.
    2. obj.greeting2(),这里是一个隐式绑定,所以 this 指向 obj
    3. greeting3(),setTimeout 会将 this 指向全局

    解析一

    var number = 5;
    var obj = {
      number: 3,
      fn: (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;
          console.log(number);
        };
      })(),
    };
    var fn = obj.fn;
    fn.call(null);
    obj.fn();
    console.log(window.number);
    

    因为 obj.fn 是一个立即执行函数(this 会指向 window),所以在 obj 创建时就会执行一次,并返回闭包函数。

    var number; // 创建了一个私有变量 number 但未赋初值
    this.number *= 2; // this.number 指向的是全局那个 number,所以 window.number = 10
    number = number * 2; // 因为私有变量 number 未赋初值,所以乘以 2 会变为 NaN
    number = 3; // 此时私有变量 number 变为 3
    

    接着执行下面两句:

    var fn = obj.fn;
    fn.call(null);
    

    因为将 obj.fn 赋值给一个全局变量 fn,所以此时 this 指向 window。接着,当 call 的第一个参数是 null 或者 undefined 时,调用的是默认绑定,因此 this 仍然指向 window.

    var num = this.number; // 因为 window.number = 10,所以 num 也就是 10
    this.number *= 2; // window.number 变成了 20
    console.log(num); // 打印出 10
    number *= 3; // 因为是闭包函数,有权访问父函数的私有变量,所以此时 number 为 9
    console.log(number); // 打印出 9
    

    当执行 obj.fn(); 时,此时的 this 指向的是 obj:

    var num = this.number; // 因为 obj.number = 3,所以 num 也就为 3
    this.number *= 2; // obj.number 变为 6
    console.log(num); // 打印出 3
    number *= 3; // 上一轮私有变量为变成了 9,所以这里变成 27
    console.log(number); // 打印出 27
    

    最后打印出 window.number 就是 20

    最终结果:
    10
    9
    3
    27
    20
    

    解析二

    var length = 10;
    
    function fn() {
      console.log(this.length);
    }
    
    var obj = {
      length: 5,
      method: function(fn) {
        fn();
        arguments[0]();
      },
    };
    
    obj.method(fn, 1);
    

    最终结果:

    10
    2
    

    传入了 fn 而非 fn(),相当于把 fn 函数赋值给 method 里的 fn 执行,所以这里是默认绑定,此时 this 指向 window,所以执行 fn() 时会打印出 10

    arguments0,就相当于执行 fn(),所以是隐式绑定,此时 this 指向 arguments,所以 this.length 就相当于 arguments.length,因为我们传递了两个参数,因此返回 2

    window.val = 1;
    
    var obj = {
      val: 2,
      dbl: function() {
        this.val *= 2;
        val *= 2;
        console.log('val:', val);
        console.log('this.val:', this.val);
      },
    };
    
    obj.dbl();
    var func = obj.dbl;
    func();
    

    最终结果:

    2, 4
    8, 8
    

    第一次调用是隐式调用,因此 this 指向 obj,所以 this.val 也就是 obj.val 变成了 4,但是 dbl 方法中没有定义 val,所以会沿着作用域链找到 window.val,所以会依次打印出 2,4

    第二次是默认调用,this 指向 window,window.val 会经历两次乘 2 变成 8,所以会依次打印出 8,8

    总结

    • 函数是否在 new 中调用(new 绑定),如果是,那么 this 绑定的是新创建的对象。
    • 函数是否通过 call,apply 调用,或者使用了 bind(即硬绑定),如果是,那么 this 绑定的就是指定的对象。
    • 函数是否在某个上下文对象中调用(隐式绑定),如果是的话,this 绑定的是那个上下文对象。一般是 obj.foo()
    • 如果以上都不是,那么使用默认绑定。如果在严格模式下,则绑定到 undefined,否则绑定到全局对象。
    • 如果把 Null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。
    • 如果是箭头函数,箭头函数的 this 继承的是外层代码块的 this。

    Notes from
    http://www.ruanyifeng.com/blog/2018/06/javascript-this.html

    相关文章

      网友评论

        本文标题:JavaScript中this的原理

        本文链接:https://www.haomeiwen.com/subject/scpsactx.html