跳到主要內容
技術

從 Hoisting 到 Execution Context Stack:揭開 JavaScript 執行機制的面紗

為什麼 var 可以「先用後宣告」?為什麼函式可以倒著呼叫?這些 JavaScript 看似神奇的行為,背後其實都指向同一件事:Execution Context 與它的 Stack。本文從 Hoisting 表象切入,逐層深入引擎的執行機制,搭配圖解徹底釐清。

寫 JavaScript 的人,多少都遇過這些「謎之現象」:

  • var 宣告的變數,竟然可以先使用、後宣告
  • 函式可以在程式碼最頂端被呼叫,宣告寫在後面也照樣能跑
  • letvar 看起來差不多,行為卻天差地遠
  • 跑一跑突然冒出 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;

同樣是「先用後宣告」,varundefinedlet 卻直接拋錯。為什麼?

要解釋這些行為,得先進到引擎內部看看它到底做了什麼。


Execution Context:程式碼的「執行舞台」

JavaScript 程式碼從來不會「裸跑」。引擎在執行任何一段程式之前,都會先替它建立一個 Execution Context(EC,執行環境)

EC 是抽象結構,記錄了該段程式執行所需的全部環境資訊:變數宣告放在哪、this 指向誰、外層作用域怎麼接。可以把它想像成一齣戲的舞台——演員(變數、函式)、道具(this)、布景(外部作用域)都得先就緒,戲才能開演。

Execution Context 的內部結構

依照 ES2015 之後的規範,一個 Execution Context 大致包含三個部分:

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 Contexteval() 執行時不建議使用

GEC 是最底層、永遠存在的那一個。每次呼叫函式就會多一個 FEC 疊上去,函式結束就被移除。


兩個階段:Creation 與 Execution(Hoisting 的真相)

EC 不是一次建好,而是分成兩個階段運作。這兩個階段就是 Hoisting 神奇現象的根源。

Creation Phase(建立階段)

引擎進入一段程式碼後,會先掃描整段程式,做三件事:

  1. 建立 Lexical Environment 與 Variable Environment
  2. 把所有 var、函式宣告先「登記」起來
  3. 決定 this 指向

關鍵的差異在第 2 步:

  • var 宣告的變數:登記名字,初始化為 undefined
  • 函式宣告(function foo() {}整個函式體一併載入
  • let / const:登記名字,但不初始化,進入 Temporal Dead Zone(TDZ)

Creation Phase 階段示意

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 PhaseExecution Phase 第一次存取的結果
a (var)登記為 undefined印出 undefined
foo (函式宣告)整個函式體載入印出函式
b (let)登記但未初始化(TDZ)ReferenceError

所謂 Hoisting(提升)——並不是程式碼真的被搬到上面,而是 Creation Phase 已經把宣告全部「登記」完了。Execution Phase 看到的,是早已布置好的舞台。

這也解釋了為什麼函式可以「倒著呼叫」:函式宣告在 Creation Phase 就已經完整載入,第幾行寫的根本不重要。


var、let、const 的行為差異

理解 Creation Phase 之後,varlet / const 的差異也就一目了然了。

var:函式作用域 + 可重複宣告 + 預設 undefined

var 用的是函式作用域(function-level scope),意思是:把 var 包在 ifforwhile 的大括號裡,並不會讓它變成區域變數。

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 的 letconst 改用區塊作用域(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

規則只有兩條:

  1. 程式啟動時,GEC 被 push 進 stack
  2. 每次呼叫函式 → 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 的演化:

Execution Context Stack 演化

逐步拆解:

  1. 程式啟動:建立 GEC 並 push 進 stack。Hoisting 把 firstsecond 登記到 GEC。
  2. 執行 first():建立 first 的 FEC 並 push。stack 頂端從 GEC 換成 first FEC。
  3. first 內呼叫 second():建立 second 的 FEC 並 push。印出 in second
  4. second 結束second FEC 被 pop。控制權回到 first FEC,印出 in first
  5. first 結束first FEC 被 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 在背後默默做完的登記工作。

這就是理解語言內部機制的價值。