Taian Su

“Something is elegant if it is two things at once: unusually simple and surprisingly powerful.”

解釋JavaScript的scope及closures,by Robert Nyman

| Comments

當發現我沒辦法向別人清楚的解釋一件事的時候,就會有一種「其實我自己也沒有弄的非常清楚」的認知。前陣子想解釋JavaScript裡的closures以慘敗收場。網路上翻到一篇文章看了之後有點「啊原來是這樣」。細讀之餘就順手翻譯一下,也許有人能用得上。

Closure中文常翻成’閉包’,不過這種看了也不會更懂的專有名詞,就留著不翻了…而Scope是變數的’作用域’或是’有效範圍’,依上下文需要翻或不翻。Rebort Nyman在2008寫了原文,請往Original Post找。


背景

有許多的文章試著解釋scope及closures,但基本上我得說他們大多數都沒辦法解釋的非常清楚。此外其中一部份的作者預設每個人都開發過15種以上的其它語言,但依我的經驗來說,寫JavaScript的人通常具備的是HTML及CSS的背景,而非C或是Java。(譯注: 在Node.js及Ajax興起的今天,也許這情況有點改變。)

因此本文謙遜的目標,是想讓大家都能領會到scope跟closure是什麼,它們如何運作的,以及該如何妥善的運用它們。在閱讀本文之前,你得先有一些變數及函式的基礎知識。

Scope

Scope代表變數及函式能夠被存取的範圍,以及它們在什麼樣的文本中被執行。一般來說,變數及函式可以定義在全域範圍或是區域範圍中。變數有所謂的’函式作用域’,而函式也有像是變數一樣的作用域。

Global Scope(全域範圍)

當某個東西是全域的,代表它在你程式碼的任何地方都可以被存取,看一下以下的範例:

如果在瀏覽器執行上面的程式碼,monkeygreetVisitor的存取範圍會是window。因此所有跑在同一個頁面下的程式都能存取這兩個變數。(譯注: 我把程式碼放到JsFiddle中,並依需要調整及註解,你可以按下Result來看結果。)

Local Scope(區域範圍)

與全域範圍相反,local scope是只宣告在程式碼的某一個區域中,也只能在這個區域中存取的東西。例如函式內部就是這些區域的一種。舉例來說:

如果你看一下上面的代碼,saying這個變數只能在talkDirty函式內部存取。在函式的外面,它根本就沒有被定義。特別要注意的是,如果你在第二行沒有用關鍵字var來定義saying,那它會自動變成全域變數。

這也代表如果你有巢狀的函式時,內層的函式能夠讀到在外部函式裡所定義的變數及函式:

如你所見,內層函式capitalizeName不需要傳入任何參數,但是它就能夠存取定義在外部函式saveName中的firstName這個參數。再用一個例子讓我們把事情弄的更清楚一點:

如你所見,兩個內層函式都可以存取外部函式的siblings陣列,而兩個同級的內層函式也可以彼此存取(如本例中joinSiblingNames去存取siblingCount)。然而定義在siblingCount函式中的siblingsLength變數,只能在該函式內部使用,這就是它的變數範圍。

Closures

現在你應該比較了解什麼是變數範圍了,我們把closures加進來看。Closures是一種將變數設為特定內容來運作的表達式,通常是函式。說的簡單一點,當內層函式存取了外部函式的變數,就會產生closure。舉例來說:

哇,!剛剛發生什麼事了? 我們一步步拆來看:

  1. 當呼叫add函式時,它會回傳一個函式。
  2. 那個回傳的函式封閉它的內文,並記憶封閉時x參數的值。(也就是上述程式碼中的5)
  3. 我們把回傳的函式指定到add5變數上,它會一直記得它建構當時x的值。
  4. 而這個add5變數代表一個會永遠把傳入參數加5的函式。
  5. 這就表示當我們呼叫add5時,傳入參數3,它就會把5跟3相加,然後回傳8。

因此在JavaScript的世界中,這個add5函式實際上看起來像這樣:

1
2
3
function add5(y){
  return 5 + y;
}

惡名昭彰的迴圈難題

你有沒有遇過試著用某些迴圈,把i指定成變數值,卻發現全部都回傳最後一個值的情況?

不正確的參照

我們來看看這個壞掉的範例,它會建立五個元素,把i設成顯示文字。再為每個元素綁定一個onclick事件,按下去後會alert這個元素的i值。也就是說會alert顯示文字的值。然後再把元素加到document body裡:

每個元素都顯示了正常的文字,也就是”Link 0”,”Link 1”等等。但不管我們按下哪一個,都會alert數字”5”。Oh my God,為什麼會這樣? 原因是這個i的值在每一次迴圈處理都會加1,而既然onclick事件還沒被觸發,只是綁定到元素的事件上,i的值會一直累加上去。(譯註:可以按Result)

因此這段迴圈一直循環到i變成5,addLinks函式結束。然後呢,不管哪一個onclick事件被觸發,它都會拿到i變數最後的那個值。

正確的參照

而你應該做的,是建立一個closure,這樣當你在把i綁定到事件上時,它會取得當下的那個值。像這樣:

用這段程式時,如果你按下第一個元素,它會alert0,第二個元素會alert1,依此類推,像是你看到上一段程式碼時期望的運作方式。這裡的解法是綁定onclick事件的那個內層函式創造了一個參照num參數,也就是i當時的值的closure。

而那個函式會把該數值閉鎖,安全的藏起來。等到觸發onclick事件時,能夠回傳對應的正確數值。

Self-invoking functions

Self-invoking functions是一種立刻執行,並建自己的closure的函式。看一下這個:

Ok,所以那個dog變數只能在內文裡存取。有什麼了不起啊,就是個藏起來的狗…但是,朋友,這就是它真正有趣的地方。這解決了我們上面迴圈的狀況,而且這也是Yahoo JavaScript Module Pattern 的基礎。

Yahoo JavaScript Module Pattern

這個pattern基本上是用一個self-invoking function來建立一個closure,從而讓JavaScript物件能夠有公開及私有的函數跟屬性。像這個簡單的例子:

這樣作的美好之處,在於從此你可以自己決定物件上的哪些東西要公開(以及可以被複寫),而那些是私有的,不能存取而且不能被更改。上面的name變數在函式外部看不到,但是能夠用getName函式取值及用setName函式設值。因為這兩個函式是建立了closure並參照了name變數。

結論

我誠摯的希望在看完這篇之後,無論是新手或有經驗的程序員,都能夠清楚的領會scope及closures在JavaScript實際上是怎麼運作的。歡迎各種問題及回應,而如果你的建議夠重要,我會把它加到我的文章裡。

Happy coding!

(原文完)


推薦延伸閱讀:

Comments