Javascript 閉包並非魔法

TONGZ發表於2018-03-28

本文翻譯自JavaScript closures for beginners

閉包不是什麼魔法

本篇文章介紹了閉包,方便程式設計師們能夠進一步理解javascript程式碼,本文適合有一定程式設計經驗的程式設計師,比如可以看懂如下程式碼:大神請繞道。

#####Example 1

function sayHello(name) {
  var text = 'Hello ' + name;
  var say = function() { console.log(text); }
  say();
}
sayHello('Joe');  //Hello Joe
複製程式碼

一旦深刻理解了核心概念,閉包就並不難分析和運用了。

一個關於閉包的案例

兩句話總結:

  • 第一級函式支援閉包。閉包它是一個表示式,可以在閉包的範圍內引用變數(當它被首次宣告),被賦值給變數,作為引數傳遞給函式,或作為函式結果返回。(譯者注:在JavaScript世界中函式是一等公民,它不僅擁有一切傳統函式的使用方式(宣告和呼叫),還可以做到像原始值一樣賦值、傳參、返回,這樣的函式也稱之為第一級函式(First-class Function)
  • 閉包是在函式開始執行時分配的堆疊幀,並且在函式返回後不會釋放(就像“堆疊幀”分配在堆上而不是棧上!)。

#####Example 2 以下程式碼返回一個函式的引用:

function sayHello2(name) {
  var text = 'Hello ' + name; // Local variable
  var say = function() { console.log(text); }
  return say;
}
var say2 = sayHello2('Bob');
say2(); // logs "Hello Bob"
複製程式碼

大多數JavaScript程式設計師都瞭解如何將一個函式的引用賦給上述程式碼中的變數say2。如果你不瞭解,那麼在學習閉包之前你需要先了解一下。一個使用C的程式設計師會將其看作是返回指向某函式的指標,會認為變數saysay2都是指向函式的指標。 C語言指向函式的指標和JavaScript的函式引用之間存在著很關鍵的區別。在JavaScript中,您可以將函式引用變數看作既包含指向函式的指標,也包含指向閉包的隱藏指標。

上述程式碼中存在一個閉包,因為匿名函式function() { console.log(text); }在另一個函式sayHello2()中宣告。在這個例子中,如果你在另一個函式體裡使用function關鍵字,那麼你正在建立閉包。

在C語言和其他大多數類似語言中,在函式返回後,所有區域性變數不可再被訪問,因為堆疊幀已經被銷燬了。

而在Javascript語言中,如果你在一個函式體內再宣告一個函式,這個函式被返回到了全域性,區域性變數依然可以被訪問。如上面所示,我們在函式sayHello2()返回後呼叫了函式say2(),請注意,變數text是函式sayHello2()的區域性變數。

function() { console.log(text); } // Output of say2.toString();
複製程式碼

注意say2.toString()的輸出,我們可以看到這段程式碼引用了變數text,由於sayHello2()的區域性變數被儲存到閉包內,所以這個匿名函式可以引用儲存"Hello Bob"的變數text

在Javascript中函式引用包含指向它所建立的閉包的隱藏指標就類似於js中的事件委託(一個事情本需要自己做,但自己委託給別人做了)。

##更多案例 出於某種原因,閉包似乎很難理解,但是當你多看一些案例之後,它的工作原理變得逐漸清晰(我花了很長時間才搞清楚)。我建議你仔細研究這些案例,直至弄明白閉包是如何工作的。如果你在沒弄明白之前就使用閉包,就一定會碰到一些非常奇怪的錯誤。 #####Example 3 這個案例表明,區域性變數沒有被複制,而是它們的引用被儲存。就好像當外部函式退出後在記憶體保留一個堆疊幀。

function say667() {
  // Local variable that ends up within closure
  var num = 42;
  var say = function() { console.log(num); }
  num++;
  return say;
}
var sayNumber = say667();
sayNumber(); // logs 43
複製程式碼

#####Example 4 所有這三個全域性函式都有一個對同一個閉包的共同引用,因為它們都是在同一個setupSomeGlobals()函式中宣告的。

var gLogNumber, gIncreaseNumber, gSetNumber;
function setupSomeGlobals() {
  // Local variable that ends up within closure
  var num = 42;
  // Store some references to functions as global variables
  gLogNumber = function() { console.log(num); }
  gIncreaseNumber = function() { num++; }
  gSetNumber = function(x) { num = x; }
}

setupSomeGlobals();
gIncreaseNumber();
gLogNumber(); // 43
gSetNumber(5);
gLogNumber(); // 5

var oldLog = gLogNumber;

setupSomeGlobals();
gLogNumber(); // 42

oldLog() // 5
複製程式碼

這三個函式共用一個閉包——三個函式被定義時,函式setupSomeGlobals()的區域性變數。 請注意,在上述案例中,如果再次呼叫setupSomeGlobals(),則會建立一個新的閉包(堆疊幀)。舊的gLogNumber, gIncreaseNumber, gSetNumber變數被具有新閉包的新函式覆蓋(在Javascript中,無論何時在另一個函式內宣告一個函式,每次呼叫外部函式時都會重新建立內部函式)。

#####Example 5 這個案例對於許多人來說是一個大難題,你需要仔細理解一下。如果你要在一個迴圈體中定義一個函式,要非常小心,閉包中的區域性變數可不會想你想當然那樣工作。

function buildList(list) {
    var result = [];
    for (var i = 0; i < list.length; i++) {
        var item = 'item' + i;
        result.push( function() {console.log(item + ' ' + list[i])} );
    }
    return result;
}

function testList() {
    var fnlist = buildList([1,2,3]);
    // Using j only to help prevent confusion -- could use i.
    for (var j = 0; j < fnlist.length; j++) {
        fnlist[j]();
    }
}

 testList() //logs "item2 undefined" 3 times
複製程式碼

這行程式碼result.push( function() {console.log(item + ' ' + list[i])} );所示,將一個匿名函式的引用新增到result陣列中三次。如果你對匿名函式不熟悉,也可當成如下:

pointer = function() {console.log(item + ' ' + list[i])};
result.push(pointer);
複製程式碼

請注意,當案例執行時,"item2 undefined"會輸出三次!這是因為跟之前案例一樣,buildList的區域性變數只有一個閉包。當在執行fnList[j]()呼叫匿名函式時,三個匿名函式都共用一個閉包,並且它們使用的是迴圈結束後的當前值作為該閉包中的iitem(迴圈已完成,i的值為3,item值為"item2")。請注意,該迴圈從0開始索引,到迴圈結束前item值為"item2",而i ++會將i值增加到3。

#####Example 6 此案例顯示:在外部函式退出前,外部函式內宣告的所有全域性變數都包含在閉包內。請注意,變數alice實際上是在匿名函式之後宣告的,匿名函式是最先宣告的,當該函式被呼叫時,它仍然可以訪問alice變數,因為該變數處於相同作用域內(Javascript宣告提升)。另外,sayAlice()()只是直接呼叫從sayAlice()返回的函式引用。

function sayAlice() {
    var say = function() { console.log(alice); }
    // Local variable that ends up within closure
    var alice = 'Hello Alice';
    return say;
}
sayAlice()();// logs "Hello Alice"
複製程式碼

需要注意的是:say變數也在閉包中,可以通過sayAlice()中任何可能宣告的其他函式訪問,或者可以在內部函式內遞迴訪問。

#####Example 7 最後這個案例表明,每次呼叫外部函式都會為區域性變數建立一個單獨的閉包。不是每個函式宣告都有單獨閉包,而是每次函式呼叫都會建立一個閉包。

function newClosure(someNum, someRef) {
    // Local variables that end up within closure
    var num = someNum;
    var anArray = [1,2,3];
    var ref = someRef;
    return function(x) {
        num += x;
        anArray.push(num);
        console.log('num: ' + num +
            '; anArray: ' + anArray.toString() +
            '; ref.someVar: ' + ref.someVar + ';');
      }
}
obj = {someVar: 4};
fn1 = newClosure(4, obj);
fn2 = newClosure(5, obj);
fn1(1); // num: 5; anArray: 1,2,3,5; ref.someVar: 4;
fn2(1); // num: 6; anArray: 1,2,3,6; ref.someVar: 4;
obj.someVar++;
fn1(2); // num: 7; anArray: 1,2,3,5,7; ref.someVar: 5;
fn2(2); // num: 8; anArray: 1,2,3,6,8; ref.someVar: 5;
複製程式碼

總結

如果對閉包並不完全明白,那麼最好的辦法就是回過頭研究研究這些案例。我對閉包和堆疊幀等概念的解釋可能在專業上並不完全正規,這都是為了幫助大家更好地理解。一旦這些基礎知識得到掌握,你可以在以後的日子裡去摳那些更專業的細節。

最後幾點

  • 任何時候,你在一個函式體內用了另外一個函式,閉包就產生了。
  • 任何時候,你在一個函式體內用了eval(),閉包就產生了。在eval中的內容可以引用函式裡的區域性變數,你甚至可以在eval內宣告新的區域性變數,比如:eval('var foo = ...')
  • 當你在一個函式體內使用建構函式(new Function(...)),不會產生閉包(這個建構函式不能引用外部函式的區域性變數)。
  • Javascript中的閉包就像外部函式返回後,用來儲存所有區域性變數的儲存副本一樣。
  • 最好可以這樣認為:閉包只是一個函式的入口,函式的區域性變數被新增到這個閉包中。
  • 每次呼叫一個帶有閉包的函式時,都會儲存一組新的區域性變數(假定該函式內包含一個函式宣告,並且返回到外部,或者以某種方式為其保留外部引用)。
  • 兩個函式可能看起來程式碼相同,但是由於“隱藏”的閉包,它們有著完全不同的行為。我並不認為通過Javascript程式碼可以很容易看出一個函式引用是否擁有閉包。
  • 如果你想進行動態修改程式碼(比如:myFunction = Function(myFunction.toString().replace(/Hello/,'Hola'));),如果myFunction是閉包,將行不通(當然,你應該永遠不會想要這樣進行原始碼字串替換,但是……)。
  • 很可能出現這種情況:在函式體內的函式宣告中還有函式宣告,那麼你會發現有不止一個層級上的閉包出現。
  • 我懷疑Javascript中的閉包和那些函式式語言的閉包不同。

閉包的應用(譯者注)

  • 實現封裝,私有化屬性/變數
  • 模組化開發,防止全域性汙染
  • 用作快取
  • 用作公有變數
  • 等等……

閉包的危害(譯者注)

閉包會導致原有作用域鏈不釋放,造成記憶體洩露。

感謝

如果你剛剛學會了閉包(在這篇文章或者其他地方),歡迎提出任何意見或建議,因為你的反饋可能會使這篇文章更加清晰完善,為更多有需要的人帶來方便。我不是Javascript專家也不是閉包專家,歡迎批評指正。

相關文章