寫 JavaScript 的人,多少都遇過這些「謎之現象」:
- 用
var宣告的變數,竟然可以先使用、後宣告 - 函式可以在程式碼最頂端被呼叫,宣告寫在後面也照樣能跑
let跟var看起來差不多,行為卻天差地遠- 跑一跑突然冒出
Maximum call stack size exceeded
很多教學會告訴你:「這叫 Hoisting,背起來就對了」。但 Hoisting 只是表象。它背後真正的運作機制,是 JavaScript 引擎的兩個核心概念:Execution Context(執行環境) 與 Execution Context Stack(執行堆疊)。
這篇文章會從你最熟悉的 Hoisting 現象切入,一路追到引擎內部,看清楚為什麼這些行為會這樣發生。
從一段「奇怪的程式碼」開始
先看看下面這段程式碼,猜猜輸出是什麼:
console.log(a); // ?
console.log(foo); // ?
var a = 1;
function foo() { console.log('hello'); }
直覺上,第一行還沒宣告 a 應該要報錯才對。但實際輸出是:
undefined
ƒ foo() { console.log('hello'); }
a 印出 undefined(不是 ReferenceError),而 foo 直接印出整個函式定義。
再看一個對照組:
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2;
同樣是「先用後宣告」,var 是 undefined,let 卻直接拋錯。為什麼?
要解釋這些行為,得先進到引擎內部看看它到底做了什麼。
Execution Context:程式碼的「執行舞台」
JavaScript 程式碼從來不會「裸跑」。引擎在執行任何一段程式之前,都會先替它建立一個 Execution Context(EC,執行環境)。
EC 是抽象結構,記錄了該段程式執行所需的全部環境資訊:變數宣告放在哪、this 指向誰、外層作用域怎麼接。可以把它想像成一齣戲的舞台——演員(變數、函式)、道具(this)、布景(外部作用域)都得先就緒,戲才能開演。
Execution Context 的內部結構
依照 ES2015 之後的規範,一個 Execution Context 大致包含三個部分:
- Lexical Environment(詞法環境):儲存
let/const/ 函式宣告,並藉由 Outer Reference 連到外層作用域,形成 Scope Chain。 - Variable Environment(變數環境):儲存
var宣告的變數。在 ES6 之後它與 Lexical Environment 大致對等,但保留了var特有的 Hoisting 行為。 - this Binding:當前 EC 中
this所指向的對象。
Execution Context 的三種類型
依照建立的時機,EC 分為三種:
| 類型 | 何時建立 | 數量 |
|---|---|---|
| Global Execution Context (GEC) | 程式啟動時 | 整個程式只有一個 |
| Function Execution Context (FEC) | 每次函式被呼叫時 | 任意多個 |
| Eval Execution Context | eval() 執行時 | 不建議使用 |
GEC 是最底層、永遠存在的那一個。每次呼叫函式就會多一個 FEC 疊上去,函式結束就被移除。
兩個階段:Creation 與 Execution(Hoisting 的真相)
EC 不是一次建好,而是分成兩個階段運作。這兩個階段就是 Hoisting 神奇現象的根源。
Creation Phase(建立階段)
引擎進入一段程式碼後,會先掃描整段程式,做三件事:
- 建立 Lexical Environment 與 Variable Environment
- 把所有
var、函式宣告先「登記」起來 - 決定
this指向
關鍵的差異在第 2 步:
var宣告的變數:登記名字,初始化為undefined- 函式宣告(
function foo() {}):整個函式體一併載入 let/const:登記名字,但不初始化,進入 Temporal Dead Zone(TDZ)
Execution Phase(執行階段)
Creation Phase 完成後,引擎才開始逐行執行程式碼,做指派、運算、呼叫函式等動作。
回頭解釋開頭的「謎題」
回到本文一開始那段程式碼:
console.log(a); // undefined
console.log(foo); // ƒ foo() { ... }
console.log(b); // ReferenceError
var a = 1;
function foo() { console.log('hello'); }
let b = 2;
現在你可以看清楚發生了什麼:
| 變數 | Creation Phase | Execution Phase 第一次存取的結果 |
|---|---|---|
a (var) | 登記為 undefined | 印出 undefined |
foo (函式宣告) | 整個函式體載入 | 印出函式 |
b (let) | 登記但未初始化(TDZ) | ReferenceError |
所謂 Hoisting(提升)——並不是程式碼真的被搬到上面,而是 Creation Phase 已經把宣告全部「登記」完了。Execution Phase 看到的,是早已布置好的舞台。
這也解釋了為什麼函式可以「倒著呼叫」:函式宣告在 Creation Phase 就已經完整載入,第幾行寫的根本不重要。
var、let、const 的行為差異
理解 Creation Phase 之後,var 和 let / const 的差異也就一目了然了。
var:函式作用域 + 可重複宣告 + 預設 undefined
var 用的是函式作用域(function-level scope),意思是:把 var 包在 if、for、while 的大括號裡,並不會讓它變成區域變數。
if (true) {
var myName = "John";
}
console.log(myName); // "John" — 居然看得到!
這段程式碼等同於:
var myName; // 因為 hoisting,宣告被提升到最頂端
if (true) {
myName = "John"; // 賦值留在原位
}
console.log(myName); // "John"
要讓 var 變成區域變數,必須用函式包起來——這也是為什麼古時候會有 IIFE(立即執行函式)這種寫法:
(function() {
var myName = "John";
console.log(myName); // "John"
})();
console.log(myName); // ReferenceError
let / const:區塊作用域 + 不可重複宣告 + TDZ
ES6 的 let 和 const 改用區塊作用域(block-level scope)——大括號就是邊界:
{
let myName = "John";
}
console.log(myName); // ReferenceError
而 TDZ 機制讓「先用後宣告」直接拋錯,避免了 var 那種容易讓人誤會的 undefined 行為。
經典陷阱:for 迴圈裡的 setTimeout
for (var i = 0; i < 3; ++i) {
setTimeout(() => console.log(i), i * 1000);
}
// 輸出:3, 3, 3
為什麼三次都印 3?因為 var i 被提升到全域,三個 setTimeout 共用同一個 i。當 callback 執行時,迴圈早就跑完了,i 已經是 3。
換成 let 就解決了——每一輪迴圈都會建立一個新的區塊作用域,每個 callback 都捕捉到各自那輪的 i:
for (let i = 0; i < 3; ++i) {
setTimeout(() => console.log(i), i * 1000);
}
// 輸出:0, 1, 2
Execution Context Stack:誰先誰後上場?
到目前為止我們只談了單一個 EC。但實際程式中函式會互相呼叫,引擎怎麼管理「現在在執行哪一段」?
JavaScript 是單執行緒語言,同一時間只能執行一段程式碼。引擎用一個 Stack(後進先出) 來管理所有正在運作的 EC,這個 stack 就叫 Execution Context Stack,俗稱 Call Stack。
規則只有兩條:
- 程式啟動時,GEC 被 push 進 stack
- 每次呼叫函式 → push FEC;函式 return → pop FEC
Stack 頂端的 EC,就是目前正在執行的程式碼。
範例:追蹤 Stack 的演化
function second() {
console.log('in second');
}
function first() {
second();
console.log('in first');
}
first();
console.log('in global');
對應到 Execution Context Stack 的演化:
逐步拆解:
- 程式啟動:建立 GEC 並 push 進 stack。Hoisting 把
first、second登記到 GEC。 - 執行
first():建立first的 FEC 並 push。stack 頂端從 GEC 換成firstFEC。 first內呼叫second():建立second的 FEC 並 push。印出in second。second結束:secondFEC 被 pop。控制權回到firstFEC,印出in first。first結束:firstFEC 被 pop。控制權回到 GEC,印出in global。
最終輸出:
in second
in first
in global
觀察重點:
second雖然是後呼叫的,卻先印出來。這完全符合 stack「後進先出」的特性——後 push 的先被執行完、先 pop。
遞迴與 Stack Overflow
每個 EC 都會佔用記憶體。如果函式無限遞迴:
function recurse() {
recurse();
}
recurse();
// Uncaught RangeError: Maximum call stack size exceeded
這個錯誤訊息你一定看過——它的本質就是 Execution Context Stack 被疊爆了。V8 引擎的 stack 大約能疊幾萬層深,依環境而異。
修法不是「加大 stack」,而是改寫成迭代版本:
function loop() {
while (true) {
// 做點事,不疊 EC
}
}
補充:Tail Call Optimization(TCO)理論上可以讓尾遞迴不疊 stack,但 V8 至今並未正式啟用。
為什麼這些觀念重要?
把 Hoisting、Execution Context、Execution Context Stack 串起來看,許多原本「各自獨立」的 JavaScript 主題會在腦中拼成同一張地圖:
- Hoisting:不是程式碼被搬上去,而是 Creation Phase 先登記宣告
- Scope / Closure:函式 EC 結束被 pop,但其 Lexical Environment 仍被內層函式參照而存活
this跑掉了:this在 EC 建立時決定,與「呼叫方式」綁定,而非定義位置- 非同步行為:Call Stack 必須清空後,Event Loop 才會把佇列裡的 callback 推上 stack——這是另一個故事
結語
Hoisting 是 JavaScript 引擎丟給開發者的「結果」,Execution Context 是引擎內部的「執行單位」,Execution Context Stack 則是它們輪流上場的「舞台排程系統」。
這三層連起來看:
引擎在進入一段程式碼時建立 EC(Creation Phase 完成 Hoisting) → 把 EC push 進 Stack → 開始 Execution Phase → 函式呼叫就 push 新的 EC → 執行完就 pop。
下次你再看到 Maximum call stack size exceeded,或被 var 的怪行為絆倒,你不會只覺得是「JavaScript 又在搞」——你會直接想像引擎內部那座一層層疊起的 stack,以及 Creation Phase 在背後默默做完的登記工作。
這就是理解語言內部機制的價值。