JavaScript 閉包入門(譯文)

Damonare發表於2017-01-21

前言

總括 :這篇文章使用有效的javascript程式碼向程式設計師們解釋了閉包,大牛和功能型程式設計師請自行忽略。

譯者 :文章寫在2006年,可直到翻譯的21小時之前作者還在完善這篇文章,在Stackoverflow的How do JavaScript closures work?這個問題裡更是得到了4000+的贊同,文章內容質量自然不必多說。

本文屬於譯文

正文

閉包並不是魔法

這篇文章使用有效的javascript程式碼向程式設計師們解釋了閉包,大牛和功能型程式設計師請自行忽略。

實際上一旦你對閉包的核心概念心領神會了,閉包就不難理解了,但如果你想通過讀那些學術性文章或是學院派的論文來理解閉包那基本是不可能的。

本文主要是面向那些有主流程式語言開發經驗或是能看懂下面這段程式碼的程式設計師:

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

一個閉包小案例

兩種方式概括:

  • 閉包是javascript支援頭等函式的一種方式,它是一個能夠引用其內部作用域變數(在本作用域第一次宣告的變數)的表示式,這個表示式可以賦值給某個變數,可以作為引數傳遞給函式,也可以作為一個函式返回值返回。

或是

  • 閉包是函式開始執行的時候被分配的一個棧幀,在函式執行結束返回後仍不會被釋放(就好像一個棧幀被分配在堆裡而不是棧裡!)

下面這段程式碼返回了一個指向這個函式的引用:

function sayHello2(name) {
  var text = 'Hello ' + name; // 區域性變數text
  var say = function() { console.log(text); }
  return say;
}
var say2 = sayHello2('Bob');
say2(); // 列印日誌: "Hello Bob"
複製程式碼

絕大部分Javascript程式設計師能夠理解上面程式碼中的一個函式引用是如何返回賦值給變數say2的,如果你不理解,那麼你需要理解之後再來學習閉包。C語言程式設計師會認為這個函式返回一個指向某函式的指標,變數saysay2都是指向某個函式的指標。

Javascript的函式引用和C語言指標相比還有一個關鍵性的不同之處,在Javascript中,一個引用函式的變數可以看做是有兩個指標,一個是指向函式的指標,一個是指向閉包的隱藏指標。

上面程式碼中就有一個閉包,為什麼呢?因為匿名函式function() { console.log(text); }是在另一個函式(在本例中就是sayHello2()函式)宣告的。在Javascript中,如果你在另一個函式中使用了function關鍵字,那麼你就建立了一個閉包。

在C語言和大多數常用程式語言中,當一個函式返回後,函式內宣告的區域性變數就不能再被訪問了,因為該函式對應的棧幀已經被銷燬了。

在Javscript中,如果你在一個函式中宣告瞭另一個函式,那麼在你呼叫這個函式返回后里面的區域性變數仍然是可以訪問的。這個已經在上面的程式碼中演示過了,即我們在函式sayHello()返回後仍然可以呼叫函式say2()注意:我們在程式碼中引用的變數text是我們在函式sayHello2()中宣告的區域性變數。

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

觀察say2.toString()的輸出,我們可以看到確實引用了text變數。匿名函式之所以可以引用包含'Hello Bob'text變數就是因為sayhello2()的區域性變數被儲存在了閉包中。

神奇的是,在JavaScript中,函式引用還有一個對於它所建立的閉包的祕密引用,類似於事件委託是一個方法指標加上對於某個物件的祕密引用。

更多例子

出於某種不得而知的原因,當你去閱讀一些關於閉包的文章的時候,閉包看起來真的是難以理解的。但如果你看到一些你能夠去操作的閉包小案例(這花費了我一段時間),閉包就容易理解了。推薦好好推敲下這幾個小案例直到你徹底理解了它們到底是如何工作的。如果你沒完全弄明白閉包是如何工作的就去盲目使用閉包,會搞出很多神奇的bug的!

例3

區域性變數雖然沒有被複制,但可以通過被引用而被保留下來。這就好像外部函式退出後,但棧幀依舊儲存在記憶體中一樣。

function say667() {
  // 區域性變數num最後會儲存在閉包中
  var num = 42;
  var say = function() { console.log(num); }
  num++;
  return say;
}
var sayNumber = say667();
sayNumber(); // 輸出 43
複製程式碼

例4

下面三個全域性函式對同一個閉包有一個共同的引用,因為他們都是在呼叫函式setupSomeGlobals()時宣告的。

var gLogNumber, gIncreaseNumber, gSetNumber;
function setupSomeGlobals() {
  // 區域性變數num最後會儲存在閉包中
  var num = 42;
  // 將一些對於函式的引用儲存為全域性變數
  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()時,一個新的閉包(棧幀)就被建立了。**舊變數gLogNumbergIncreaseNumbergSetNumber 被有新閉包的函式覆蓋(在JavaScript中,如果你在一個函式中宣告瞭一個新的函式,那麼當外部函式被呼叫時,內部函式會被重新建立)。

例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]);
    // 使用j是為了防止搞混---可以使用i
    for (var j = 0; j < fnlist.length; j++) {
        fnlist[j]();
    }
}
 testList() //輸出 "item2 undefined" 3 次
複製程式碼

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

例6

這個例子表明了閉包會儲存函式退出之前內部定義的所有的區域性變數。注意:變數alice是在匿名函式之前建立的。 匿名函式先被宣告,然後當它被呼叫的時候之所以能夠訪問alice是因為他們在同一個作用域內(JavaScript做了變數提升),sayAlice()()直接呼叫了從sayAlice()中返回的函式引用——這個和前面的完全一樣,只是少了臨時的變數【譯者注:儲存sayAlice()返回的函式引用的變數】

function sayAlice() {
    var say = function() { console.log(alice); }
    // 區域性變數最後儲存在閉包中
    var alice = 'Hello Alice';
    return say;
}
sayAlice()();// 輸出"Hello Alice"
複製程式碼

技巧:需要注意變數say也是在閉包內部,也能被在sayAlice()內部宣告的其它函式訪問,或者也可以在函式內部遞迴訪問它。

例7

最後一個例子說明了每次呼叫函式都會為區域性變數建立一個閉包。實際上每次函式宣告並不會建立一個單獨的閉包,但每次呼叫函式都會建立一個獨立的閉包。

function newClosure(someNum, someRef) {
    // 區域性變數最終儲存在閉包中
    var num = someNum;
    var anArray = [1,2,3];
    var ref = someRef;
    return function(x) {
        num += x;
        anArray.push(num);
        console.log('num: ' + num +
            '\nanArray ' + anArray.toString() +
            '\nref.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;
複製程式碼

總結

如果任何不太明白的地方最好的方式就是把玩這幾個例子,去機械地閱讀一些文章遠比去做這些例項難得多。我關於閉包的說明、棧框體(stack-frame)的說明等等,嚴格理論上講並不是完全正確的——它們只是為了理解而簡化處理過的。當基礎的概念心領神會之後,就可以輕鬆地理解這些細節了。

最終總結

  • 每當你在另一個函式裡使用了關鍵字function,一個閉包就被建立了
  • 每當你在一個函式內部使用了eval(),一個閉包就被建立了。在eval內部你可以引用外部函式定義的區域性變數,同樣的,在eval內部也可以通過eval('var foo = …')來建立新的區域性變數。
  • 當你在一個函式內部使用new function(...)(即建構函式)時,它不會建立閉包(新函式不能引用外部函式的區域性變數)。
  • JavaScript中的閉包,就像一個副本,將某函式在退出時候的所有區域性變數複製儲存其中。
  • 也許最好的理解是閉包總是在進入某個函式的時候被建立,而區域性變數是被加入到這個閉包中。
  • 閉包函式每次被呼叫的時候都會建立一組新的區域性變數儲存。(前提是這個函式包含一個內部的函式宣告,並且這個函式的引用被返回或者用某種方法被儲存到一個外部的引用中)
  • 兩個函式或許從原始碼文字上看起來一樣,但因為隱藏閉包的存在會讓兩個函式具有不同的行為。我認為Javascript程式碼實際上並不能找出一個函式引用是否有閉包。
  • 如果你正嘗試做一些動態原始碼的修改(例如:myFunction = Function(myFunction.toString().replace(/Hello/,'Hola'));),如果myFunction是一個閉包的話,那麼這並不會生效(當然,你甚至可能從來都沒有在執行的時候考慮過修改原始碼字串,但是。。。)。
  • 在函式內部的函式的內部宣告函式是可以的——可以獲得不止一個層級的閉包。
  • 通常我認為閉包是一個同時包含函式和被捕捉的變數的術語,但是請注意我並沒有在本文中使用這個定義。
  • 我覺得JavaScript中的閉包跟其它函數語言程式設計語言中的閉包是有不同之處的。

感謝

如果你正好在學習閉包(在這裡或是其他地方),期待您對本文的任何反饋,您的任何建議都可能會使本文更加清晰易懂。請聯絡jztan1996@gmail.com 【譯者注:這是譯者的郵箱,歡迎交流學習】

後記

這是譯者翻譯的第一篇文章,收穫良多,感覺上並不比自己寫一篇文章省事,相反熟悉內容瞭解程式碼的同時還得去揣摩作者表達的意圖,難度的確要比自己單獨寫一篇高。能力有限,水平一般,有翻譯不到位的地方,歡迎批評指正。感謝!

https://user-gold-cdn.xitu.io/2020/2/3/1700919fb9a285de?w=450&h=359&f=png&s=66765

相關文章