JavaScript中this的原理

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指的是函數運行時所在的環(huán)境。對于obj.foo()來說,foo運行在obj環(huán)境,所以this指向obj;對于foo()來說,foo運行在全局環(huán)境,所以this指向全局環(huán)境。所以,兩者的運行結果不一樣。

內存的數據結構

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]]: 函數的地址
    ...
  }
}

由于函數是一個單獨的值,所以它可以在不同的環(huán)境(上下文)執(zhí)行。

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

// 單獨執(zhí)行
f()

// obj 環(huán)境執(zhí)行
obj.f()

環(huán)境變量

JavaScript 允許在函數體內部,引用當前環(huán)境的其他變量。

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

上面代碼中,函數體里面使用了變量x。該變量由運行環(huán)境提供。

現在問題就來了,由于函數可以在不同的運行環(huán)境執(zhí)行,所以需要有一種機制,能夠在函數體內部獲得當前的運行環(huán)境(context)。所以,this就出現了,它的設計目的就是在函數體內部,指代函數當前的運行環(huán)境。

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

上面代碼中,函數體里面的this.x就是指當前運行環(huán)境的x。

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

var x = 1;
var obj = {
  f: f,
  x: 2,
};

// 單獨執(zhí)行
f() // 1

// obj 環(huán)境執(zhí)行
obj.f() // 2

上面代碼中,函數f在全局環(huán)境執(zhí)行,this.x指向全局環(huán)境的x。


4.png

在obj環(huán)境執(zhí)行,this.x指向obj.x。

5.png
  • obj.foo()是通過obj找到foo,所以就是在obj環(huán)境執(zhí)行。
  • 一旦var foo = obj.foo,變量foo就直接指向函數本身,所以foo()就變成在全局環(huán)境執(zhí)行。

this的綁定方式

默認綁定

默認綁定是在不使用其他綁定規(guī)則時的規(guī)則,通常是獨立函數的調用。

function greeting() {
  console.log(`Hello, ${this.name}`);
}

var name = 'Eric';

greeting();

// Hello, Eric

隱式綁定

隱式綁定指的是在一個對象上調用函數。

通過 obj 調用 greeting 方法,this 就指向了 obj

將 obj.greeting 賦給了一個全局的變量 otherGreeting,所以在執(zhí)行 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中,一段代碼執(zhí)行時,會先執(zhí)行宏任務中的同步代碼,當遇到setTimeout之類的宏任務,那么就把這個 setTimeout內部的函數推入「宏任務的隊列」中,下一輪宏任務執(zhí)行時調用。當本輪宏任務調用結束后,下一輪宏任務執(zhí)行時,此時函數位于內存中,this指向全局環(huán)境。此時 this.name 就是 Eric

  • obj2.greeting()調用

greeting函數位于內存中,通過obj2來調用,那么this的指向環(huán)境變成了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環(huán)境指向window,這個與前面的實驗obj1.greeting()調用是一致的,此時相當于在全局環(huán)境中,我們來調用了obj2.greeting(),這個與前面的實驗obj2.greeting()調用是一致的,所以打印的結果是Hello,Obj2

顯式綁定

顯示綁定就是通過 call, apply, bind 來顯式地指定 this 的綁定對象。三者的第一個參數都是傳遞 this 指向的對象,call 與 apply 的區(qū)別是前者從第二個參數起傳遞一個參數序列,后者傳遞一個數組,call, apply 和 bind 的區(qū)別是前兩個都會立即執(zhí)行對應的函數,而 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. 第二步執(zhí)行 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 是一個立即執(zhí)行函數(this 會指向 window),所以在 obj 創(chuàng)建時就會執(zhí)行一次,并返回閉包函數。

var number; // 創(chuàng)建了一個私有變量 number 但未賦初值
this.number *= 2; // this.number 指向的是全局那個 number,所以 window.number = 10
number = number * 2; // 因為私有變量 number 未賦初值,所以乘以 2 會變?yōu)?NaN
number = 3; // 此時私有變量 number 變?yōu)?3

接著執(zhí)行下面兩句:

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

當執(zhí)行 obj.fn(); 時,此時的 this 指向的是 obj:

var num = this.number; // 因為 obj.number = 3,所以 num 也就為 3
this.number *= 2; // obj.number 變?yōu)?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 執(zhí)行,所以這里是默認綁定,此時 this 指向 window,所以執(zhí)行 fn() 時會打印出 10

arguments0,就相當于執(zhí)行 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 綁定的是新創(chuàng)建的對象。
  • 函數是否通過 call,apply 調用,或者使用了 bind(即硬綁定),如果是,那么 this 綁定的就是指定的對象。
  • 函數是否在某個上下文對象中調用(隱式綁定),如果是的話,this 綁定的是那個上下文對象。一般是 obj.foo()
  • 如果以上都不是,那么使用默認綁定。如果在嚴格模式下,則綁定到 undefined,否則綁定到全局對象。
  • 如果把 Null 或者 undefined 作為 this 的綁定對象傳入 call、apply 或者 bind,這些值在調用時會被忽略,實際應用的是默認綁定規(guī)則。
  • 如果是箭頭函數,箭頭函數的 this 繼承的是外層代碼塊的 this。

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯(lián)系作者。