JavaScript 閉包
- 2021 年 10 月 1 日
- 筆記
- Closure, javascript, 閉包
一個函數和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函數被引用包圍),這樣的組合就是閉包(closure)。也就是說,閉包讓你可以在一個內層函數中訪問到其外層函數的作用域。在 JavaScript 中,每當創建一個函數,閉包就會在函數創建的同時被創建出來。–MDN
閉包?
每當一個函數被創建時,這個函數就和創建其的詞法環境綁定了。這保證了這個函數始終能訪問創建其的詞法環境里的數據。
例如如下代碼 00:
function 張三(){
let foo = "張三的foo";
let bar = "張三的bar";
function show(){
console.log(foo,bar)
}
show();
}
張三(); // 張三的foo 張三的bar
console.log(foo,bar); // Uncaught ReferenceError: foo is not defined
張三
被調用時,創建了一個詞法環境,其中包含了foo
、bar
和show
等變量,他們僅在該詞法環境和其內層詞法環境中可見,而外層詞法環境不可見。
當張三
被執行完畢,這個詞法環境里的foo
和bar
隨即銷毀,這個詞法環境也不復存在。
而閉包就使得這個詞法環境得以保持,並且外部/其他詞法環境可以訪問其中的數據。
例如如下代碼 01:
let foo = "全局的foo";
let bar = "全局的bar";
function 張三(){
let foo = "張三的foo";
return function show(){ // *
console.log(foo,bar);
}
}
function 李四(){
let foo = "李四的foo";
let bar = "李四的bar";
let show = 張三();
show(); // 張三的foo 全局的bar
}
李四();
let show = 張三();
show(); // 張三的foo 全局的bar
┌───────────────────────┐ ┌───────────────────────┐
│[ 張三 ] │ │[ 李四 ] │
│ │ │ foo: '李四的foo' │
│ foo: '張三的foo' >>>>function show>>>> bar: '李四的bar' │
│ │ │ show: function │
│ │ │ │
└──────────↡────────────┘ └───────────────────────┘
↡
↳>>>>function show>>>> [ global ]
*
處的return function show
將張三的詞法環境「打開」,使得擁有這個返回的函數的詞法環境能夠訪問張三的詞法環境。
無論這個函數被傳遞到哪裡,它都會持有對原始定義作用域的引用,在訪問foo
和bar
時總會先搜索張三的詞法環境,如果沒有則搜索其外部詞法環境直至全局詞法環境,如果在任何地方都找不到這個變量,那麼就會報錯。
當張三
被執行完畢,這個詞法環境也不會立即銷毀(張三自己會被銷毀),因為還有”李四和全局要訪問它”,即李四和全局持有對張三的詞法環境的引用show
。
這個在 其他作用域 中對 原始定義時的作用域 的引用就是閉包。
關於銷毀
上文說道,show
能使定義它的作用域不被銷毀,但不會阻止張三不被銷毀
例如如下代碼 02:
function 張三他爹(){
function 張三(){
let foo = "張三的foo";
return function show(){
console.log(foo);
}
}
return 張三();
}
let show = 張三他爹();
show(); // 張三的foo
張三他爹
執行完後,張三
不復存在,但show
仍能訪問定義它的作用域
或者來點更直接的,殺死張三
,代碼 03:
function 張三(){
let foo = "張三的foo";
return function show(){
console.log(foo);
}
}
let show = 張三();
張三 = null; //張三死了
show(); //張三的foo
張三死了,但他的精神還在
作用域 閉包
顯然,多個作用域持有的閉包指向同一個詞法環境。如果某個作用域中通過閉包修改了指向的詞法環境的某個數據,其它作用域也會感知到。
例如以下代碼 04:
function 張三(){
let size = 10;
return [
function(n){
size += n;
console.log(size);
},
function(n){
size += n;
console.log(size);
}
]
}
[enlarge1,enlarge2] = 張三();
enlarge1(2); // 12
enlarge2(2); // 14
enlarge1
和enlarge2
同時指向了同一個詞法環境,因此兩次enlarge了同一個張三的size
。
代碼 02看起來有些怪異。
請看代碼 05:
function 張三(){
let size = 10;
return function(n){
size += n;
console.log(size);
}
}
enlarge1 = 張三();
enlarge2 = 張三();
enlarge1(2); // 12
enlarge2(2); // 12
看起來正常多了,但是結果不是我們想要的。
?
兩次調用張三
實際上創建了兩個獨立的詞法環境 [張三1, 張三2],所以兩次enlarge了兩個不同的張三的size
。
循環
比較經典的是循環與閉包結合
例如以下代碼 06:
for (var i=1; i<=5; i++) {
setTimeout( function logi() {
console.log( i );
}, i*100 );
}
輸出為5個6。
異步的setTimeout
總是在當前事件(循環及其後面的同步代碼)執行結束後再開始執行。每次向setTimeout
傳遞的function都是對循環內詞法環境的閉包,因此當這些function被調用時,它們訪問循環內詞法環境中的i
,得到的是累加完畢的6。
let
如果使用現代的let
而非var
,結果會完全不同
例如以下代碼 07:
for (let i=1; i<=5; i++) {
setTimeout( function logi() {
console.log( i );
}, i*100 );
}
輸出為1 2 3 4 5。
這個循環實際上有兩個作用域
┌─────────────────────────┐
│[ ()內 ] i │
│ ┌──────────────────┐ │
│ │[ {}內 ] i >>>>function logi>>>>[ global ]
│ └──────────────────┘ │
│ │
└─────────────────────────┘
每次迭代,JavaScript就會用上次迭代結束時i
的值重新聲明並初始化 {}內的 i
驗證
代碼 08:
for(let i=1;i<5;i++){
console.log(i); //Uncaught ReferenceError: Cannot access 'i' before initialization
let i = 1;
}
在ES6中,let聲明會被提升到作用域開頭但不會初始化,這就是暫時性死區。
上述代碼拋出了ReferenceError: Cannot access 'i' before initialization
而不是SyntaxError: Identifier 'i' has already been declared
證明i
並不在{}內作用域定義,而是屬於其外層作用域。
但i
確實存在在{}內作用域中
猜測:由於整個for循環的執行體中並沒有使用let,但是執行中每次都產生了塊級作用域,我猜想是由底層代碼創建並塞給for執行體中。
— //www.cnblogs.com/echolun/p/10584703.html
應用
工廠函數
可以通過一個函數創建多個特定函數
例如代碼 09:
function createAdder(m){
return function(n){
return m+n;
}
}
let addTo5 = createAdder(5);
addTo5(10);
調用createAdder
會產生一個偏函數,實現功能更狹窄但更精確的函數。
如addTo5
返回某數與5的和。
為什麼我們通常會創建一個偏函數?
好處是我們可以創建一個具有可讀性高的名字(double,triple)的獨立函數。我們可以使用它,並且不必每次都提供一個參數,因為參數是被綁定了的。
另一方面,當我們有一個非常通用的函數,並希望有一個通用型更低的該函數的變體時,偏函數會非常有用。
— //zh.javascript.info/bind
模塊/類
例如代碼 0A:
let person = (function createPerson(){
let age = 1;
let name = "Li Hua";
return {
grow(){
age++
},
say(){
console.log(`I'm ${name}, and I'm ${age} years old`)
}
}
})();
person.say() // I'm Li Hua, and I'm 1 years old
person.grow()
person.say() // I'm Li Hua, and I'm 2 years old
createPerson
拋出了兩個方法grow
和say
,使其他作用域可以訪問其內部的數據、方法。
性能
如果不是某些特定任務需要使用閉包,在其它函數中創建函數是不明智的,因為閉包在處理速度和內存消耗方面對腳本性能具有負面影響。
— MDN
使用閉包來實現一些東西不是明智的,例如代碼 0A中每創建一個對象都要把方法賦給這個對象。
改為使用原型,代碼 0B:
let father = {
age: undefined,
name: undefined,
init(name){
this.age = 1;
this.name = name;
},
grow(){
this.age++;
},
say(){
console.log(`I'm ${this.name}, and I'm ${this.age} years old`)
}
}
let p1 = Object.create(father);
p1.init("Li Hua")
let p2 = Object.create(father);
p2.init("Li Ming")
p1.grow()
p1.say() // I'm Li Hua, and I'm 2 years old
p2.say() // I'm Li Ming, and I'm 1 years old
或使用class
來實現。
這樣,方法都存在他們的原型中,免去了每次創建的賦值。
內存消耗
從理論上說,閉包會保持住其 原始定義作用域 的所有數據,因此會導致較大的內存消耗。
現代的JavaScript引擎可能會分析出一片作用域中沒有使用的量,並把他們從環境中刪除。
例如 代碼0C:
function 張三(){
let foo = "張三的foo";
let bar = "張三的bar";
return function show(){
console.log(foo);
}
}
let show = 張三();
show();
當通過show()
訪問張三作用域時,bar是不存在的,因為沒有使用。
測試環境:Edge 95,JavaScript: V8 9.5.91
但這個和引擎的實現有關,實際使用時應當慎重。
總結
事實上,JavaScript中到處都有閉包存在,每當函數被傳遞到當前詞法作用域之外執行,就產生了閉包。
更確切地說,所有的函數都是閉包的,當函數被傳遞到當前詞法作用域之外執行,閉包的效果得以表現。
學會並能夠識別閉包可以減少出錯,寫出更健壯的代碼。
在面試時,前端開發者通常會被問到「什麼是閉包?」,正確的回答應該是閉包的定義,並解釋清楚為什麼 JavaScript 中的所有函數都是閉包的,以及可能的關於詞法環境原理的技術細節。
— //zh.javascript.info/closure