JS Hoisting 與 Scope 一起看:為什麼同一個變數,有時能用有時直接爆?
你以為變數都住同一層?
TL;DR
hoisting是 JavaScript 在執行前,先把宣告放進對應作用域的行為。scope是變數能在哪裡被存取的範圍。 真正讓人混亂的,不是這兩個觀念各自多難,而是它們一混在一起,var、let、const就開始各演各的。
很多人學 JavaScript 時,hoisting 跟 scope 是分開讀的。
結果讀完之後,腦袋裡還是會冒出同一個問題:
「所以到底為什麼這裡可以用,那裡不行?」
很合理。因為這兩個觀念如果拆開看,好像都還行;一合體之後,就很容易變成 JavaScript 經典節目:《你以為你懂了,其實還沒有》。
這篇就把它們綁在一起講,順便把 var、let、const 的差異一起收掉。
如果你是從 TDZ 那個錯誤一路追過來,建議先讀上一篇:
JS TDZ 是什麼?搞懂 Temporal Dead Zone,別再被 let 和 const 偷襲
那篇先處理「為什麼宣告了還不能用」;這篇再把 hoisting 跟 scope 的整體脈絡補完整,讀起來會比較順。
hoisting 是什麼?
Hoisting 可以先粗略理解成:
JavaScript 在正式執行程式前,會先把「宣告」放進對應的作用域。
注意,是宣告先處理,不代表賦值也一起完成。
例如:
console.log(a);
var a = 10;
很多教學會把它近似理解成:
var a;
console.log(a);
a = 10;
所以結果會是:
undefined
這就是 hoisting 最常見的入門版本。
scope 是什麼?
Scope(作用域) 就是變數的可見範圍。
你可以把它想成變數的活動區域:
- 有些變數只能在房間裡用
- 有些變數整層樓都能用
- 有些變數出了門就查無此人
在 JavaScript 裡,先記兩個最重要的:
- 函式作用域(function scope)
- 區塊作用域(block scope)
最核心的一句話
如果你只想先記一件事,記這句:
hoisting 決定「宣告什麼時候進場」,scope 決定「它可以在哪裡活動」。
這兩個一交叉,才會決定你最後拿到的是:
- 正常值
undefinedReferenceError
先看 var:hoist 有,區塊作用域沒有
if (true) {
var message = "hello";
}
console.log(message);
結果:
hello
很多新手第一次看到這段都會愣一下:
「不是寫在大括號裡嗎?怎麼外面還拿得到?」
因為 var 沒有區塊作用域。
它吃的是函式作用域。
也就是說,如果它不在函式裡,基本上就會跑到目前較外層的作用域去。
let 和 const:有區塊作用域
同樣的例子,改成 let:
if (true) {
let message = "hello";
}
console.log(message);
結果:
ReferenceError: message is not defined
這次就合理多了。
因為 let 是區塊作用域,變數只活在那個大括號裡。
const 也是一樣:
if (true) {
const message = "hello";
}
console.log(message);
結果同樣會噴錯。
為什麼 var 可以先用,let 卻不行?
這就是 hoisting 跟 scope 開始聯手搞事的地方。
var
console.log(count);
var count = 5;
結果:
undefined
因為:
var count被 hoist- 而且初始化成
undefined - 所以你可以先讀,只是值還沒進來
let
console.log(count);
let count = 5;
結果:
ReferenceError: Cannot access 'count' before initialization
因為:
let count也會 hoist- 但它在初始化前會進入 TDZ
- 所以你不能先讀
這也是為什麼很多人會誤以為 let 沒有 hoisting。
其實不是沒有,是它 hoist 得比較有脾氣。
function scope:var 最容易藏雷的地方
看這段:
function demo() {
if (true) {
var x = 100;
}
console.log(x);
}
demo();
結果:
100
因為 var x 雖然寫在 if 裡,但它的作用域其實是整個 demo() 函式。
你可以近似理解成:
function demo() {
var x;
if (true) {
x = 100;
}
console.log(x);
}
這也是很多舊程式碼會出現「欸,這變數怎麼從那裡漏出來」的原因。
block scope:let / const 比較像正常人世界
function demo() {
if (true) {
let x = 100;
}
console.log(x);
}
demo();
結果:
ReferenceError: x is not defined
這才比較符合直覺:
你在 if 裡宣告的東西,就留在 if 裡,不要跑出來四處社交。
所以我自己的立場很明確:除非你在維護舊專案,不然新程式大多數情況應該優先用 const,需要重新賦值時再用 let。var 能少碰就少碰。
不是因為 var 完全不能用,而是它太容易讓作用域變得黏黏的,不好清。
函式宣告也會 hoist,而且比變數還大牌
函式宣告是另一個很容易考的點。
sayHi();
function sayHi() {
console.log("hi");
}
這段可以正常執行,結果是:
hi
因為函式宣告本身也會 hoist,而且是整個函式內容一起進場。
但函式表達式就不是這樣了
sayHi();
var sayHi = function () {
console.log("hi");
};
結果:
TypeError: sayHi is not a function
原因是這裡被 hoist 的只有:
var sayHi;
不是那個 function 本體。
所以執行 sayHi() 時,sayHi 還只是 undefined,你對 undefined 呼叫函式,JS 當然會不爽。
如果改成 let:
sayHi();
let sayHi = function () {
console.log("hi");
};
這次錯誤會變成:
ReferenceError: Cannot access 'sayHi' before initialization
同樣是 hoisting,但因為 let 有 TDZ,所以錯誤型態也不同。
scope chaining:內層找不到,會往外找
再看一個很重要的基本功:
const siteName = "Halfmemo";
function showName() {
console.log(siteName);
}
showName();
結果:
Halfmemo
因為函式裡找不到 siteName,就往外層找。這叫 scope chain。
這件事平常很好用,但如果你在內層又宣告同名變數,就可能出事。
const siteName = "Halfmemo";
function showName() {
console.log(siteName);
let siteName = "AJ Blog";
}
showName();
這段不會去拿外層的 siteName,而是直接炸 TDZ。
原因不是外層消失了,而是內層已經先宣告了自己的 siteName,只是它還沒初始化。
一張速查表看懂差異
| 類型 | 會 hoist 嗎 | 初始化時機 | 作用域 | 宣告前存取 |
|---|---|---|---|---|
var | 會 | hoist 時先設成 undefined | 函式作用域 | undefined |
let | 會 | 執行到宣告那一行才初始化 | 區塊作用域 | TDZ,報 ReferenceError |
const | 會 | 執行到宣告那一行才初始化 | 區塊作用域 | TDZ,報 ReferenceError |
function declaration | 會 | hoist 時可直接用 | 依所在作用域 | 可直接呼叫 |
如果你要背面試題,這張表很夠用。 如果你要寫正式專案,請再多記一句:不要因為背得出表格,就以為自己永遠不會踩坑。
JavaScript 最會在你自信回升的時候補一刀。
實務上怎麼寫比較不容易亂?
1. 預設用 const
能不改值就不要改。 這樣你看到宣告時,心裡會比較有底。
const apiUrl = "/api/users";
2. 需要重新賦值時再用 let
let currentPage = 1;
currentPage += 1;
3. 避免用 var
除非你是在讀舊程式、修舊專案,或你真的非常清楚它在那段程式裡的作用域效果。
不然很多時候你以為自己省了一個選擇,實際上是幫未來的自己增加 debug 工時。
4. 變數先宣告,再使用
這條很老派,但超有效。
尤其是當程式稍微長一點,或作用域多一點,先宣告真的可以少很多莫名其妙的 hoisting / TDZ 問題。
5. 少用同名變數遮蔽外層
const userName = "AJ";
function render() {
const displayName = "Guest";
console.log(displayName);
}
比起在函式裡再宣告一個 userName,這種命名通常更清楚。
怎麼把 hoisting 跟 scope 一起記?
我自己的記法很簡單:
- 先問:這個宣告會不會先進場? 看 hoisting。
- 再問:它能在哪裡被看到? 看 scope。
- 最後問:在我使用的這個時間點,它初始化了沒?
這一步就會連到
undefined、TDZ 或正常值。
你把這三步順著想,很多題目就不再只是死背答案,而是真的推得出來。
常見問題
Q:var 為什麼這麼常被建議不要用?
A:因為它只有函式作用域,沒有區塊作用域,搭配 hoisting 很容易讓變數提早存在、範圍又比你想像的大,讀程式和除錯都比較累。
Q:let 和 const 都有 hoisting 嗎?
A:有。差別在於它們不會像 var 那樣先給你 undefined,而是先進 TDZ,初始化前不能存取。
Q:函式宣告跟函式表達式最大的差別是什麼? A:函式宣告可以在宣告前直接呼叫;函式表達式不行,因為被 hoist 的通常只是變數宣告,不是函式值本身。
Q:scope 跟 closure 一樣嗎? A:不一樣。scope 是變數可見範圍;closure 是函式即使離開原本執行環境,仍能記住外層作用域變數的能力。兩者有關,但不是同一件事。
小結
hoisting 跟 scope 單獨看都不算太可怕,可怕的是它們一起出現時,JavaScript 會突然變得很有個性。
你只要抓住幾個核心:
var:會 hoist、先給undefined、只有函式作用域let/const:也會 hoist,但有 TDZ,而且是區塊作用域- function declaration:可以提前呼叫
- function expression:要看你用
var、let還是const接它
這樣大部分「為什麼這裡可以、那裡不行」的問題,你都能自己拆開來看。
如果你剛好是從 TDZ 那篇看過來,這篇可以把整張地圖補完整。 JavaScript 不會突然變簡單,但至少它哪裡機車,你會看得比較明白。
半桶水的
留言區
載入中...
發表留言