上一篇討論 JavaScript 的值如何改變型別,以及隱式轉換可能帶來的結果。當程式開始拆成函式和條件區塊後,還得決定資料該放在哪裡。
以 TypeTrail 為例,題目文字只需要在顯示題目時使用,作答次數卻要在多次呼叫之間保留下來。資料放得太外面,程式的其他部分也能修改;放得太裡面,每次執行函式又會重新建立。
這篇會先看函式與區塊如何限制名稱的使用範圍,再理解函式如何從宣告位置向外尋找名稱。掌握這些規則後,就能用閉包保留作答狀態,也能判斷函式交給其他程式呼叫時,可以讀取哪些資料。
作用域決定變數、參數與函式名稱能在哪些位置使用。
function showQuestion() {
const questionText = "變數可以在哪裡使用?";
console.log(questionText);
}
showQuestion();
console.log(questionText); // ReferenceError:函式外找不到這個名稱
由函式建立的範圍稱為函式作用域(function scope)。
函式參數也只能在該函式內使用。不同函式即使宣告同名變數,也各自擁有自己的變數,不會互相覆蓋。
let 與 const 具有區塊作用域(block scope)。條件判斷、迴圈或單獨一組大括號形成的區塊,都會限制它們的可見範圍。
const isCorrect = true;
if (isCorrect) {
const message = "答對了";
let bonus = 1;
console.log(message); // 答對了
console.log(bonus); // 1
}
console.log(message); // ReferenceError:區塊外找不到這個名稱
var 的規則不同。它有函式作用域,但沒有區塊作用域:
function checkAnswer() {
if (true) {
var result = "correct";
}
console.log(result); // correct
}
checkAnswer();
通常優先使用 const,需要重新指定變數時才用 let,並把變數宣告在實際使用它的區塊內。
JavaScript 會先找目前的作用域;找不到時,再往包住它的外層作用域尋找。這層由內向外的查找關係稱為作用域鏈(scope chain),找到名稱就停止。
const siteName = "TypeTrail";
function showQuiz() {
const day = 3;
function printTitle() {
console.log(`${siteName} Day ${day}`);
}
printTitle();
}
showQuiz(); // TypeTrail Day 3
詞法作用域(lexical scope):名稱的可見範圍由宣告位置與巢狀結構決定,不會隨函式的呼叫位置改變。

接著看這個例子:printLabel 在 run 裡被呼叫時,會讀到哪一個 label?
const label = "全域題目";
function printLabel() {
console.log(label);
}
function run() {
const label = "函式內題目";
printLabel();
}
run(); // 全域題目
在 run 裡直接讀取 label,會得到 "函式內題目"。但 printLabel 會依照自己的宣告位置向外尋找,因此讀到全域的 label,印出 "全域題目"。
JavaScript 的函式可以像其他值一樣,被存進變數或傳給另一個函式。這類交給其他程式呼叫的函式,常稱為回呼函式(callback)。
function checkAnswer(answer, onResult) {
const isCorrect = answer === "A";
onResult(isCorrect);
}
checkAnswer("A", function printResult(isCorrect) {
console.log(isCorrect ? "答對了" : "再試一次");
});
checkAnswer 透過 onResult(isCorrect) 呼叫收到的函式,並把判斷結果傳給它。這裡的回呼直接在 checkAnswer 執行期間執行,回呼本身不等於非同步。
同一道題可能作答多次。建立紀錄函式時先傳入題號,之後每次只要傳入答案,就能累加作答次數。
工廠函式 / Factory function
負責建立並回傳物件或函式的函式,常稱為工廠函式。createAnswerRecorder每次執行都會建立一個新的作答紀錄函式。
function createAnswerRecorder(questionId) {
let attempts = 0;
return function recordAnswer(answer) {
attempts += 1;
return {
questionId,
answer,
attempts,
};
};
}
const recordDay3Question1 = createAnswerRecorder("day-03-01");
const recordDay3Question2 = createAnswerRecorder("day-03-02");
console.log(recordDay3Question1("B"));
// { questionId: "day-03-01", answer: "B", attempts: 1 }
console.log(recordDay3Question1("A"));
// { questionId: "day-03-01", answer: "A", attempts: 2 }
console.log(recordDay3Question2("C"));
// { questionId: "day-03-02", answer: "C", attempts: 1 }
createAnswerRecorder 執行完畢後,回傳的 recordAnswer 仍能使用那次呼叫的 questionId 與 attempts。函式連同它能存取的外部環境,合稱為閉包(closure)。
每次呼叫 recordAnswer,都會把作答次數加一,再回傳題號、這次的答案與累積次數。兩次呼叫 createAnswerRecorder 會建立兩個獨立的 attempts,所以兩道題的次數不會互相影響。呼叫端也不必重複傳入題號,或直接存取內部的 attempts。
閉包不一定要回傳函式才存在。前面的 printTitle 在外層函式執行期間就被呼叫,同樣能透過閉包存取外層變數。
let 與 const 另外具有區塊作用域。下一篇會看 Prototype、Class 與 this,理解 JavaScript 如何查找物件方法,以及 this 為什麼會隨呼叫方式改變。
本文同步分享於個人部落格:https://johnsonchen.dev/blog/typescript/scope-and-closure/