先看一段程式碼,猜猜看它印出什麼:
let x = 1;
function hey() {
console.log(x);
}
function test() {
let x = 2;
hey();
}
test();答案是 1。
如果你猜 2,你的推論大概是這樣:hey 是在 test 裡面被呼叫的,而 test 裡面剛好有一個 x 是 2,所以 hey 應該會拿到那個 2。
這個推論很合理,但 JavaScript 不是這樣運作的。
hey 這個名字在程式裡出現了兩次,但那是兩件不同的事。
function hey() { ... } 這一整塊,是把這個函式寫出來,這叫做 hey 的定義。
test 裡面那行 hey(),是把已經定義好的函式叫起來執行,這叫做呼叫。
決定 console.log(x) 拿到哪個 x 的,是定義的位置。
hey 的定義寫在最外層,跟 let x = 1 平行,沒有寫進 test 的大括號裡。
而 test 裡的 x 只活在那對大括號之內,hey 的定義在括號外面,所以看不到 test 裡的 x。
於是 hey 看得到的只有最外層的 x,值是 1。
換句話說,hey 看得到哪些變數,取決於定義寫在哪一層,而不是呼叫寫在哪一層。
這條規則叫做 Lexical Scope(詞法作用域)。
Lexical 在這裡的意思就是「原始碼上的位置」——你把函式定義在哪一層,這個函式看得到哪些變數就在那一刻定案了。
作用域:一個變數能被看到的範圍
作用域(scope)指的是程式碼裡的一塊區域,在這塊區域內宣告的變數,只有這塊區域裡面的程式碼看得到。
最常見的一種作用域就是函式:
function greet() {
let message = "hello";
console.log(message);
}
greet();
console.log(message);greet 裡面的那行 console.log 可以正常印出 hello,因為它跟 message 在同一個函式裡。
但最後那行會直接報錯:ReferenceError: message is not defined。
從函式外面看過去,message 這個變數根本不存在。
它在 greet 被呼叫的時候誕生,在 greet 執行完之後就沒了。
作用域可以一層包一層
函式裡面還可以再寫函式,於是作用域就變成了巢狀的結構:
let globalVar = "global";
function outer() {
let outerVar = "outer";
function inner() {
let innerVar = "inner";
console.log(innerVar);
console.log(outerVar);
console.log(globalVar);
}
inner();
}
outer();這段程式碼有三層:最外層、outer 裡面、inner 裡面。
inner 這三行 console.log 全部都印得出來,但取得變數的方式不太一樣。
innerVar 就是在 inner 裡面宣告的,直接拿。
outerVar 不是在 inner 裡面宣告的,於是 JavaScript 往外一層去 outer 找,找到了。
globalVar 在 outer 裡也沒有,於是再往外一層找到最外層,也找到了。
這個「自己這層沒有就往外一層找,一路找到最外層為止」的過程,就叫做作用域鏈(scope chain)。
如果找到最外層還是沒有,才會丟出 ReferenceError。
為什麼是定義的位置,不是呼叫的位置
有了作用域鏈,我們可以回頭看開場那段程式碼了。
let x = 1;
function hey() {
console.log(x);
}
function test() {
let x = 2;
hey();
}
test();hey 的大括號裡面沒有 let x 這一行,也就是說 x 不是在這裡宣告的。
既然不是在這裡宣告的,JavaScript 就得沿著作用域鏈往外找。
關鍵在於:hey 的「外面」是哪一層?
定義寫在最外層,所以它的外面就是最外層,找到的 x 是 1。
test 裡的 x 從來不在 hey 的作用域鏈上。test 裡的 hey() 只是把 hey 叫起來執行,並不會順便把 test 的變數交給 hey。
理論上,語言也可以設計成讓函式去看「呼叫寫在哪一層」,真的有語言是這樣做的。
但 JavaScript 選的是另一條路:函式的作用域鏈在定義被寫下來的那一刻就固定了,之後不管誰呼叫它、在哪一層呼叫,都不會改變。
這對你的好處是,你光讀原始碼就能推斷出一個變數指的是誰,不需要去追它的呼叫路徑。
只能往外找,不能往內找
作用域鏈是單向的。
裡面的函式看得到外面的變數,但外面看不到裡面。
function outer() {
let outerVar = "outer";
function inner() {
let innerVar = "inner";
console.log(outerVar);
}
inner();
console.log(innerVar);
}
outer();inner 印 outerVar 沒問題,這是往外找。
但 outer 最後那行想印 innerVar 就會報錯,因為那是往內找,作用域鏈不往那個方向走。
站在 outer 的位置,inner 裡面發生的事完全是個黑盒子。
不只函式,區塊也有自己的作用域
如果你用的是 let 或 const,那麼一對大括號就足以形成一個作用域,不需要是函式:
if (true) {
let blockVar = "inside the block";
}
console.log(blockVar);這裡一樣會報錯。
blockVar 被關在 if 的大括號裡面,出了那對括號就不存在了。
這叫做區塊作用域(block scope),for 迴圈、while、甚至單獨寫一對 {} 都算。
但如果換成 var,行為就不一樣了:
if (true) {
var oldVar = "inside the block";
}
console.log(oldVar);這次會印出 inside the block。
var 不管區塊,它只認函式——寫在函式裡就屬於那個函式,沒寫在函式裡就屬於最外層。
這是 var 常常讓人踩雷的原因之一,也是現在建議一律用 let 和 const 的原因之一。
閉包:函式會把作用域一起帶走
前面的例子裡,inner() 這行呼叫都寫在 outer 的大括號裡面。
function outer() {
let outerVar = "outer";
function inner() {
console.log(outerVar);
}
inner();
}
outer();inner() 那行還在 outer 的大括號之內,所以整段的執行順序是這樣:
- 呼叫
outer(),outerVar誕生 - 執行到
inner(),印出outerVar outer結束,outerVar消失
outerVar 要到第 3 步才消失,而 inner 在第 2 步就用完了,所以印得出來。
那如果 inner 是在第 3 步之後才被呼叫呢?
function outer() {
let outerVar = "outer";
return function inner() {
console.log(outerVar);
};
}
const closureFunction = outer();
closureFunction();跟上一段程式碼的差別只有兩處。
第一,outer 不再自己呼叫 inner,而是用 return 把 inner 這個函式交出去。
第二,const closureFunction = outer(); 這行跑完之後,outer 就結束了,而 closureFunction 接住的正是 inner。
所以最後那行 closureFunction(),其實就是在呼叫 inner。
執行順序變成這樣:
- 呼叫
outer(),outerVar誕生 outer把inner交出去,outer結束- 呼叫
closureFunction(),這時才印出outerVar
問題來了:照前面說的,outerVar 應該在第 2 步就跟著 outer 一起消失。
那第 3 步還印得出東西嗎?
印得出來,結果是 outer。
原因在於,inner 的作用域鏈在定義被寫下來的時候就決定了,而它的定義寫在 outer 裡面。
只要 inner 還活著,那條鏈上的變數就都得留著,因為 inner 隨時可能會用到。
於是 outerVar 就跟著 inner 一起被保留了下來。
這種「函式帶著它出生時的作用域四處跑」的現象,就叫閉包(closure)。
閉包不是一個要另外學的機制,它就是 Lexical Scope 加上「函式可以被當成值傳來傳去」這兩件事撞在一起的結果。
實務應用:把變數藏起來
閉包最直接的用途,是讓一個變數只能透過你指定的方式被修改。
假設你要做一個計數器:
❌ 用全域變數的寫法:
let count = 0;
function increment() {
count++;
return count;
}count 攤在最外層,任何一行程式碼都可以直接寫 count = 999 把它改掉。
而且你檔案裡再也不能有第二個叫 count 的全域變數,否則兩邊會互相覆蓋。
✅ 用閉包的寫法:
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter());
console.log(counter());依序印出 1 和 2。
count 被關在 createCounter 的作用域裡,外面沒有任何辦法直接看到 count——這正是前面說的「只能往外找,不能往內找」。
唯一能動到 count 的,就是那個被回傳出來的函式,而它只做加一這件事。
另外,因為每次呼叫 createCounter() 都會產生一組全新的 count,你可以同時開好幾個互不干擾的計數器。
重點整理
- 作用域是一塊區域,在裡面宣告的變數只有裡面的程式碼看得到
- 找不到變數時,JavaScript 會沿著作用域鏈往外一層一層找,找到最外層還沒有才報錯
- Lexical Scope 的意思是:這條鏈由函式定義寫在哪一層決定,跟呼叫寫在哪一層無關
- 作用域鏈單向往外,內層看得到外層的變數,外層看不到內層的
let和const是區塊作用域,一對大括號就形成一層;var只認函式- 閉包是函式被回傳出去之後,仍然帶著它出生時那條作用域鏈的現象
- 把變數關在作用域裡、只開放特定函式去操作它,是控制修改途徑最簡單的方法