原文地址: Discover the power of closures in JavaScript
原文作者: Cristi Salcescu
譯者: wcflmy
閉包是一個可以訪問外部作用域的內部函式,即使這個外部作用域已經執行結束。
作用域
作用域決定這個變數的生命週期及其可見性。
當我們建立了一個函式或者 {}
塊,就會生成一個新的作用域。需要注意的是,通過 var
建立的變數只有函式作用域,而通過 let
和 const
建立的變數既有函式作用域,也有塊作用域。
巢狀作用域
在 Javascript
中函式裡面可以巢狀函式,如下:
(function autorun(){
let x = 1;
function log(){
console.log(x);
}
log();
})();
複製程式碼
log()
即是一個巢狀在 autorun()
函式裡面的函式。在 log()
函式裡面可以通過外部函式訪問到變數 x
。此時,log()
函式就是一個閉包。
閉包就是內部函式,我們可以通過在一個函式內部或者 {}
塊裡面定義一個函式來建立閉包。
外部函式作用域
內部函式可以訪問外部函式中定義的變數,即使外部函式已經執行完畢。如下:
(function autorun(){
let x = 1;
setTimeout(function log(){
console.log(x);
}, 10000);
})();
複製程式碼
並且,內部函式還可以訪問外部函式中定義的形參,如下:
(function autorun(p){
let x = 1;
setTimeout(function log(){
console.log(x);//1
console.log(p);//10
}, 10000);
})(10);
複製程式碼
外部塊作用域
內部函式可以訪問外部塊中定義的變數,即使外部塊已執行完畢,如下:
{
let x = 1;
setTimeout(function log(){
console.log(x);
}, 10000);
}
複製程式碼
詞法作用域
詞法作用域是指內部函式在定義的時候就決定了其外部作用域。
看如下程式碼:
(function autorun(){
let x = 1;
function log(){
console.log(x);
};
function run(fn){
let x = 100;
fn();
}
run(log);//1
})();
複製程式碼
log()
函式是一個閉包,它在這裡訪問的是 autorun()
函式中的 x
變數,而不是 run
函式中的變數。
閉包的外部作用域是在其定義的時候已決定,而不是執行的時候。
autorun()
的函式作用域即是 log()
函式的詞法作用域。
作用域鏈
每一個作用域都有對其父作用域的引用。當我們使用一個變數的時候,Javascript引擎
會通過變數名在當前作用域查詢,若沒有查詢到,會一直沿著作用域鏈一直向上查詢,直到 global
全域性作用域。
示例如下:
let x0 = 0;
(function autorun1(){
let x1 = 1;
(function autorun2(){
let x2 = 2;
(function autorun3(){
let x3 = 3;
console.log(x0 + " " + x1 + " " + x2 + " " + x3);//0 1 2 3
})();
})();
})();
複製程式碼
我們可以看到,autorun3()
這個內部函式可以訪問其自身區域性變數 x3
,也可以訪問外部作用域中的 x1
和 x2
變數,以及全域性作用域中的 x0
變數。即:閉包可以訪問其外部(父)作用域中的定義的所有變數。
外部作用域執行完畢後
當外部作用域執行完畢後,內部函式還存活(仍在其他地方被引用)時,閉包才真正發揮其作用。譬如以下幾種情況:
- 在非同步任務例如
timer
定時器,事件處理,Ajax
請求中被作為回撥 - 被外部函式作為返回結果返回,或者返回結果物件中引用該內部函式
考慮如下的幾個示例:
Timer
(function autorun(){
let x = 1;
setTimeout(function log(){
console.log(x);
}, 10000);
})();
複製程式碼
變數 x
將一直存活著直到定時器的回撥執行或者 clearTimeout()
被呼叫。
如果這裡使用的是 setInterval()
,那麼變數 x
將一直存活到 clearInterval()
被呼叫。
譯者注:原文中說變數 x
一直存活到 setTimeout()
或者 setInterval()
被呼叫是錯誤的。
Event
(function autorun(){
let x = 1;
$("#btn").on("click", function log(){
console.log(x);
});
})();
複製程式碼
當變數 x
在事件處理函式中被使用時,它將一直存活直到該事件處理函式被移除。
Ajax
(function autorun(){
let x = 1;
fetch("http://").then(function log(){
console.log(x);
});
})();
複製程式碼
變數 x
將一直存活到接收到後端返回結果,回撥函式被執行。
在已上幾個示例中,我們可以看到,log()
函式在父函式執行完畢後還一直存活著,log()
函式就是一個閉包。
除了 timer
定時器,事件處理,Ajax
請求等比較常見的非同步任務,還有其他的一些非同步 API
比如 HTML5 Geolocation
,WebSockets
, requestAnimationFrame()
也將使用到閉包的這一特性。
變數的生命週期取決於閉包的生命週期。被閉包引用的外部作用域中的變數將一直存活直到閉包函式被銷燬。如果一個變數被多個閉包所引用,那麼直到所有的閉包被垃圾回收後,該變數才會被銷燬。
閉包與迴圈
閉包只儲存外部變數的引用,而不會拷貝這些外部變數的值。 檢視如下示例:
function initEvents(){
for(var i=1; i<=3; i++){
$("#btn" + i).click(function showNumber(){
alert(i);//4
});
}
}
initEvents();
複製程式碼
在這個示例中,我們建立了3個閉包,皆引用了同一個變數 i
,且這三個閉包都是事件處理函式。由於變數 i
隨著迴圈自增,因此最終輸出的都是同樣的值。
修復這個問題最簡單的方法是在 for
語句塊中使用 let
變數宣告,這將在每次迴圈中為 for
語句塊建立一個新的區域性變數。如下:
function initEvents(){
for(let i=1; i<=3; i++){
$("#btn" + i).click(function showNumber(){
alert(i);//1 2 3
});
}
}
initEvents();
複製程式碼
但是,如果變數宣告在 for
語句塊之外的話,即使用了 let
變數宣告,所有的閉包還是會引用同一個變數,最終輸出的還是同一個值。
閉包與封裝性
封裝性意味著資訊隱藏。
函式與私有狀態
通過閉包,我們可以建立擁有私有狀態的函式,閉包使得狀態被封裝起來。
工廠模式與私有原型物件
我們先來看一個通過原型建立物件的常規方式,如下:
let todoPrototype = {
toString : function() {
return this.id + " " + this.userName + ": " + this.title;
}
}
function Todo(todo){
let newTodo = Object.create(todoPrototype);
Object.assign(newTodo, todo);
return newTodo;
}
複製程式碼
在這個例子中,todoPrototype
原型物件是一個全域性物件。
我們可以通過閉包,只用建立原型物件一次,也能夠被所有 Todo
函式呼叫所公用,並且保證其私有性。示例如下:
let Todo = (function createTodoFactory(){
let todoPrototype = {
toString : function() {
return this.id + " " + this.userName + ": " + this.title;
}
}
return function(todo){
let newTodo = Object.create(todoPrototype);
Object.assign(newTodo, todo);
return newTodo;
}
})();
let todo = Todo({id : 1, title: "This is a title", userName: "Cristi", completed: false });
複製程式碼
這裡,Todo()
就是一個擁有私有狀態的函式。
工廠模式與私有建構函式
檢視如下程式碼:
let Todo = (function createTodoFactory(){
function Todo(spec){
Object.assign(this, spec);
}
return function(spec){
let todo = new Todo(spec);
return Object.freeze(todo);
}
})();
複製程式碼
這裡,Todo()
工廠函式就是一個閉包。通過它,不管是否使用 new
,我們都可以建立不可變物件,原型物件也只用建立一次,並且它是私有的。
let todo = Todo({title : "A description"});
todo.title = "Another description";
// Cannot assign to read only property 'title' of object
todo.toString = function() {};
//Cannot assign to read only property 'toString' of object
複製程式碼
而且,在記憶體快照中,我們可以通過建構函式名來識別這些示例物件。
翻譯功能與私有map
通過閉包,我們可以建立一個 map
,在所有翻譯呼叫中被使用,且是私有的。
示例如下:
let translate = (function(){
let translations = {};
translations["yes"] = "oui";
translations["no"] = "non";
return function(key){
return translations[key];
}
})();
translate("yes"); //oui
複製程式碼
自增生成器函式
通過閉包,我們可以建立自增生成器函式。同樣,內部狀態是私有的。示例如下:
function createAGenerate(count, increment) {
return function(){
count += increment;
return count;
}
}
let generateNextNumber = createAGenerate(0, 1);
console.log(generateNextNumber()); //1
console.log(generateNextNumber()); //2
console.log(generateNextNumber()); //3
let generateMultipleOfTen = createAGenerate(0, 10);
console.log(generateMultipleOfTen()); //10
console.log(generateMultipleOfTen()); //20
console.log(generateMultipleOfTen()); //30
複製程式碼
譯者注:原文中依次輸出0,1,2,0,10,20是有誤的,感謝@Round的指正
物件與私有狀態
以上示例中,我們可以建立一個擁有私有狀態的函式。同時,我們也可以建立多個擁有同一私有狀態的函式。基於此,我們還可以建立一個擁有私有狀態的物件。
示例如下:
function TodoStore(){
let todos = [];
function add(todo){
todos.push(todo);
}
function get(){
return todos.filter(isPriorityTodo).map(toTodoViewModel);
}
function isPriorityTodo(todo){
return task.type === "RE" && !task.completed;
}
function toTodoViewModel(todo) {
return { id : todo.id, title : todo.title };
}
return Object.freeze({
add,
get
});
}
複製程式碼
TodoStore()
函式返回了一個擁有私有狀態的物件。在外部,我們無法訪問私有的 todos
變數,並且 add
和 get
這兩個閉包擁有相同的私有狀態。在這裡,TodoStore()
是一個工廠函式。
閉包 vs 純函式
閉包就是那些引用了外部作用域中變數的函式。
為了更好的理解,我們將內部函式拆成閉包和純函式兩個方面:
- 閉包是那些引用了外部作用域中變數的函式。
- 純函式是那些沒有引用外部作用域中變數的函式,它們通常返回一個值並且沒有副作用。
在上述例子中,add()
和 get()
函式是閉包,而 isPriorityTodo()
和 toTodoViewModel()
則是純函式。
閉包在函數語言程式設計中的應用
閉包在函數語言程式設計中也應用廣泛。譬如,underscore
原始碼中 函式相關小節 中的所有函式都利用了閉包這一特性。
A function decorator is a higher-order function that takes one function as an argument and returns another function, and the returned function is a variation of the argument function — Javascript Allongé
裝飾器函式也使用了閉包的特性。
我們來看如下 not
這個簡單的裝飾器函式:
function not(fn){
return function decorator(...args){
return !fn.apply(this, args);
}
}
複製程式碼
decorator()
函式引用了外部作用域的fn變數,因此它是一個閉包。
如果你想知道更多關於裝飾器相關的知識,可以檢視這篇文章。
垃圾回收
在 Javascript
中,區域性變數會隨著函式的執行完畢而被銷燬,除非還有指向他們的引用。當閉包本身也被垃圾回收之後,這些閉包中的私有狀態隨後也會被垃圾回收。通常我們可以通過切斷閉包的引用來達到這一目的。
在這個例子中,我們首先建立了一個 add()
閉包。
let add = (function createAddClosure(){
let arr = [];
return function(obj){
arr.push(obj);
}
})();
複製程式碼
隨後,我們又定義了兩個函式:
addALotOfObjects()
往閉包變數arr
中加入物件。clearAllObjects()
將閉包函式置為null
。
並且兩個函式皆作為事件處理函式:
function addALotOfObjects(){
for(let i=1; i<=10000;i++) {
add(new Todo(i));
}
}
function clearAllObjects(){
if(add){
add = null;
}
}
$("#add").click(addALotOfObjects);
$("#clear").click(clearAllObjects);
複製程式碼
當我點選 Add
按鈕時,將往 閉包變數 arr
中加入10000個 todo
物件,記憶體快照如下:
當我點選 Clear
按鈕時,我們將閉包引用置為 null
。隨後,閉包變數 arr
將被垃圾回收,記憶體快照如下:
避免全域性變數
在 Javascript
中,我們很容易建立出全域性變數。任何定義在函式和 {}
塊之外的變數都是全域性的,定義在全域性作用域中的函式也是全域性的。
這裡以定義建立不同物件的工廠函式為例。為了避免將所有的工廠函式都放在全域性作用域下,最簡單的方法就是將他們掛在 app
全域性變數下。
示例如下:
let app = Loader();
app.factory(function DataService(args){ return {}});
app.factory(function Helper(args){ return {}});
app.factory(function Mapper(args){ return {}});
app.factory(function Model(args){});
複製程式碼
app.factory()
方法還可以將不同的工廠函式歸類到不同的模組中。下面這個示例就是將 Timer
工廠函式歸類到 tools
模組下。
app.factory("tools")(function Timer(args){ return {}});
複製程式碼
我們可以在 app
物件上暴露一個 start
方法來作為應用的入口點,通過 回撥函式中 factories
引數來訪問這些工廠函式。這裡 start()
函式只能被呼叫一次,如下:
app.start(function startApplication(factories){
let helper = factories.Helper();
let dataService = factories.DataService();
let model = factories.Model({
dataService : dataService,
helper : helper,
timer : factories.tools.Timer()
});
});
複製程式碼
A Composition Root is a (preferably) unique location in an application where modules are composed together.
Mark Seemann
loader
物件
讓我們來將 app
完善為一個 loader
物件,示例如下:
function Loader(){
let modules = Object.create(null);
let started = false;
function getNamespaceModule(modulesText){
let parent = modules;
if(modulesText){
let parts = modulesText.split('.');
for(let i=0; i<parts.length; i++){
let part = parts[i];
if (typeof parent[part] === "undefined") {
parent[part] = Object.create(null);
}
parent = parent[part];
}
}
return parent;
}
function addFunction(namespace, fn){
if(typeof(fn) !== "function") {
throw "Only functions can be added";
}
let module = getNamespaceModule(namespace);
let fnName = fn.name;
module[fnName] = fn;
}
function addNamespace(namespace){
return function(fn){
addFunction(namespace, fn)
}
}
function factory(){
if(typeof(arguments[0]) === "string"){
return addNamespace(arguments[0]);
} else {
return addFunction(null, arguments[0]);
}
}
function start(startApplication){
if(started){
throw "App can be started only once";
}
startApplication(Object.freeze(modules));
started = true;
}
return Object.freeze({
factory,
start
});
};
let app = Loader();
複製程式碼
factory()
方法用於新增新的工廠函式到內部變數 modules
中。
start()
方法則會呼叫回撥函式,在回撥函式中訪問內部變數。
通過 factory()
定義工廠函式,將 start()
作為整個應用中呼叫各種工廠函式生成不同物件的唯一入口點,這是如此簡潔優雅的方式。
在這裡,factory
和 start
都是閉包。
總結
閉包是一個可以訪問外部作用域中變數的內部函式。
這些被引用的變數直到閉包被銷燬時才會被銷燬。
閉包使得 timer
定時器,事件處理,AJAX
請求等非同步任務更加容易。
可以通過閉包來達到封裝性。
最後,想獲得更多關於 Javascript
函式相關知識,可以檢視以下文章:
Discover Functional Programming in JavaScript with this thorough introduction
Discover the power of first class functions
How point-free composition will make you a better functional programmer
Here are a few function decorators you can write from scratch