提摩西全身濕透地來到圖書館,雨水順著他的外套滴落。他一直在倫敦的街頭奔跑,急切地想和瑪格麗特談談一些讓他對函數運作原理徹底困惑的事情。
“這不可能,”他一邊甩掉頭髮上的水一邊說,“我寫的程式碼完全違背了我所學的關於內存和作用域的所有知識。”
瑪格麗特抬起頭,放下茶杯。 “帶我去看看。”
提摩西拿出筆記型電腦,輸入:
function createCounter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
「我不明白,」蒂莫西說。 “ createCounter執行完畢後,它的局部變數應該會被垃圾回收。count 變數應該已經不存在了。但是返回count函數仍然知道它的存在。它正在存取一個不應該存在的變數。”
瑪格麗特笑了。 「你發現了閉包。你的困惑是對的——它看起來好像違反了程式語言的執行規則。但在 JavaScript 中,這不是 bug,而是一個特性。”
瑪格麗特拿出筆記本。 「在解釋發生了什麼之前,我們需要了解詞法作用域。你們在第一章學過作用域——變數存在的位置。但還有更深層的東西。”
她寫道:
function outer() {
let outerVar = "I'm in outer";
function inner() {
console.log(outerVar); // Can I see this?
}
inner();
}
outer(); // "I'm in outer"
「當inner查找outerVar時,它會在哪裡找到?」瑪格麗特問。
“它查看變數的聲明位置,”蒂莫西回答說,“在outer函數中。”
“沒錯。inner inner在哪裡被呼叫並不重要,重要的是它在inner被編寫。這就是詞法作用域:函數會在定義變數的作用域內查找變數,而不是在執行變數的作用域內查找。”
蒂莫西點了點頭。這和他在第一章學到的內容相符。
“那麼,”瑪格麗特繼續說道,“如果我們從outer返回inner會發生什麼?”
function outer() {
let outerVar = "I'm in outer";
function inner() {
console.log(outerVar);
}
return inner; // Return the function itself
}
const myFunction = outer(); // outer() finishes here
myFunction(); // "I'm in outer" - but how?!
“當outer函數執行完畢後,它的局部變數應該消失,”蒂莫西緩緩說道,“但是inner還在存取outerVar 。這個變數怎麼還活著?”
「因為 JavaScript 不會讓它被銷毀,」瑪格麗特說。 “當你返回inner ,JavaScript 會說:’這個函數引用了 `outerVar 。我現在還不能回收這個變數,因為這個函數以後可能會被呼叫,並且需要用到這個變數。’所以 JavaScript 會把outerVar保留在內存中,專門供inner使用。”
“所以這個變數仍然存在?”
“變數會一直存在。函數會記住它的起始位置。這種組合——一個函數加上它在其作用域內需要的變數——被稱為閉包。”
瑪格麗特向後靠去。 “想像一下,每個函數在建立時都會得到一個背包。在定義函數時,JavaScript 會環顧四周,然後說:‘這個函數需要哪些變數?’然後它就把這些變數裝進背包裡。”
function createGreeter(greeting) {
// createGreeter's scope
return function(name) {
// This inner function needs 'greeting' from createGreeter's scope
console.log(greeting + ", " + name);
};
}
const sayHello = createGreeter("Hello");
const sayHi = createGreeter("Hi");
sayHello("Alice"); // "Hello, Alice"
sayHi("Bob"); // "Hi, Bob"
「當你定義內部函數時,它會四處查找。它發現了greeting 。它將greeting打包到它的背包裡。之後,無論在哪裡呼叫該函數,它都會攜帶greeting 。”
蒂莫西指著程式碼說:“所以sayHello和sayHi背包不一樣? greeting語也不一樣?”
「沒錯。每次createGreeter執行時,都會建立一個新的作用域,並建立一個新的greeting變數。返回的函數會將這個特定的greeting加入到它的「背包」中。所以sayHello會記住 'Hello',而sayHi會記住 'Hi'。它們是完全獨立的。」
“這就是結局了,”蒂莫西恍然大悟地說,“功能加上它的背包。”
“恰恰。”
提摩西迅速打字:
function createAccount() {
let balance = 100;
return {
deposit: function(amount) {
balance += amount;
console.log("Balance: " + balance);
},
withdraw: function(amount) {
balance -= amount;
console.log("Balance: " + balance);
},
getBalance: function() {
return balance;
}
};
}
const account = createAccount();
account.deposit(50); // Balance: 150
account.withdraw(20); // Balance: 130
account.getBalance(); // 130
“你看,”蒂莫西說,“我返回一個包含三個方法的物件。這三個方法都是關於balance閉包。但它們看到的balance值都是一樣的,對吧?”
“是的。而且至關重要的是,這是一份真實的參考資料,而不是一份副本。”
瑪格麗特拿起鍵盤,輸入:
const account = createAccount();
account.deposit(50); // Balance: 150
account.withdraw(20); // Balance: 130
account.deposit(100); // Balance: 230
// All three methods access the SAME variable
// If one changes it, all others see the change
「想想這意味著什麼,」瑪格麗特說。 「這三個函數都在同一個『背包』裡。它們都持有對同一個記憶體位置的引用,而這個記憶體位置正是balance所在的位置。這不是100的三個獨立副本,而是一個所有三個函數都可以讀取和修改的共享變數。”
蒂莫西突然意識到了一件事。 “而且,外人是無法直接感知balance ,對吧?”
他嘗試了:
console.log(account.balance); // undefined
account.balance = 9999; // This creates a new property, doesn't change the closure variable
console.log(account.getBalance()); // Still 230
“沒錯,”瑪格麗特說,“ balance變數完全隱藏了。唯一能與之互動的方式就是透過你提到的三種方法。這才是真正的資料隱私。”
蒂莫西向後靠了靠。 「在Python中,我們會使用像_balance這樣的前導下劃線,然後祈禱人們不會去修改它。但在這裡,實際上沒有人能去修改它。”
“沒錯。這非常強大。你可以建立所謂的模組化模式。”
瑪格麗特寫道:
const calculator = (function() {
let lastResult = 0;
return {
add: function(a, b) {
lastResult = a + b;
return lastResult;
},
subtract: function(a, b) {
lastResult = a - b;
return lastResult;
},
getLastResult: function() {
return lastResult;
}
};
})();
calculator.add(10, 5); // 15
calculator.subtract(20, 8); // 12
calculator.getLastResult(); // 12
// lastResult is private—no one can access or modify it except through these methods
「這是一個模組,」瑪格麗特解釋說。 “你使用立即執行函數表達式——函數會立即執行——來建立一個作用域。在作用域內,你定義像lastResult這樣的私有變數。然後,你返回一個物件,該物件包含可以存取這些私有變數的公共方法。”
「這就像建立一個類,但不用 class 關鍵字,」蒂莫西說。
“沒錯。在 ES6 類別出現之前,模組模式是 JavaScript 中建立帶有私有狀態的物件的方法。即使在今天,它仍然用於組織程式碼。”
瑪格麗特的表情變得嚴肅起來。 “現在我必須警告你,JavaScript歷史上最臭名昭著的閉包陷阱是什麼。”
她寫道:
function setupButtons() {
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log("Button " + i + " clicked");
}, 1000);
}
}
setupButtons();
“執行一下。你期望得到什麼?”
蒂莫西心想:“應該先打印‘按鈕 1 被點擊’,然後打印‘按鈕 2 被點擊’,最後打印‘按鈕 3 被點擊’。”
瑪格麗特負責營運。
Button 4 clicked
Button 4 clicked
Button 4 clicked
蒂莫西愣住了。 “什麼?為什麼是4?而且為什麼都是同一個數字?”
「這是第一章的 var 問題,只不過這次加入了閉包,」瑪格麗特說。 “仔細看。使用var ,變數i屬於函數setupButtons ,而不是循環塊。這裡只有一個i變數。”
她追溯了整個行刑過程:
// The loop runs:
// i = 1, create callback, schedule it
// i = 2, create callback, schedule it
// i = 3, create callback, schedule it
// i = 4, exit loop
// One second later, all three callbacks fire.
// Each callback's closure contains 'i'.
// What is i right now? 4.
// All three print 4.
「這三個回調函數都呼叫了同一個變數i ,」蒂莫西低聲說。 “它們在建立時並沒有獲取該變數的值,而是引用了當前存在的變數。等到它們執行時,i 的值已經是 4 了。”
“沒錯。這個陷阱已經讓數百萬 JavaScript 開發者栽了跟頭。以前的解決方案很糟糕。”
她給他看了:
// The old workaround
for (var i = 1; i <= 3; i++) {
(function(j) {
setTimeout(function() {
console.log("Button " + j + " clicked");
}, 1000);
})(i); // Pass i as j
}
“這樣每次迭代都會建立一個新函數, j成為該函數的局部變數,用於捕獲i的當前值。雖然可行,但不夠優雅。”
蒂莫西呻吟道:“肯定有更好的辦法。”
“有的。還記得第一章嗎?塊作用域。”
瑪格麗特改了一個字:
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log("Button " + i + " clicked");
}, 1000);
}
“使用let ,JavaScript 會在每次迭代中建立一個新的i 。每次循環體執行時,都會建立一個新的i變數。每個回調函數都會覆寫它自己的i 。”
提摩西測試過了,效果完美:
Button 1 clicked
Button 2 clicked
Button 3 clicked
“所以let透過區塊作用域來解決這個問題嗎?”
“是的。var 是函數作用域的——整個函數只有一個變數。let let程式碼塊作用域的——每次循環迭代都會var一個新變數。每個回調函數都會呼叫不同的變數。問題解決了。”
蒂莫西笑了。 “我開始明白你為什麼說 JavaScript 的關鍵在於作用域和上下文了。”
瑪格麗特舉了一個實際例子:
function makeButton(label, callback) {
const button = document.createElement('button');
button.textContent = label;
button.addEventListener('click', function() {
callback();
});
return button;
}
let clickCount = 0;
const myButton = makeButton('Click Me', function() {
clickCount++;
console.log("Clicked " + clickCount + " times");
});
document.body.appendChild(myButton);
「這就是 JavaScript 在現實世界中處理事件的方式,」瑪格麗特說。 “事件處理程序會關閉clickCount物件。每次你點擊按鈕時,處理程序都會執行,並且可以存取和修改clickCount 。”
蒂莫西點點頭。 “這就是事件處理程序如此強大的原因。它們會記住上下文。”
“沒錯。還有一種模式你會到處看到——帶有配置的函數工廠:”
function createMultiplier(factor) {
return function(number) {
return number * factor;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
“每個返回的函數都會呼叫自己的factor 。這樣就建立了同一個函數的各種特殊版本。這在 JavaScript 庫中非常常見。”
瑪格麗特站起身,走到窗邊,望著被雨水浸透的倫敦街道。
「蒂莫西,關於關閉,你需要了解以下幾點:
每個函數都具有閉包的潛力。當一個函數在另一個函數內部建立,並存取外部作用域中的變數時,它就變成了一個閉包。更準確地說,當一個函數在其定義的作用域之外仍然需要存取該作用域中的變數時,就形成了閉包。
詞法作用域決定了哪些變數可以存取。一個函數可以存取自身作用域以及所有父作用域中的變數。
背包中包含即時引用。當一個函數對變數進行閉包操作時,它會持有對該變數的參考。如果該變數發生變化,閉包也會感知到這種變化。
只要閉包引用變數,變數就會一直存在。如果閉包仍然需要該變數,垃圾回收就無法將其刪除。這很強大,但需要注意——如果閉包持有對大型物件的引用,該物件將無法被垃圾回收,如果不小心,可能會導致記憶體洩漏。
這可以保護資料隱私。您可以建立只有特定函數才能存取的私有變數。
這使得工廠模式成為可能。您可以建立多個具有獨立狀態的函數。
使用let來控製作用域可以避免常見的錯誤。在循環中, let為每次迭代建立一個新的變數,從而使每個閉包都有自己的引用。
她轉過身對蒂莫西說:“閉包不是什麼怪癖,而是函數式編程的基石。你寫的每一個回調函數都是一個閉包。你建置的每一個模組都使用了閉包。每一個事件處理程序都使用了閉包。”
蒂莫西閉上眼睛片刻。 “所以,當我編寫事件處理程序時,我傳遞給addEventListener函數會覆寫它需要的所有外部作用域變數嗎?”
「是的。而且它會攜帶這些引用,並在事件觸發時使用它們,即使原始作用域早已執行完畢。”
「那……真是太美了,」提摩西說。
瑪格麗特笑了。 “的確如此。一旦你不再把閉包視為漏洞,而是將其視為特性,JavaScript 就迎刃而解了。”
閉包是一個函數加上它的詞法作用域-當一個函數被建立時,它就可以存取定義它的作用域中的所有變數,即使該作用域已經執行完畢。
詞法作用域指的是「它被寫出來的地方」 ——函數會在程式碼中定義變數的地方尋找變數,而不是在呼叫變數的地方尋找變數。
每個閉包都會獲得一個背包——當一個函數被建立時,它會從其父作用域中捕獲它需要的變數。
閉包保存的是有效引用,而非副本-如果閉包中的變數改變,函數看到的將是新值。所有對同一變數進行閉包操作的函數共用同一個參考。
如果閉包引用變數,則變數會一直存在——即使外部函數已經完成,JavaScript 也會將變數保留在記憶體中,只要閉包可以存取它們。
閉包能夠保護資料隱私——閉包中的變數只能透過呼叫該閉包的函數來存取,任何外部程式碼都無法直接修改它們。
模組模式使用閉包-傳回一個物件,該物件的方法可以存取私有變數,從而建立一個具有公共介面和私有狀態的模組。
循環中的var會建立一個共享變數——循環中的所有回調函數都會存取同一個var變數,該變數在回調函數執行時具有最終的循環值。
循環中的let會建立每次迭代的變數-循環的每次迭代都有自己的let變數,因此每個閉包都有自己的引用。
閉包在 JavaScript 中無所不在——事件處理程序、回調函數、工廠函數和模組都依賴閉包才能正常運作。
你認為為什麼 JavaScript 決定在閉包引用變數時保持變數的存活狀態,而不是讓它們消失?
在模組模式中,為什麼外部函數要立即被呼叫(function() { ... })() ?為什麼不直接定義一個普通的函數呢?
當你向addEventListener傳遞回調函數時,它會關閉哪些變數,以及這樣做有什麼用?
如果一個閉包持有對一個大型物件的引用,那麼該物件的記憶體會發生什麼變化?它能被垃圾回收嗎?
如何在不使用let情況下解決經典的循環問題?有什麼變通方法嗎?
請在評論區分享您在內容創作方面的發現和實際案例!
《JavaScript的秘密生活》揭示了JavaScript的實際運作原理——而非你希望它如何運作。本書透過提摩西(一位好奇的開發者,正在學習他的第一門新語言)和瑪格麗特(一位經驗豐富的JavaScript專家)在倫敦維多利亞時代圖書館的對話,探索了JavaScript奇特語法背後的理念。
每一章都以前一章為基礎:
第一章:變數-它們存在於何處以及作用域是如何運作的
第二章: this與約束
第三章:原型-物件委託,而非複製
第四章:閉包-函數記住它們的誕生地
接下來是: “事件循環的秘密生活” ——單線程語言如何在不凍結的情況下管理回呼、計時器和非同步程式碼。
Aaron Rose 是一位軟體工程師,也是tech-reader.blog的技術作家,著有《像天才一樣思考》一書。
原文出處:https://dev.to/aaron_rose_0787cc8b4775a0/the-secret-life-of-javascript-understanding-closures-40if