JS: Closure

前陣子看線上課程學習使用React遇到useState沒有立即更新問題,參考一篇stackoverflow的文章,當中的最佳解答有提及Closure,因為感覺自己對Closure沒有很理解,決定留篇文章整理一下對Closure的想法。另外,學習其他語言偶爾也會談到Closure(例如C++的Lambda),但這邊以JavaScript(簡稱:JS)的語法和角度去討論。

以下有些名詞在中文沒有統一翻譯,所以文章談到名詞都會以英文為主、中文為輔;而Closure的中文譯名是閉包,為了深刻理解Closure是一個特別的函式,若有用到Closure的中文名詞都會稱為閉包函式。

先用一句話簡單定義什麼是Closure?

一個函式(function),可以用來存取外層作用域(Scope)。

簡言之,Closure就是一個有特殊功用的函式(function),因為能夠存取外層作用域的特性,Closure可以幫忙間接存取(access)或保存(preserve)外層作用域的變數。

這樣說可能會很難理解,以下會有Scope、Nested Scope、Lexical Scoping 和 Scope Chain 的名詞和觀念複習,才會回頭來談Closure。不過這篇文章最主要是了解Closure,複習名詞不會聊太多,不看應該不會影響後面對於Closure觀念的理解,所以可以斟酌閱讀。



這部分是名詞複習,若看完仍不太能了解可參考本篇最底部References的相關文章連結或其他網路文章書籍。

Scope

中譯:作用域/範圍/範疇

Scope定義變數(variable)的可存取性(accessibility)和可見性(visibility)

作用域在JS可以分為三種:

  1. global scope: 作用域存在於整個JavaScript檔案開始到檔案結束;
  2. function scope (有的稱local scope): 作用域存在於函式開始至函式結束;
  3. block scope (ES6): ECMA2015(ES6)標準後才出現,作用域存在於任何大括號 {} ,舉凡if-else、for loop、switch等,所以作用域就存在於這些左大括號 { 開始至右大括號 }

一個變數是否能夠存取,端看於這個變數所處的作用域,一旦離開變數所在作用域,則無法存取這個變數。

以Example 1的 global Msg 來說, global Msg 到檔案關閉前為止都可以被整個檔案作用域內的其他變數或函式等取用;不一樣的是inner函式內的 innerMsg 就只能被inner函式作用域內的變數或函式取用;當我們離開inner函式的作用域,在outer函式內 alert(innerMsg); 就會出錯。

/**
 *  Example 1:
 *  nested scopes, scope chain, lexical scoping
 */

let globalMsg = "I am global message";

function outer() {
    let outerMsg = "I belong to outer function";

    function inner() {
        let innerMsg = "I only can be accessed by inner";
        alert(outerMsg);
        alert(globalMsg);
    }

    // alert(innerMsg); // outer cannot access variables inside inner function

    inner();           // execute inner function inside outer function's scope
    }

outer();


Nested Scope

中譯:Nested 一般稱巢狀,這個複合名詞可以稱巢狀作用域/巢狀範圍/巢狀範疇。

Nested Scope是指Scope內還有Scope。

就如同Example 1 Outer函式的作用域內還有另一個inner函式的作用域,可想成是Scope像蜂巢一層一層由外而內地包住裡面的Scope,通常會稱被其他Scope包圍的Scope為 內層(inner),包住其他Scopes或自己內部還有其他Scopes的Scope為外層(outer)

Lexical Scoping

又稱Static Scoping,指的是JavaScript Engine解析(resolve)一個變數的方式,即指在編譯期(Compile Time)就根據變數被宣告的位置來決定變數的可存取性。

Lxeical是語彙、語法的意思,Lexical Scoping 就是根據所寫的JS程式碼,變數被宣告在哪裡就怎麼去取用這個變數,簡單來說JS引擎解析後的結果就如我們眼睛所見、能夠一眼看出來,例如 Example 1 裡,

  1. inner function 可以存取宣告在 Outer Scope 的變數:如下面程式碼],被 outer function 包住的 inner function 能夠使用 outerMsg 這個變數。
  2. 相反地,outer function 不能存取宣告在內層函式的變數,如程式碼第11行。

※註:與Static Scoping相反的是Dynamic Scoping,指的是編譯器會在執行時期(Run Time)才會決定所存取的變數值,使用Dynamic Scoping的語言並不多,重點是JavaScript內並沒有Dynamic Scoping的機制,這邊有點超出範圍,但還是稍微用JS語法寫一下差異:

let msg = "I am global message";

function showMsg(){
    console.log(msg);
}

function outer() {
    let msg = "I belong to outer function";
    showMsg();
}

outer();  //  Lexical/Static Scoping: I am global message
          //  Dynamic Scoping: I belong to outer function


Scope Chain

Scope Chain 是指在內層作用域存取某個變數時,發現在內層作用域找不到這個變數的宣告(declarement),就會逐步往外層作用域搜尋這個變數的宣告,直到找到或沒找到為止。

JavaScipt基於前面的Lexical Scoping而發展出這樣的搜尋方式,若有找到就回傳,但如果一直找到最外層的global scope,都沒找到這個變數宣告,也就是整份檔案內都沒有這個變數宣告,就會回報錯誤或undefined。


Okay,回來看前面所說的Closure定義。

Closure

一個函式(function),可以用來存取外層作用域(Scope)。

先看下面Example2的 alert(outerMsg); ,從JavaScript engine基於Lexical Scoping的Scope Chain機制我們可以知道,被宣告在outer函式內的變數 outerMsg 能夠被其作用域內層的inner函式所存取。

你可以把宣告理解成 變數的定義 ,因為我們沒有在inner函式作用域內定義 outerMsg,JavaScript Engine 如果在inner函式作用域內找不到 outerMsg 的定義,它會往inner函式以外,也就是外層 - outer函式的作用域去搜尋,果然它在這裡找到了,就會讓 alert(outerMsg); 正常發揮。

再來看一個常被忽略的Closure例子,注意一下Example2中分別讓outer function和inner function存取到最外層的 globalMsg,也成功存取到了,因此這邊小結幾個關於Closure的資訊:

  1. outer function是一種Closure,所以其實所有能夠存取到global scope的function都是一種Closure;
  2. 從inner function可以呼叫 globalMsg 來看,Closure不僅可以存取外層作用域 alert(outerMsg); ,也能存取外層的外層作用域 alert(globalMsg); (甚至外層的外層的...外層的外層作用域)。

總之,有了Closure,即使我們不呼叫outer function,也能使用outer function內層的閉包函式-inner function來達到操作outer function內變數的效果了!

/**
 *  Example 2: closure concept
 */

let globalMsg = "I am global message";

function outer() {
    let outerMsg = "I'm outer's message";
    console.log(globalMsg);

    function inner() {
        alert(outerMsg);
        alert(globalMsg);
    }
}

接著看另一個常見的Closure範例 Example3,因為我們能藉由outer function內的閉包函式-inner function,在outer function以外的地方來間接存取outerMsg,所以在程式碼最後幾行先用變數 callInner 儲存 outer function 回傳的結果,意即儲存 outer fucntion 所回傳的 inner function參照 - return inner;

注意:return inner; 不能加上呼叫函式的運算子 () ,如果寫成 return inner(); 代表的是會立即呼叫 inner function。

意思是還沒寫下 callInner() 以前, 在 let callInner = outer(); 的這個階段就會立即呼叫 inner function,但這不是我們想要的結果,因為儲存變數時通常不會想立即執行某件事情,而是希望等到適當的時機才使用這個變數。

不過方便起見,這裡儲存變數以後接著就寫下 callInner(); 代表呼叫所儲存的 outer fucntion 回傳結果,也就是呼叫 inner function。

/**
 *  Example 3: closure concept
 */

let globalMsg = "I am global message";

function outer() { 
    let outerMsg = "I belong to the scope of outer function.";

    function inner() {
        let innerMsg = "I only can be accessed by inner";
        alert(globalMsg);
        alert(outerMsg);    
    }
    // alert(innerMsg);    // outer cannot access local variable inside inner function

    return inner;          // Do not add parentheses (), namely, inner();
                           // they would execute inner function when we invoke outer function
}

// console.log(outerMsg);  // error, cannot access variable inside outer function's scope

let callInner = outer();
callInner();               // This invokes inner function wrapped inside the outer function

補充一下:

所謂回傳inner function的參照是什麼意思?

其實這部分還沒蒐集到更多資料確定,但考慮到JavaScript是一種類C的語言,這邊用以前學C/C++的記憶來理解。在C/C++裡面,函式名稱代表的是指向這個函式的變數名。

回想一下JavaScript的函式表達式(function expression),const func = function () { console.log("I'm anonymous function");},為什麼函式表達式可以儲存匿名函式(anonymous function)?因為函式表達式會用一個變數去儲存,或說 參照 到這個函式,我想同理可證,這件事也許印證了函式名同時也可以當作參照到這個函式的變數名。

因此,return inner; 表示回傳這個參照到這個函式的變數名,所以最後的 callInner(); 才會是直接呼叫inner function的結果 :) 。

最後附上比較複雜的Example 3 JSFiddle執行連結,以及淺顯易懂的Youtube解說影片:

後記: 不確定為何Closure的中文翻譯被翻成閉包,但從上面範例來看,outerMsg 的存取權就好比 被封閉 在 outer function 內部,可能才這樣稱呼?


References

  1. Scope & Lexical Scoping
  2. Function
  3. Clousure