幾年前,我還是一名高中生時,我的一個朋友向我講述了閉包的概念。雖然我當時一點也不明白他想表達的內容,但他在向我講述時卻表現得非常高大上。對於當時的我來說,閉包看來是一個深不可測的魔法。即使 Google 後也不能解除我的疑惑。而所有我能查閱的科技文章,都爲高中生所難以理解。
現在的我回想起高中編程時光,我都會不禁一笑。這是一篇試圖用一些簡單項目去解釋閉包的文章,這會幫助我的學弟學妹們能輕易地駕馭強大的閉包。
計數事件
我們將從一個簡單的問題開始。如果將閉包引入到該程序中,將能輕易解決這個問題。
我們爲計數事件創建一個機制。該機制將有助於我們跟蹤代碼的執行,甚至去調試一些問題。例如,我會以下面的方式調用計數器:
increment(); // Number of events: 1
increment(); // Number of events: 2
increment(); // Number of events: 3
正如你所看到的上述案例,我們希望代碼會在我們每次執行 increment() 函數時,會顯示一條信息“Number of events: x”。下面以簡單的方式實現該函數:
var counter = 0;
function increment() {
counter = counter + 1;
console.log("Number of events: " + counter);
}
多個計數器
上述代碼非常簡單明確。然而,當我們引入第二個計數器時,就會很快遇到問題。當然,我們能實現兩個單獨的計數器機制,如下面的代碼,但很明顯有需要改進的地方:
var counter1 = 0;
function incrementCounter1() {
counter1 = counter1 + 1;
console.log("Number of events: " + counter1);
}
var counter2 = 0;
function incrementCounter2() {
counter2 = counter2 + 1;
console.log("Number of events: " + counter2);
}
incrementCounter1(); // Number of events: 1
incrementCounter2(); // Number of events: 1
incrementCounter1(); // Number of events: 2
上述代碼出現了不必要的重複。明顯地,這種解決辦法並不適用於超過二或三個記數器的情況。我們需要想出更好的解決方案。
引入我們第一個閉包
在保持與上述例子相似的情況下,我們以某種方式引入新的計數器,該計數器捆綁了一個能自增的函數,而且沒有大量重複的代碼。下面嘗試使用閉包:
function createCounter() {
var counter = 0;
function increment() {
counter = counter + 1;
console.log("Number of events: " + counter);
}
return increment;
}
讓我們看看這是如何工作的。我們將創建兩個計數器,並讓它們跟蹤兩個獨立的事件:
var counter1 = createCounter();
var counter2 = createCounter();
counter1(); // Number of events: 1
counter1(); // Number of events: 2
counter2(); // Number of events: 1
counter1(); // Number of events: 3
啊,這看起來有點複雜…然而,這實際上是非常簡單的。我們只需將實現邏輯分成幾個易於理解的塊。下面就看看我們實現了什麼:
首先,創建了一個名爲 counter 的局部變量。
然後,創建了一個名爲 increment 的局部函數,它能增加 counter 變量值。如果你從未接觸過將函數作爲數據來處理的函數式編程,這也許對你非常陌生。然而,這是非常常見的,而且只需要一些練習就能適應這一概念。
你應該注意到這一點,createCounter() 的實現與我們原先的計數器實現幾乎一致。唯一不同的是它被包裝或封裝在一個函數體內。因此,這些構造器都被稱爲閉包。
現在是棘手的部分:
在 createCounter() 的最後一步返回了局部函數 increment。請注意,這並不是返回調用函數的運行結果,而是函數本身。
這就意味着,當我們在這個代碼段下面創建新的計數器時,實際上是生成新函數。
// fancyNewCounter is a function in this scope
// fancyNewCounter 是當前作用域的一個函數
var fancyNewCounter = createCounter();
這就是閉包生命週期的力量所在。每個生成的函數,都會保持在 createCounter() 所創建的 counter 變量的引用。在某種意義上,被返回的函數記住了它所被創建時的環境。
在這裏需要提醒大家注意的是,內部變量 counter 都是獨立存在於每個作用域!例如,如果我們創建兩個計數器,那麼它們都會在閉包體內會分配一個新的 counter 變量。我們觀察以下代碼:
每個計數器都會從 1 算起:
var counter1 = createCounter();
counter1(); // Number of events: 1
counter1(); // Number of events: 2
var counter2 = createCounter();
counter2(); // Number of events: 1
第二個計數器並不會干擾第一個計數器的值:
counter1(); // Number of events: 3
爲我們的計數器命名
信息“Number of events: x” 是沒問題的,但如果能描述每個計數事件的類型,那麼這將會更好。如以下例子,我們爲計數器添加名字:
var catCounter = createCounter("cats");
var dogCounter = createCounter("dogs");
catCounter(); // Number of cats: 1
catCounter(); // Number of cats: 2
dogCounter(); // Number of dogs: 1
我們僅需通過爲閉包傳遞參數就能達到這種目的。
function createCounter(counterName) {
var counter = 0;
function increment() {
counter = counter + 1;
console.log("Number of " + counterName + ": " + counter);
}
return increment;
}
非常棒!請注意上述 createCounter() 函數的一個有趣行爲。返回函數不僅記住了局部變量 counter,而且記住了傳遞進來的參數。
改善公用接口
我所說的公用接口是指,我們如何使用計數器。這並不單純指,當被創建的計數器被調用時會增加值。
var dogCounter = createCounter("dogs");
dogCounter.increment(); // Number of dogs: 1
讓我們創建這樣的一個實現:
function createCounter(counterName) {
var counter = 0;
function increment() {
counter = counter + 1;
console.log("Number of " + counterName + ": " + counter);
};
return { increment : increment };
}
在上述代碼段,我們簡單地返回一個對象,該對象包含了該閉包的所有功能。在某種意義下,我們能定義閉包能返回的一系列信息。
增加一個減量
現在,我們能非常簡單地爲我們的計數器引入減量(decrement)。
function createCounter(counterName) {
var counter = 0;
function increment() {
counter = counter + 1;
console.log("Number of " + counterName + ": " + counter);
};
function decrement() {
counter = counter - 1;
console.log("Number of " + counterName + ": " + counter);
};
return {
increment : increment,
decrement : decrement
};
}
var dogsCounter = createCounter("dogs");
dogsCounter.increment(); // Number of dogs: 1
dogsCounter.increment(); // Number of dogs: 2
dogsCounter.decrement(); // Number of dogs: 1
隱藏計數器行爲
上述代碼有兩處冗餘的代碼行。沒錯,就是 console.log。如果能創建一個專門用於顯示計數器值的函數將會更好。讓我們調用 display 函數。
function createCounter(counterName) {
var counter = 0;
function display() {
console.log("Number of " + counterName + ": " + counter);
}
function increment() {
counter = counter + 1;
display();
};
function decrement() {
counter = counter - 1;
display();
};
return {
increment : increment,
decrement : decrement
};
}
var dogsCounter = createCounter("dogs");
dogsCounter.increment(); // Number of dogs: 1
dogsCounter.increment(); // Number of dogs: 2
dogsCounter.decrement(); // Number of dogs: 1
increment() 和 decrement() 函數看起來非常相似,然而這是大相徑庭的。我們沒有在結果對象返回計數值!這意味着以下代碼將會調用失敗:
var dogsCounter = createCounter("dogs");
dogsCounter.display(); // ERROR !!!
我們讓 display() 函數對外部來說是不可見的。它僅在 createCounter() 內可用。
抽象數據類型
正如你所見,我們通過閉包能非常簡單地引入抽象數據類型。例如,讓我們通過閉包實現一個 堆棧。
function createStack() {
var elements = [];
return {
push: function(el) { elements.unshift(el); },
pop: function() { return elements.shift(); }
};
}
var stack = createStack();
stack.push(3);
stack.push(4);
stack.pop(); // 4
注意:在 JavaScript 中,閉包並不是堆棧數據類型的最佳實現方式。用 Prototype 實現會對內存更友好(譯者注:在當前對象實例找不會相應屬性或方法時,會到相應實例共同引用的 Prototype 屬性尋找相應屬性或方法(如果在當前Prototype屬性找不到時,會沿着當前原型鏈向上查找),而Prototype 上的屬性或方法是公用的,而不像實例的屬性或方法那樣,各自單獨創建屬性或方法,從而節省更多的內存)。
閉包與面向對象編程
如果你具有 面向對象編程 的經歷,那麼你應該會注意到上述構造器看來非常像類、對象、實例值和私有/公有方法。
閉包與類相似,都會將一些能操作內部數據的函數聯繫在一起。因此,你能在任何地方像使用對象一樣使用閉包。
結語
閉包是編程語言一個很棒的屬性。當我們想在 JavaScript 創建“真正的”隱藏域,或者需要創建簡單的構造器時,閉包這個屬性是非常好用的。不過對於一般的類來說,閉包可能還是有點太重了。