深入淺出 JavaScript 中的 this

Web開發者發表於2012-10-04

  JavaScript 是一種指令碼語言,因此被很多人認為是簡單易學的。然而情況恰恰相反,JavaScript 支援函數語言程式設計、閉包、基於原型的繼承等高階功能。本文僅採擷其中的一例:JavaScript 中的 this 關鍵字,深入淺出的分析其在不同情況下的含義,形成這種情況的原因以及 Dojo 等 JavaScript 工具中提供的繫結 this 的方法。可以這樣說,正確掌握了 JavaScript 中的 this 關鍵字,才算邁入了 JavaScript 這門語言的門檻。

  在 Java 等物件導向的語言中,this 關鍵字的含義是明確且具體的,即指代當前物件。一般在編譯期確定下來,或稱為編譯期繫結。而在 JavaScript 中,this 是動態繫結,或稱為執行期繫結的,這就導致 JavaScript 中的 this 關鍵字有能力具備多重含義,帶來靈活性的同時,也為初學者帶來不少困惑。本文僅就這一問題展開討論,閱罷本文,讀者若能正確回答 JavaScript 中的 What ’s this 問題,作為作者,我就會覺得花費這麼多功夫,撰寫這樣一篇文章是值得的。

  Java 語言中的 this

  在 Java 中定義類經常會使用 this 關鍵字,多數情況下是為了避免命名衝突,比如在下面例子的中,定義一個 Point 類,很自然的,大家會使用 x,y 為其屬性或成員變數命名,在建構函式中,使用 x,y 為引數命名,相比其他的名字,比如 a,b,也更有意義。這時候就需要使用 this 來避免命名上的衝突。另一種情況是為了方便的呼叫其他建構函式,比如定義在 x 軸上的點,其 x 值預設為 0,使用時只要提供 y 值就可以了,我們可以為此定義一個只需傳入一個引數的建構函式。無論哪種情況,this 的含義是一樣的,均指當前物件。

  清單 1. Point.java

public class Point { 
    private int x = 0; 
    private int y = 0; 
    
    public Point(x, y){ 
        this.x = x; 
        this.y = y; 
    } 
    
    public Point(y){ 
        this(0, y); 
    } 
 } 

  JavaScript 語言中的 this

  由於其執行期繫結的特性,JavaScript 中的 this 含義要豐富得多,它可以是全域性物件、當前物件或者任意物件,這完全取決於函式的呼叫方式。JavaScript 中函式的呼叫有以下幾種方式:作為物件方法呼叫,作為函式呼叫,作為建構函式呼叫,和使用 apply 或 call 呼叫。下面我們將按照呼叫方式的不同,分別討論 this 的含義。

  作為物件方法呼叫

  在 JavaScript 中,函式也是物件,因此函式可以作為一個物件的屬性,此時該函式被稱為該物件的方法,在使用這種呼叫方式時,this 被自然繫結到該物件。

  清單 2. point.js

 var point = { 
 x : 0, 
 y : 0, 
 moveTo : function(x, y) { 
     this.x = this.x + x; 
     this.y = this.y + y; 
     } 
 }; 

 point.moveTo(1, 1)//this 繫結到當前物件,即 point 物件

  作為函式呼叫

  函式也可以直接被呼叫,此時 this 繫結到全域性物件。在瀏覽器中,window 就是該全域性物件。比如下面的例子:函式被呼叫時,this 被繫結到全域性物件,接下來執行賦值語句,相當於隱式的宣告瞭一個全域性變數,這顯然不是呼叫者希望的。

  清單 3. nonsense.js

 function makeNoSense(x) { 
 this.x = x; 
 } 

 makeNoSense(5); 
 x;// x 已經成為一個值為 5 的全域性變數

  對於內部函式,即宣告在另外一個函式體內的函式,這種繫結到全域性物件的方式會產生另外一個問題。我們仍然以前面提到的 point 物件為例,這次我們希望在 moveTo 方法內定義兩個函式,分別將 x,y 座標進行平移。結果可能出乎大家意料,不僅 point 物件沒有移動,反而多出兩個全域性變數 x,y。

  清單 4. point.js

 var point = { 
 x : 0, 
 y : 0, 
 moveTo : function(x, y) { 
     // 內部函式
     var moveX = function(x) { 
     this.x = x;//this 繫結到了哪裡?
    }; 
    // 內部函式
    var moveY = function(y) { 
    this.y = y;//this 繫結到了哪裡?
    }; 

    moveX(x); 
    moveY(y); 
    } 
 }; 
 point.moveTo(1, 1); 
 point.x; //==>0 
 point.y; //==>0 
 x; //==>1 
 y; //==>1 

  這屬於 JavaScript 的設計缺陷,正確的設計方式是內部函式的 this 應該繫結到其外層函式對應的物件上,為了規避這一設計缺陷,聰明的 JavaScript 程式設計師想出了變數替代的方法,約定俗成,該變數一般被命名為 that。

  清單 5. point2.js

 var point = { 
 x : 0, 
 y : 0, 
 moveTo : function(x, y) { 
      var that = this; 
     // 內部函式
     var moveX = function(x) { 
     that.x = x; 
     }; 
     // 內部函式
     var moveY = function(y) { 
     that.y = y; 
     } 
     moveX(x); 
     moveY(y); 
     } 
 }; 
 point.moveTo(1, 1); 
 point.x; //==>1 
 point.y; //==>1

  作為建構函式呼叫

  JavaScript 支援物件導向式程式設計,與主流的物件導向式程式語言不同,JavaScript 並沒有類(class)的概念,而是使用基於原型(prototype)的繼承方式。相應的,JavaScript 中的建構函式也很特殊,如果不使用 new 呼叫,則和普通函式一樣。作為又一項約定俗成的準則,建構函式以大寫字母開頭,提醒呼叫者使用正確的方式呼叫。如果呼叫正確,this 繫結到新建立的物件上。

  清單 6. Point.js

 function Point(x, y){ 
    this.x = x; 
    this.y = y; 
 } 

  使用 apply 或 call 呼叫

  讓我們再一次重申,在 JavaScript 中函式也是物件,物件則有方法,apply 和 call 就是函式物件的方法。這兩個方法異常強大,他們允許切換函式執行的上下文環境(context),即 this 繫結的物件。很多 JavaScript 中的技巧以及類庫都用到了該方法。讓我們看一個具體的例子:

  清單 7. Point2.js

 function Point(x, y){ 
    this.x = x; 
    this.y = y; 
    this.moveTo = function(x, y){ 
        this.x = x; 
        this.y = y; 
    } 
 } 

 var p1 = new Point(0, 0); 
 var p2 = {x: 0, y: 0}; 
 p1.moveTo(1, 1); 
 p1.moveTo.apply(p2, [10, 10]); 

  在上面的例子中,我們使用建構函式生成了一個物件 p1,該物件同時具有 moveTo 方法;使用物件字面量建立了另一個物件 p2,我們看到使用 apply 可以將 p1 的方法應用到 p2 上,這時候 this 也被繫結到物件 p2 上。另一個方法 call 也具備同樣功能,不同的是最後的引數不是作為一個陣列統一傳入,而是分開傳入的。

  換個角度理解

  如果像作者一樣,大家也覺得上述四種方式不方便記憶,過一段時間後,又搞不明白 this 究竟指什麼。那麼我向大家推薦 Yehuda Katz 的這篇文章:Understanding JavaScript Function Invocation and “this”。在這篇文章裡,Yehuda Katz 將 apply 或 call 方式作為函式呼叫的基本方式,其他幾種方式都是在這一基礎上的演變,或稱之為語法糖。Yehuda Katz 強調了函式呼叫時 this 繫結的過程,不管函式以何種方式呼叫,均需完成這一繫結過程,不同的是,作為函式呼叫時,this 繫結到全域性物件;作為方法呼叫時,this 繫結到該方法所屬的物件。

  結束?

  通過上面的描述,如果大家已經能明確區分各種情況下 this 的含義,這篇文章的目標就已經完成了。如果大家的好奇心再強一點,想知道為什麼 this 在 JavaScript 中的含義如此豐富,那就得繼續閱讀下面的內容了。作者需要提前告知大家,下面的內容會比前面稍顯枯燥,如果只想明白 this 的含義,閱讀到此已經足夠了。如果大家不嫌枯燥,非要探尋其中究竟,那就一起邁入下一節吧。

  函式的執行環境

  JavaScript 中的函式既可以被當作普通函式執行,也可以作為物件的方法執行,這是導致 this 含義如此豐富的主要原因。一個函式被執行時,會建立一個執行環境(ExecutionContext),函式的所有的行為均發生在此執行環境中,構建該執行環境時,JavaScript 首先會建立 arguments變數,其中包含呼叫函式時傳入的引數。接下來建立作用域鏈。然後初始化變數,首先初始化函式的形參表,值為 arguments變數中對應的值,如果 arguments變數中沒有對應值,則該形參初始化為 undefined。如果該函式中含有內部函式,則初始化這些內部函式。如果沒有,繼續初始化該函式內定義的區域性變數,需要注意的是此時這些變數初始化為 undefined,其賦值操作在執行環境(ExecutionContext)建立成功後,函式執行時才會執行,這點對於我們理解 JavaScript 中的變數作用域非常重要,鑑於篇幅,我們先不在這裡討論這個話題。最後為 this變數賦值,如前所述,會根據函式呼叫方式的不同,賦給 this全域性物件,當前物件等。至此函式的執行環境(ExecutionContext)建立成功,函式開始逐行執行,所需變數均從之前構建好的執行環境(ExecutionContext)中讀取。

  Function.bind

  有了前面對於函式執行環境的描述,我們來看看 this 在 JavaScript 中經常被誤用的一種情況:回撥函式。JavaScript 支援函數語言程式設計,函式屬於一級物件,可以作為引數被傳遞。請看下面的例子 myObject.handler 作為回撥函式,會在 onclick 事件被觸發時呼叫,但此時,該函式已經在另外一個執行環境(ExecutionContext)中執行了,this 自然也不會繫結到 myObject 物件上。

  清單 8. callback.js

button.onclick = myObject.handler; 

  這是 JavaScript 新手們經常犯的一個錯誤,為了避免這種錯誤,許多 JavaScript 框架都提供了手動繫結 this 的方法。比如 Dojo 就提供了 lang.hitch,該方法接受一個物件和函式作為引數,返回一個新函式,執行時 this 繫結到傳入的物件上。使用 Dojo,可以將上面的例子改為:

  清單 9. Callback2.js

button.onclick = lang.hitch(myObject, myObject.handler); 

  在新版的 JavaScript 中,已經提供了內建的 bind 方法供大家使用。

  eval 方法

  JavaScript 中的 eval 方法可以將字串轉換為 JavaScript 程式碼,使用 eval 方法時,this 指向哪裡呢?答案很簡單,看誰在呼叫 eval 方法,呼叫者的執行環境(ExecutionContext)中的 this 就被 eval 方法繼承下來了。

  結束語

  本文介紹了 JavaScript 中的 this 關鍵字在各種情況下的含義,雖然這只是 JavaScript 中一個很小的概念,但藉此我們可以深入瞭解 JavaScript 中函式的執行環境,而這是理解閉包等其他概念的基礎。掌握了這些概念,才能充分發揮 JavaScript 的特點,才會發現 JavaScript 語言特性的強大。

相關文章