You don't know JavaScript Yet:#10 閉包(Closures)
在「You don't know JavaScript Yet:#3 深入JS的核心」中曾經有談論過關於閉包(Closures),在前面幾個章節中也時不時會提到閉包,但一直未深入討論這個話題,終於到了這章能好好地探究它了。閉包是程式語言重要的特色之一,它不僅只在JS中存在,若要當一個JS大師,那麼善用閉包是必須熟練的技巧。
初探閉包
「閉包是函式的一種特性,它也只會發生於函式中。」
從上面這句話可以知道,若我們要觀察閉包的行為,只能透過建立函式來觀察,所以在這裡我們沿用前面章節中「第一個隱喻:彈珠與泡泡」的例子:
// outer/global scope: RED(1)
function lookupStudent(studentID) {
// function scope: BLUE(2)
var students = [
{ id: 14, name: "Kyle" },
{ id: 73, name: "Suzy" },
{ id: 112, name: "Frank" },
{ id: 6, name: "Sarah" }
];
return function greetStudent(greeting){
// function scope: GREEN(3)
var student = students.find(
student => student.id == studentID
);
return `${ greeting }, ${ student.name }!`;
};
}
var chosenStudents = [
lookupStudent(6),
lookupStudent(112)
];
// accessing the function's name:
chosenStudents[0].name;
// greetStudent
chosenStudents[0]("Hello");
// Hello, Sarah!
chosenStudents[1]("Howdy");
// Howdy, Frank!
首先lookupStudent(..)
會回傳函式greetStudent(..)
,這裡執行了兩次lookupStudent(..)
並將結果儲存於陣列chosenStudents
當中,我們透過.name
這個屬性來確認函式是否符合我們的預期,它也確實是greetStudent(..)
的實例。
接著在每次lookupStudent(..)
執行完畢後,通常內部變數都會透過GC(garbage collected)自動釋放記憶體,只保留回傳值greetStudent(..)
,這邊就是我們想要觀察的地方。greetStudent(..)
允許輸入一個參數greeting
,同時在它裡面使用了來至於lookupStudent
範疇中的students
與studentID
,並將它們封存於內部函式之中,這種參考外部範疇變數並將其封存的行為稱為閉包(closure),若學術一點的說法:greetStudent(..)
closes over the outer variables students
and studentID
,這句話傳達幾個意思:
greetStudent(..)
為閉包,意味著它是函式。- 閉包將一些變數
students
與studentID
封存(closes over, 若有更好的翻譯歡迎提供)於它的範疇當中。
在第三章中曾經有給閉包一個簡單的定義:「閉包是讓函式記住與持續訪問在其範疇之外的變數的一種能力,即使該函式在其他範疇中執行也是如此。」,用這個定義來看這個例子,閉包使得greetStudent(..)
能持續訪問它範疇之外的變數(students
與studentID
)即使lookupStudent(..)
已經執行完畢,students
與studentID
也不會被GC給清掉而是保留它們的記憶體。在這之後greetStudent
在全域範疇中執行時,這些變數依然被保留著。
如果JS沒有閉包的能力,當lookupStudent(..)
回傳greetStudent(..)
之後,代表students
與studentID
都將被GC給回收,若此時在執行任意一個greetStudent(..)
,它會嘗試去尋找函式範疇BLUE(2)中的變數,但BLUE(2)已經不存在了,所以會拋出ReferenceError
。但實際上執行chosenStudents[0]("Hello")
是有跑出Hello, Sarah
的,所以代表students
與studentID
都還存在於記憶體當中,這就是閉包的能力。
在上面的例子當中我們還遺漏了一點小細節,上面的箭頭函式(arrow functions)也是一個範疇,這是我們常疏忽的地方:
var student = students.find(
student =>
// function scope: ORANGE(4)
student.id == studentID
);
ORANGE(4)中參考了BLUE(2)的studentID
,這個箭頭函式是基於持有studentID
的閉包,所以實際上greetStudent(..)
沒有持有studentID
,不過這不影響運作,但了解事實有助於理解閉包,即使是小小的箭頭函式也能擁有閉包。
讓我們看看一個經常被引用作為閉包的例子:
function adder(num1) {
return function addTo(num2){
return num1 + num2;
};
}
var add10To = adder(10);
var add42To = adder(42);
add10To(15); // 25
add42To(9); // 51
每一次實例化adder(..)
都將變數num1
封存於addTo(..)
中,當adder(..)
執行完畢也不會釋放num1
的記憶體,所以當上面執行add10To(15)
時,就等同於10 + 15
,每次執行add10To(..)
都會是10 + num2
。
在這裡要說一個很容易被忽略的重要細節,前面章節再介紹「語彙範疇」時,我們知道範疇決定於編譯期,但在上面的例子,每次進行實例化adder(..)
都會創建一個新的函式addTo(..)
,並且為它們建立新的閉包,儘管閉包是基於語彙範疇,但閉包的特徵表現於函式實例化的時候。
即時鏈結,而非快照
在前面的兩個例子中,我們透過閉包保存的變數來讀取值,感覺很像是當我們要讀取值時,閉包在某個時間點將值的快照(閉包當時的值)傳給我們,這是很多人容易誤解的地方。
閉包實際上為即時鏈結,你不只可以對變數進行讀取的動作,也能夠進行賦值的動作,這代表閉包所封存的是一個完整的變數,這也是閉包為什麼如此強大並且用在許多程式語言上的原因。
讓我們來看看例子:
function makeCounter() {
var count = 0;
return getCurrent(){
count = count + 1;
return count;
};
}
var hits = makeCounter();
hits(); // 1
hits(); // 2
hits(); // 3
變數count
被封存於內部函式getCurrent()
中,hits()
每次執行都會使count
遞增。儘管閉包所封存的變數通常來至於函式中,但這並非必要的,只要任意範疇中有一個函式,且這函式使用外部範疇的變數即可:
var hits;
{ // an outer scope (but not a function)
let count = 0;
hits = function getCurrent(){
count = count + 1;
return count;
};
}
hits(); // 1
hits(); // 2
hits(); // 3
這裡使用函式表達式的原因於前一章有提到,避免FiB所帶來的危害。
還有一種錯誤的狀況也很常見:
var keeps = [];
for (var i = 0; i < 3; i++) {
keeps[i] = function keepI(){
// closure over `i`
return i;
};
}
keeps[0](); // 3 -- WHY!?
keeps[1](); // 3
keeps[2](); // 3
你可能期待keeps[0]()
會回傳0
,但由於變數i
是透過var
進行宣告的,在第八章的「可以使用變數的時間點」中有提過,若使用var
宣告的變數會與最近的函式範疇連結(若沒有函式範疇則與全域範疇連結),所以這裡你可以看成如下:
var keeps = [];
var i;
for (i = 0; i < 3; i++) {
keeps[i] = function keepI(){
// closure over `i`
return i;
};
}
keeps[0](); // 3 -- WHY!?
keeps[1](); // 3
keeps[2](); // 3
當for
迴圈執行結束後,最後i
的值為3
,前面提到閉包並非使用快照,而是即時鏈結,這邊就是一個好的證明。所以要解決這問題的最快方法就是把var
改成let
即可:
var keeps = [];
for (let i = 0; i < 3; i++) {
// the `let i` gives us a new `i` for
// each iteration, automatically!
keeps[i] = function keepEachI(){
return i;
};
}
keeps[0](); // 0
keeps[1](); // 1
keeps[2](); // 2
這樣在迴圈中每次都會建立一個獨立的i
出來,就不會有上面的問題。
如果沒辦法觀察到閉包呢
如果一個閉包它存在於程式的某處,但我們無法察覺到它的存在,這重要嗎?實際上不重要。重要的是我們能透過觀察來察覺閉包的存在。
我們用幾個例子來強調這點:
function say(myName) {
var greeting = "Hello";
output();
function output() {
console.log(
`${ greeting }, ${ myName }!`
);
}
}
say("Kyle");
// Hello, Kyle!
內部函式output()
使用了greeting
與myName
,但呼叫output()
也在同一個範疇當中,所以這只是單純的語彙範疇且並沒有閉包存在,但它看起來像是有閉包存在一樣。
再看看另外一個例子:
var students = [
{ id: 14, name: "Kyle" },
{ id: 73, name: "Suzy" },
{ id: 112, name: "Frank" },
{ id: 6, name: "Sarah" }
];
function getFirstStudent() {
return function firstStudent(){
return students[0].name;
};
}
var student = getFirstStudent();
student();
// Kyle
實際上全域變數沒辦法透過觀察來確認它是否用於閉包,因為它在任何地方都能夠被使用。students
雖然被用於內部函式firstStudent()
當中,但由於students
位處於全域範疇當中,透過這樣呼叫跟使用正常的語彙範疇沒什麼區別。所有函式都能使用全域變數,無論該程式語言是否支援閉包,在此處使用閉包有點多此一舉的感覺。
下一個例子中,變數若沒有被使用到,就不會有閉包的存在:
function lookupStudent(studentID) {
return function nobody(){
var msg = "Nobody's here yet.";
console.log(msg);
};
}
var student = lookupStudent(112);
student();
// Nobody's here yet.
內部函式nobody()
中未曾使用到studentID
也未封存任何變數,它只用到自己範疇內的msg
,當執行lookupStudent(..)
時,studentID
因為沒有被nobody()
使用到,所以最後GC會對其進行記憶體清除的動作。
最後一個例子,若函式未被呼叫,儘管它有閉包的行為存在,但它依然不算是一個閉包:
function greetStudent(studentName) {
return function greeting(){
console.log(
`Hello, ${ studentName }!`
);
};
}
greetStudent("Kyle");
// nothing else happens
由於這邊呼叫greetStudent(..)
但未使用一個變數接收它的回傳值,所以當執行完畢回傳值也直接被回收掉了,技術上來說JS在短暫時間內有閉包的存在,但因為我們無法觀察到,所以也沒什麼意義。
可觀察的定義
「當一個函式使用外部範疇的變數時,即使在無法存取這些變數的範疇中執行這個函式,也能觀察到閉包(Closure is observed when a function uses variable(s) from outer scope(s) even while running in a scope where those variable(s) wouldn't be accessible.)。」
上面這段(翻的不好請見諒😓)有幾個意義存在:
- 必須有一個函式被呼叫。
- 這個函式至少使用一個外部範疇的變數。
- 必須在與這個(些)變數不同的範疇中,使用這個函式。
這意味著我們應該以是否能夠觀察到閉包會對程式造成影響或者產生什麼行為來定義閉包,而不是將閉包透過某種學術定義來說明它。
閉包的生命週期與垃圾回收(Garbage Collection, GC)
由於閉包本質上與函式的實例關聯著,只要這個函式reference一直被使用著,閉包中的變數也會持續存在著。
假設你有十個函數全部都封存某個變數,但隨著時間流逝,其中九個函式reference已經被丟棄了,但只要還有一個函式的reference還存在,仍然會保留該變數,直到最後一個函式reference被丟棄,這個變數就會被GC處理掉。
這對於程式的效能來說很重要,因為這代表著如果有一堆函式封存了一堆變數,這些變數都存在於記憶體當中,它們可能意外地阻止了GC做清除的動作,進而導致記憶體的過度使用,所以當函式reference不再被需要使用時就進行丟棄的動作是很重要的。
考慮一下程式碼:
function manageBtnClickEvents(btn) {
var clickHandlers = [];
return function listener(cb){
if (cb) {
let clickHandler =
function onClick(evt){
console.log("clicked!");
cb(evt);
};
clickHandlers.push(clickHandler);
btn.addEventListener(
"click",
clickHandler
);
}
else {
// passing no callback unsubscribes
// all click handlers
for (let handler of clickHandlers) {
btn.removeEventListener(
"click",
handler
);
}
clickHandlers = [];
}
};
}
// var mySubmitBtn = ..
var onSubmit = manageBtnClickEvents(mySubmitBtn);
onSubmit(function checkout(evt){
// handle checkout
});
onSubmit(function trackAction(evt){
// log action to analytics
});
// later, unsubscribe all handlers:
onSubmit();
我們建立函式checkout(..)
與函式trackAction(..)
傳給函式listener(..)
的參數cb
進行監聽點擊事件並且函式onClick
會對cb
進行封存。在最後一行我們不帶任何參數用來執行清除的動作,這將會取消所有曾經監聽過的點擊事件並且把clickHandlers
清成空陣列,這代表經由cb
使用checkout(..)
與trackAction(..)
的函式reference都一併被清除了,所以GC就會釋放它們的記憶體。
考慮程式整體運行狀況以及效能,將不再需要使用的事件取消監聽比進行監聽更為重要。
封存部分變數還是整個範疇
我們應該只封存我們有參考到的變數,還是將整個範疇鏈的變數都進行封存呢?
概念上來說,只封存有參考到的變數就好,但實際情況沒有我們想的那麼簡單,考慮以下程式碼:
function manageStudentGrades(studentRecords) {
var grades = studentRecords.map(getGrade);
return addGrade;
// ************************
function getGrade(record){
return record.grade;
}
function sortAndTrimGradesList() {
// sort by grades, descending
grades.sort(function desc(g1,g2){
return g2 - g1;
});
// only keep the top 10 grades
grades = grades.slice(0,10);
}
function addGrade(newGrade) {
grades.push(newGrade);
sortAndTrimGradesList();
return grades;
}
}
var addNextGrade = manageStudentGrades([
{ id: 14, name: "Kyle", grade: 86 },
{ id: 73, name: "Suzy", grade: 87 },
{ id: 112, name: "Frank", grade: 75 },
// ..many more records..
{ id: 6, name: "Sarah", grade: 91 }
]);
// later
addNextGrade(81);
addNextGrade(68);
// [ .., .., ... ]
函式manageStudentGrades(..)
接收一組學生成績的陣列並且回傳函式addGrade(..)
的reference給addNextGrade
,每當呼叫addNextGrade(..)
就會新增一個成績,接著將成績排序後取前十名,grades
透過閉包保存於addGrade(..)
之中,這是其中一個被addGrade(..)
封存的變數,還有另外一個被封存的對象是函式sortAndTrimGradesList()
,函式也是閉包封存的對象之一。
想想看還有哪些是被封存的變數?
函式getGrade(..)
有被封存嗎?它只被用於manageStudentGrades(..)
範疇的一開始,但沒被用於addGrade(..)
與sortAndTrimGradesList()
中,所以它沒有被封存。
再看看studentRecords
,它與getGrade(..)
一樣只被用於範疇的一開始,若studentRecords
有被封存的話,那麼對記憶體來說絕對是一大負擔,但我們透過觀察,實際上它並沒有被封存。
根據我們前面理解閉包的定義,當manageStudentGrades(..)
執行完畢時,未被封存的變數都會被GC給清除掉。我們可以嘗試使用在chrome上的DevTools中進行Debug,設立一個中斷點在addGrade(..)
中,接著可以注意到變數studentRecords
未被列出它的值,這能夠證明它並沒有被封存。
但是上述這種驗證方式可靠嗎?看看下面例子:
function storeStudentInfo(id,name,grade) {
return function getInfo(whichValue){
// warning:
// using `eval(..)` is a bad idea!
var val = eval(whichValue);
return val;
};
}
var info = storeStudentInfo(73,"Suzy",87);
info("name");
// Suzy
info("grade");
// 87
注意到內部函式getInfo
並沒有封存id
、name
或者grade
,但是我們透過執行info(..)
仍然可以獲得這些值,這違背我們上面談論到的。
這邊的結果與前面我們所描述的又不一樣了,變數不論是否有被內部函式參考都會被閉包保留著。那麼回到我們一開始的議題,這樣是否傾向於整個範疇鏈的變數都會被封存呢?視情況而定。
許多現代化的JS engine都會做最佳化的動作,將那些未明確使用的變數從閉包中移除。但正如我們上面看到使用eval(..)
的情況,在某些情況下JS engine則無法做優化的動作,這時閉包中就會擁有所有的原始變數。換句話說,閉包必須根據不同的範疇進行最佳化的動作,它會盡量減少變數保留的數量,這結果就如同我們一開始說明閉包那樣,沒用到的變數都將被GC清除掉。
但在幾年前,許多JS engine都沒有進行這種優化,若你運行在老舊的設備或者未更新的瀏覽器中,未清除的變數其記憶體占用的時間會比我們想像中還要來的長。由於這個優化並非在規範當中,所以我們不該依賴每台設備的JS engine都會幫我們做優化的動作。
若你使用了一個較大的陣列或者物件,當你不再使用它並且不希望保留它的記憶體時,進行手動丟棄的動作(有點像養成良好資源回收的概念),而不是依賴閉包的優化與GC。
讓我們試著修改前面manageStudentGrades(..)
的例子,變數studentRecords
由於我們不再使用它了,所以可以透過下面方式來進行手動清除:
function manageStudentGrades(studentRecords) {
var grades = studentRecords.map(getGrade);
// unset `studentRecords` to prevent unwanted
// memory retention in the closure
studentRecords = null;
return addGrade;
// ..
}
我們並沒有真正的從閉包中清除掉studenRecords
,因為那並不在我們能控制的範圍內,我們只能確保它就算被遺留於閉包當中,它至少不會占用太多的記憶體,至於最後清除這個變數的工作,就交給GC處理就好。除了studenRecords
以外,實際上getGrade
也是我們需要清理的對象。
了解閉包在程式中出現的位置以及它包含哪些變數是很重要的,仔細的管理這些閉包,僅保留最基本的需求而不浪費多餘的記憶體,不論在哪種程式語言中都是很重要的一環。
另一種角度看閉包
我們先來看看前面的例子:
// outer/global scope: RED(1)
function adder(num1) {
// function scope: BLUE(2)
return function addTo(num2){
// function scope: GREEN(3)
return num1 + num2;
};
}
var add10To = adder(10);
var add42To = adder(42);
add10To(15); // 25
add42To(9); // 51
我們當前的觀點認為,無論在何處傳遞和呼叫函式,閉包都會保留一個回去原始範疇的隱藏路徑,以便於訪問被封存的變數。我們透過下圖來說明此概念:
上面例子中的內部函式addTo(..)
透過函式adder(..)
回傳它的實例給RED(1)範疇中的變數(add10To
與add42To
),但我們可以用另外一種思考方式,想像回傳的函數實例實際上是回傳它所屬位置的範疇,其中也包含整個範疇鏈,也就是說這個賦值的動作實際上是把整個範疇都傳遞過去,而非只是一個函式。我們透過下圖來看看這個概念與上面的差異:
從上圖可以看到函式adder(..)
每次都會創立一個新的BLUE(2)範疇,其中包含了變量num1
,以及函式addTo(..)
的實例與它的範疇GREEN(3)。與Fig.1不同的地方在於addTo(..)
它的位置留在BLUE(2)之中,然後add10To
與add42To
移到RED(1)之中,且它們不再只是表示addTo(..)
的實例。當add10To
被執行時,它依舊存在於BLUE(2),所以它可以很自然的存取範疇鏈的變數。
所以上面兩個描述方式哪一個是對的呢?實際上兩個都是對的,只是用範疇鏈的觀點來看待閉包,更貼近我們實際使用程式時的狀況。閉包描述了一個函式的實例不僅僅只是一個函式而已,還有其背後連結整個範疇鏈的能力。不論你選擇哪一種方式來理解閉包,我們透過程式觀察到的狀況都是一樣的。
透過閉包改善程式碼
前面我們已經了解閉包到底如何運作的了,接下來我們來討論如何使用閉包來改善程式碼架構。
假設我們有一個按鈕,按下去就會透過Ajax發送與接收一些資料。首先我們先看看不使用閉包的例子:
var APIendpoints = {
studentIDs:
"https://some.api/register-students",
// ..
};
var data = {
studentIDs: [ 14, 73, 112, 6 ],
// ..
};
function makeRequest(evt) {
var btn = evt.target;
var recordKind = btn.dataset.kind;
ajax(
APIendpoints[recordKind],
data[recordKind]
);
}
// <button data-kind="studentIDs">
// Register Students
// </button>
btn.addEventListener("click",makeRequest);
函式makeRequest(..)
用於從點擊事件接收一個evt
物件,這函式會從目標按鈕中獲取data-kind
屬性,並使用獲取到的值進行對應的Ajax請求。
上面這段程式碼可以正常的運作,但有一些問題存在,它在每次事件觸發時都會去獲取DOM屬性,這會造成效能的低落,理當說我們只要再加入監聽時,將它的DOM屬性記下就好,所以我們可以透過閉包來解決這問題:
var APIendpoints = {
studentIDs:
"https://some.api/register-students",
// ..
};
var data = {
studentIDs: [ 14, 73, 112, 6 ],
// ..
};
function setupButtonHandler(btn) {
var recordKind = btn.dataset.kind;
btn.addEventListener(
"click",
function makeRequest(){
ajax(
APIendpoints[recordKind],
data[recordKind]
);
}
);
}
// <button data-kind="studentIDs">
// Register Students
// </button>
setupButtonHandler(btn);
這裡改使用函式setupButtonHandler(..)
,在其中我們只進行一次檢索DOM屬性的動作,接著就加入按鈕的事件監聽,其中函式makeRequest()
會封鎖外部變數recordKind
,在每次觸發事件時,都會使用相同的值,而不用像前面一樣每次都檢索一次。
除此之外,我們還可以替換ajax
中參數的部分,由於在每次觸發時需要從全域變數APIendpoints
與data
進行檢索的動作,我們可以透過以下這種方式來改寫:
function setupButtonHandler(btn) {
var recordKind = btn.dataset.kind;
var requestURL = APIendpoints[recordKind];
var requestData = data[recordKind];
btn.addEventListener(
"click",
function makeRequest(){
ajax(requestURL,requestData);
}
);
}
函式makeRequest()
也封存了變數requestURL
與requestDate
,這樣看起來比較具有可讀性且效率也比較好。
我們利用閉包的特性,將一些訊息(變數)封裝於函式之中,這些帶有訊息的函式就不需要帶入參數而可以直接使用,這可以使程式碼變得較為簡潔並且還提供了使用更好的語義名稱來標記函數的機會。
我們可以再進一步改進程式碼,讓程式可重複使用:
function defineHandler(requestURL,requestData) {
return function makeRequest(){
ajax(requestURL,requestData);
};
}
function setupButtonHandler(btn) {
var recordKind = btn.dataset.kind;
var handler = defineHandler(
APIendpoints[recordKind],
data[recordKind]
);
btn.addEventListener("click",handler);
}
這段程式碼與前面介紹的相當類似,只是我們讓makeRequest()
提前進行封存的動作並將結果賦予變數handler
,
函式defineHandler(..)
在程式中將能被重複的使用。我們還明確地將閉包限制為僅需要兩個變數(requestURL與requestData)。
總結
我們可以將閉包解釋成下面兩種模式:
- 以觀察的角度:閉包是一個函式的實例,即使該函式被傳遞到其他範疇,並在其他範疇被呼叫,它依舊會記住外部變數。
- 以範疇鏈的角度:閉包是一個函式的實例,它的範疇會保留於原本的位置,任何參考都會指向這個位置,之後的運作就如同於範疇鏈一般。
閉包能為我們帶來的好處:
- 閉包可以將訊息進行封存,記住已經確認過的訊息,而不必每次都進行計算或查詢的動作,進而提高程式執行的效率。
- 閉包通過將變數封存於函式實例之中來增加程式的可讀性,確保當函式被呼叫時訊息有被保留下來,減少變數的曝光同時也能帶來POLE的好處。
Reference
- You don't know JavaScript Yet
- You don't know JavaScript Yet:#1 什麼是JavaScript
- You don't know JavaScript Yet:#2 概觀JS
- You don't know JavaScript Yet:#3 深入JS的核心
- You don't know JavaScript Yet:#4 範疇
- You don't know JavaScript Yet:#5 說明語彙範疇
- You don't know JavaScript Yet:#6 範疇鏈
- You don't know JavaScript Yet:#7 全域範疇
- You don't know JavaScript Yet:#8 變數神秘的生命週期
- You don't know JavaScript Yet:#9 限制範疇曝光