一文看穿JavaScript中this的圈圈繞

熊飼發表於2019-04-01

導文目錄


  • 為什麼說JavaScript中 this 指標圈圈繞?
  • JavaScript 中 this 繫結作用域的四種情況
    • 先搞清Node環境中和瀏覽器環境中全域性物件的異同
    • 預設繫結
    • 隱式繫結
    • 硬繫結(或者說 顯示繫結)
    • new操作符繫結
    • 四種繫結的優先順序
  • ES6中引入箭頭函式對this的繫結產生了什麼影響?
  • 附上前面程式的輸出答案

為什麼說JavaScript中 this 指標圈圈繞?


相比C++或者Java中的this指標的概念而言,JavaScript中的this指標更為 "靈活" ,C++或Java中的 this在類定義時便成為了一個指向特定類例項的指標,但是JavaScript中的this指標是可以動態繫結的,也就是說是依據上下文環境來指定this到底指向誰。這樣的機制給程式設計帶來了很大的靈活性(以及趣味性),但這也導致了在JavaScript程式設計中若不明白this指標的作用機制而濫用this指標的話,常常會引發一些 "莫名其妙" 的問題。比如說,下面這段程式:

1. let num = 10;
2. console.log(global.num); 
3. global.num = 20;
4. console.log(this.num);
5. console.log(this === global); 
6. function A(){
7.    console.log(this.num++);
8. }
9. let obj = {
10.     num : 30,
11.     B : function(){
12.         console.log(this.num++);
13.         return () => console.log(this.num++);
14.      }
15. }
16. A();
17. let b = obj.B; 
18. b()();  
19. obj.B();   
20. b.apply(obj);
21. new A(); 
22. console.log(global.num); 
複製程式碼

你能列出最終所有的輸出嗎?你可以先嚐試著寫一下,不要複製到VSCode中執行哦~ ,手動寫出答案!寫完先看一下最後面的答案,看你是否寫對了。如果寫對了說明你已經基本掌握了JavaScript中this指標的機制 (PS:設定這裡執行環境是node環境 ;如果沒有寫對,那看完本文相信就可以對this有一個基本清楚的認識了。

相信你肯定忍不住去看了答案了,或許答案看起來雜亂無章,這也是為什麼this作為JavaScript中最複雜的機制之一經常被拿到面試中去考察JS的功底。以下內容可能需要花費8-10分鐘時間,但是會讓讀者你受益匪淺的,你的疑問也可以在下面的內容中得到解答!

JavaScript 中 this 繫結作用域的四種情況


先搞清Node環境中和瀏覽器環境中全域性物件的異同

在講解this繫結作用域的四種情況之前,我們先要弄清楚一個問題。Node環境中的全域性作用域和瀏覽器環境下的全域性作用域有什麼不同?

這個問題很重要,因為這個異同,會導致同樣的程式碼在Node環境和瀏覽器環境下的表現不盡相同。就比如我們這裡要講的this指標的指向會因為環境不同而不同。這個不同體現在以下三點:

  • 瀏覽器的全域性作用域的全域性物件是window ; 而Node中這個"等價"的全域性物件是global
  • 瀏覽器環境下全域性作用域下的this指向的就是window物件; 但是Node環境下全域性作用域中的thisglobal是分離的,this指標指向一個空物件
  • 瀏覽器環境下全域性作用域中宣告的變數會被認為是全域性物件window的屬性;但是Node下全域性作用域下的宣告的變數不屬於global

由此,你便可知,上面程式碼中1-5的輸出了,就像下面這樣:

undefined  // 1 
undefined  // 2
false      // 3
複製程式碼

為了方便講解,我給每個輸出編了號,我們依次來看:

  1. 第一個undefined是因為Node的全域性作用域上的變數並不會作為global的屬性,此時global.num尚未賦值,所以是undefined
  2. 第二個undefined是因為Node中全域性作用域中的this並不指向global,所以此時this.num尚未賦值,所以也是undefined
  3. 第三個false也更加應證了 2 中的結論,Node中全域性作用域的thisglobal風馬牛不相及

【PS】上面我一直強調是全域性作用域下的this ,為什麼呢?因為Node中在子作用域中的this的行為和瀏覽器中是相仿的,基本一致

預設繫結

下面我們來講解JavaScript中this繫結作用域的四種情況。

先來說第一種——預設繫結 ,我們可以這樣理解 預設繫結 ,this指標在作用域內沒有認領的物件時就會預設繫結到全域性作用域的全域性物件中去,在Node中就是會繫結到global物件上去。這是一個很形象的說法,雖然不嚴謹但是好理解,我們看下面這幾個例子,來說明什麼情況下,this沒有物件認領。

global.name = 'javascript';
(function A(){
    this.name += 'this';
    console.log(this.name);//輸出 javascriptthis
})();
console.log(global.name);//輸出 javascriptthis
複製程式碼

在函式A的作用域內,this並沒有可以依靠的物件,所以this指標便開啟預設繫結模式,此時指向的是global

這裡我們有必要明確一個概念,有別於JavaScript中"一切皆為物件"的概念,雖然A確實是一個Function型別的物件 , 下面的例子可證明確實如此

function A(){}
console.log(A instanceof Function); //輸出 true
console.log(A instanceof Object);   //輸出 true
複製程式碼

但是function A(){}只是一個函式的宣告,並沒有例項物件的產生,而this是需要依託於一個存在的例項物件 , 如果使用new A()則便有了例項物件,this也就有了依託,有關new操作符繫結在後面說。

明白了這一點,我們來看一個更為複雜的例子:

global.name = 'javascript';
(function A(){
    this.name += 'this';
    return function(){
        console.log(this.name);//輸出 javascriptthis
    }
})()();
console.log(global.name);//輸出 javascriptthis
複製程式碼

這個例子中函式A返回了一個匿名函式也可以叫閉包,我們發現this照樣繫結在了global上。這個例子是想告訴讀者,預設繫結和作用域層級沒有關係,只要是在作用域內this找不到認領的例項物件,那就會啟用預設繫結。

由此,你是不是可以知道開篇的例子中 6,7,8,16行的輸出結果了?

20  //這裡是後置自增運算,所以先輸出後加一
複製程式碼

隱式繫結

隱式繫結顧名思義沒有顯式的表明this的指向,但是已經繫結的某個例項物件上去了。舉個簡單的例子,這個用法其實是我們最常用的:

global.name = 'javascript' ;
let obj = {
    name : 'obj',
    A    : function(){
        this.name += 'this';
        console.log(this.name); 
    }
}
obj.A();//輸出 objthis
console.log(global.name);//輸出 javascript
複製程式碼

這個例子中函式A的作用域內,this總算是有物件認領了,這個物件就是obj,所以this.name指向的就是obj中的name ,這種情況就叫做隱式繫結

隱式繫結雖然是我們最常用的,也是相對好理解的一種繫結方式,但是確是四種繫結中最坑的一種,為什麼呢?因為,這種情況下this一不小心就會找不到認領她的物件了,我們稱之為"丟失"。而在"丟失"的情況下,this的指向會啟用預設繫結。我們看下面的例子;

global.name = 'javascript' ;
let obj = {
    name : 'obj',
    A    : function(){
        this.name += 'this';
        console.log(this.name)
    },
    B    : function(f){
        this.name += 'this';
        f();
    },
    C    : function(){
      setTimeout(function(){
          console.log(this.name);
      },1000);
    }
}
let a = obj.A;              // 1
a();                        // 2
obj.B(function(){           // 3
    console.log(this.name); // 4
});                         // 5
obj.C();                    // 6
console.log(global.name);   // 7
複製程式碼

這裡列出了三種"丟失"的情況:

  1. 1-2行中obj的A函式賦值給了a,然後呼叫a(),這時候函式的執行上下文發生了變化,相當於是全域性作用域下的一個函式的執行,所以承接我們上面所說,此時啟用了預設繫結
  2. 3-5行中給obj.B傳遞一個Function引數,並且在Bf()執行,這相當於一個B中的立即執行函式,此時在this所在作用域找不到認領的物件,於是啟用預設繫結
  3. 6行是最有意思的一行,為什麼呢?因為這一行在Node環境瀏覽器環境下的結果是不一樣的,按照常理來說,回撥函式中的this同樣會因為丟失而啟用預設繫結,在瀏覽器環境下確實如此。但是在node中事情好像沒那麼簡單,我們先看看輸出的結果,在做分析
javascriptthis // 1-2行執行結果
javascriptthis // 3-5行執行結果
javascriptthis // 7行執行結果
undefined      // 6行執行結果
複製程式碼

你會發現有一個值很扎眼,沒錯,就是undefined,那為什麼setTimeout()回撥中的this沒有啟用預設繫結呢?這裡根據這篇部落格做了一個猜想 : NodeJS 回撥函式中的this ,我建議你看一看這篇部落格

亦如fs.open()回撥一樣,setTimeout()函式會先初始化自己,那麼此時回撥函式作用域上就是存在例項物件了,只是這個物件我們看不到而已,所以此時this.name並未初始化,所以輸出undefined。為此我做了一個實驗來證明,setTimeout()this指向不等於global

function A(){
    console.log(this === global);
}
A();  //輸出  true
setTimeout(function(){
    console.log(global === this);
},1000);  // 輸出  false
複製程式碼

由此,我們可以知道,開篇例子中18,19行的輸出便是:

21 // 隱式繫結丟失
22 // 箭頭函式繫結上級作用域this指標,這個後面會講
30 //隱式繫結
複製程式碼

硬繫結(顯式繫結)

接下來要講的是硬繫結 , 這個比較簡單,是通過JS的三個常用API來顯式的實現繫結特定作用域,這三個API為

  • apply
  • call
  • bind

這三個API之間的關係非本篇關鍵,可以自行了解,本篇以apply為例

我們知道JS函式的一大特點就是有 定義時上下文執行時上下文 以及 上下文可變 的概念,而apply就是幫助我們改變函式執行時上下文的環境,這種通過API顯式指定某個函式執行上下文環境的繫結方式就是 硬繫結

我們來看下面這個例子:

global.name = 'global';
function A(){
    console.log(this.name);
}
let obj = {
    name : 'obj'
}
A.apply(obj); //輸出 obj
A.apply(global); //輸出 global
複製程式碼

對,你應該懂了,什麼叫硬繫結。就是不管函式你定義在哪裡,這樣使用了我這個API,你就可以為所欲為,繫結到任意作用域的物件上去,哪怕是global都不帶怕的,硬核API !!!

由此,你也可以得到開篇例子中20行輸出結果應該是:

31  //obj.name在此之前被加了一次1,所以這裡是31
複製程式碼

new操作符繫結

最後一種繫結方式是new操作符繫結,這個也是JS中最常用的用法之一了,簡單來說就是通過new操作符來例項化一個物件的過程中發生了this指標的繫結,這個過程是不可見的,是後臺幫你完成了這一繫結過程。具體是什麼過程呢?這裡我們就已開篇的例子為例吧

function A(){
    console.log(this.num++);
}
new A(); //輸出為 NaN
複製程式碼

NaN是JSNumber物件上的一個靜態屬性,意如其名"not a number",表示不是數字。這裡new A()例項化了一個物件,此時在A的作用域裡就用物件認領this指標了,所以此時this指向例項化物件,但是這個物件中num屬性並沒有初始化,因此是undefined,而undefined非數字卻使用了++運算,因此最終輸出了NaN

四種繫結的優先順序

既然this的繫結有四種機制,那必定會出現機制衝突的情況,沒關係,其實從上面的講解中你應該已經能隱約感覺到這四種機制是有優先順序存在的。比如,在new操作符繫結的時候,就是因為new繫結優先順序高於預設繫結,所以this指標指向的是新例項化的物件而不是全域性物件global。這裡給出這四種繫結的優先順序 :

 new 操作符繫結   >    硬繫結   >    隱式繫結   >    預設繫結

這個關係還是挺明顯的,故不作例子闡述了。

ES6中引入箭頭函式對this的繫結產生了什麼影響?


快要結束了,再堅持一下,最後有必要說明以下ES6中的箭頭函式對於this指標繫結的影響,ES6中引入箭頭函式是為了更優雅的書寫函式,對於那些簡單的函式我們使用箭頭函式代替原來的函式寫法可以大大簡化程式碼量而且看上去更加整潔優雅。也正是因為箭頭函式的設計是為了簡潔優雅,所以箭頭函式除了簡化程式碼表示以外,還簡化了函式的行為。

  • 箭頭函式不能宣告的方式定義,只能通過函式表示式
  • 箭頭函式不能通過new來例項化物件
  • 也是因為上面的原因,箭頭函式中並沒有自己的this指標,這不代表不能使用this,箭頭函式中的this是繼承自父級作用域上的this,也就是說箭頭函式中的this繫結的是父級作用域內this所指向的物件

舉個例子來講:

name = 'global';
this.name = 'this';
let obj = {
    name : 'obj',
    A    : function(){
        ()=>{
            console.log(this.name)
        }
    },
    B    :() => {
        console.log(this.name)
    }
}
obj.A(); //輸出 obj
obj.B(); //輸出 this 
複製程式碼

這裡或許obj.B()輸出讓你疑惑,其實我們開篇也講了,全域性作用域下thisglobal風馬牛不相及,所以這裡對應到父級作用域中this對應的物件就是this本身或者export

由此,開篇示例中18行的輸出便可知曉了 :

21 // b() 所輸出
22 // (b())()所輸出
複製程式碼

這裡有些繞,之所以最終this繫結到了global上,是分了兩步

  • 首先,因為是箭頭函式,所以this繼承父級this繫結到了obj
  • 因為隱式呼叫的"丟失",導致父級this預設繫結到了global

附上前面程式的輸出答案

undefined
undefined
false
20
21
22
30
31
NaN
23
複製程式碼

總算是寫完了,寫作的過程,筆者收穫也很大,就比如Node中回撥函式的this指向問題我也沒有想到,是通過實驗才印證Node中回撥函式中this指向的是自身例項化的物件,這個工作同樣不可見,後臺完成了,就像new一樣。 希望讀者也可以得到收穫!

下面是我的微信公眾號,如果覺得本篇文章你收穫很大,可以關注我的微信公眾號,我會同步文章,這樣可以RSS訂閱方便閱讀,感謝支援!

一文看穿JavaScript中this的圈圈繞

相關文章