跳至主要內容
返回

JavaScript 核心概念

Call stack、event loop、task queues、closures、promises 與 generators 如何組成同一個 JavaScript runtime 模型

可以每天使用 promises 與 async/await,卻仍然不清楚 runtime 底下在做什麼。這些筆記由內而外重溫 JavaScript 的 execution model。

這些概念構成一個系統:同步程式碼在 call stack 上執行,host environment 處理 stack 之外的工作,而 event loop 協調何時可以跑佇列中的 callbacks。Closures 解釋 functions 如何保留 state;promises、async/await 與 generators 則提供不同方式來暫停與恢復工作。


瀏覽器 event loop 一覽:同步程式碼在 call stack 上執行,host APIs 在背景完成工作並 enqueue tasks,而 promise reactions 進入優先級更高的 microtask queue。每當 stack 清空,event loop 就把下一個 callback 推進去——先 microtasks,再一次一個 task。


1. Event Loop

常見的第一印象是:event loop 只是檢查非同步工作有沒有做完。更有用的模型是:瀏覽器每次 event-loop iteration 通常會跑 一個 task、排空 所有 microtasks、給瀏覽器一次 render 機會,然後再進入另一個 task。


重要後果是:JavaScript 不會中斷目前在 stack 上的程式碼。Timer 可能在背景結束,但它的 callback 必須等到目前這個 task 及其 microtasks 做完。這就是為什麼零毫秒的 timer 仍然不會立刻執行。


const log: string[] = []

log.push("sync start")

setTimeout(() => {
  log.push("macrotask")
}, 0)

Promise.resolve().then(() => {
  log.push("microtask")
})

log.push("sync end")

// After the timer runs:
// ["sync start", "sync end", "microtask", "macrotask"]


2. Call Stack

Call stack 是 JavaScript 對「目前正在做什麼」的紀錄。它是後進先出結構:呼叫 function 會 push 一個 frame,return 或 throw 則移除該 frame。只有最上方的 frame 可以執行。


這解釋了為什麼深度遞迴即使演算法邏輯正確仍可能失敗。每一次 recursive call 都會再消耗一個 stack frame,直到 engine 碰到上限。對深度無界的輸入,iteration 或其他常數 stack 技巧更安全。ES2015 規範了 proper tail calls,可讓某些遞迴形狀變成常數空間,但主流 engines 裡只有 JavaScriptCore 實作了——recursion depth 仍是實務限制。


function factorialRecursive(n: number): number {
  if (!Number.isInteger(n) || n < 0) {
    throw new RangeError("n must be a non-negative integer")
  }
  if (n <= 1) return 1
  return n * factorialRecursive(n - 1)
}

function factorialIterative(n: number): number {
  if (!Number.isInteger(n) || n < 0) {
    throw new RangeError("n must be a non-negative integer")
  }

  let result = 1
  for (let i = 2; i <= n; i++) {
    result *= i
  }
  return result
}

// Same result; iterative keeps O(1) stack frames
factorialRecursive(5) // 120
factorialIterative(5) // 120


3. Macrotask Queue

Macrotask 是常見術語,而 HTML specification 通常只稱它們為 tasks。Timers、用戶互動與 messages 可以把工作放進 task queues。瀏覽器可能維護多條 queues,並選擇下一個可跑的 task,所以「單一全域 FIFO queue」只是簡化的 mental model。


要記住的規則是:event loop 跑一個選中的 task,然後在選下一個 task 之前做 microtask checkpoint。因此巢狀的 setTimeout 只是排程未來的工作;它不能在父級目前這一輪裡繼續跑。


const order: string[] = []

setTimeout(() => {
  order.push("timeout-1")
  setTimeout(() => {
    order.push("timeout-2")
  }, 0)
}, 0)

setTimeout(() => {
  order.push("timeout-3")
}, 0)

// Typical order: timeout-1 → timeout-3 → timeout-2


4. Microtask Queue

Microtasks 是理解非同步 JavaScript 時最常被漏掉的一塊。Promise handlers、queueMicrotaskMutationObserver callbacks 使用這條優先級更高的 queue。當一個 task 結束且 stack 為空後,runtime 會排空 microtasks,直到 queue 清空


這解釋了為什麼 promise handlers 會在已就緒的 setTimeout callback 之前執行。它也帶有風險:一個 microtask 可以再 enqueue 另一個 microtask,所以無界鏈可能無限期延遲 timers、用戶輸入與 rendering。


const order: string[] = []

setTimeout(() => {
  order.push("macrotask")
}, 0)

queueMicrotask(() => {
  order.push("micro-1")
  queueMicrotask(() => {
    order.push("micro-2")
  })
})

Promise.resolve().then(() => {
  order.push("promise")
})

// Final order after the timeout runs:
// micro-1 → promise → micro-2 → macrotask


5. Execution Context

Execution context 把 call stack 與 scope 連起來。當 JavaScript 進入 global code 或呼叫 function 時,它會建立執行該程式碼所需的環境:bindings、outer lexical environment,以及 this value。


每次 function call 都有自己的 context 與 stack frame。Function return 後 frame 會被移除,但若回傳的 function 對它形成 closure,其 lexical environment 仍可被觸達。Arrow functions 多了一個有用區別:它們不建立自己的 this;而是從周圍 context 捕捉它。


const obj = {
  label: "object",
  regular(this: { label: string }): string {
    const arrow = (): string => this.label
    return arrow()
  },
}

const globalLabel = "global"

function outer(prefix: string) {
  const local = "inner"

  function inner(): string {
    // Scope chain: inner → outer → global
    return `${prefix}:${local}:${globalLabel}`
  }

  return inner
}

// The regular method receives `obj` as `this`, and its arrow captures it.
obj.regular() // "object"

// The returned function retains `prefix` and `local`.
outer("ctx")() // "ctx:inner:global"


6. Closures

Closure 最好不要想成某種特殊類型的 function。Function 天然帶有對建立時 lexical environment 的存取,即使它在 outer function 已經 return 之後才被呼叫。


這讓 private state、event handlers,以及許多 hook patterns 成為可能。Closure 保留了什麼值得刻意處理:被捕捉的 objects 會保持可達;在 loop 裡用 var 建立的 callbacks 會共用同一個 function-scoped binding。let loop variable 則為每次 iteration 建立新 binding,避免那個常見陷阱。


function createCounter(start = 0) {
  let count = start

  return {
    inc(): number {
      count += 1
      return count
    },
    value(): number {
      return count
    },
  }
}

const counter = createCounter(10)
counter.inc() // 11
counter.inc() // 12
counter.value() // 12 — `count` is private to the closure


7. Promises

Promise 代表一個操作最終的結果。它從 pending 開始,然後變成 fulfilledrejected。一個容易忽略的細節是:呼叫 .then.catch.finally 並不會立刻跑 handler;已 settled 的 promise 會把該 handler 排進 microtask queue。


Promise combinators 若當成不同的協調策略,會比較好選:Promise.all 快速失敗,Promise.allSettled 記錄每個結果,Promise.race 以第一個結果 settle。在 await 之前先啟動多個 promises,可以讓底層操作重疊進行。


type User = { id: string; name: string }
type Settings = { theme: "light" | "dark" }

function delay<T>(value: T, milliseconds: number): Promise<T> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), milliseconds)
  })
}

// Both timers start immediately; neither waits for the other.
const userPromise = delay<User>({ id: "1", name: "Ada" }, 100)
const settingsPromise = delay<Settings>({ theme: "dark" }, 50)

Promise.all([userPromise, settingsPromise]).then(([user, settings]) => {
  console.log(user.name, settings.theme)
  // "Ada", "dark" — after roughly 100 ms, not 150 ms
})


8. Async/Await

async/await 是建在 promises 上的語法,而不是另一套 concurrency 系統。async function 永遠回傳 promise。當它碰到 await,該 function 會交出控制權;等 awaited promise settle 後,函式其餘部分會以 microtask 排程繼續。


await 不會阻塞 JavaScript thread,也不會讓 JavaScript 變成多執行緒。它只暫停那個 async function。彼此獨立的操作應一起啟動,而不是逐個 await。


type User = { id: string; name: string }
type Settings = { theme: "light" | "dark" }

async function getJson<T>(url: string): Promise<T> {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  // This assertion describes the expected shape; it does not validate it.
  return (await response.json()) as T
}

async function loadDashboard(userId: string) {
  // Start both independent requests before waiting.
  const userPromise = getJson<User>(`/api/users/${userId}`)
  const settingsPromise = getJson<Settings>("/api/settings")

  const [user, settings] = await Promise.all([
    userPromise,
    settingsPromise,
  ])

  return { user, settings }
}


9. Generators

Generatorfunction*)是暫停執行的另一個角度。呼叫 generator 不會立刻跑它的 body。它回傳一個 iterator,每次 .next() 會恢復執行,直到下一個 yield 或 function return。


這讓 generators 適合 lazy sequences,因為只有在被要求時才產生值。使用 async generator 時,.next() 回傳 iterator result 的 promise,而 for await...of 會等待每個結果。當值隨時間到來時——例如分頁 responses 或 streams——這個模式很有用。


function* range(start: number, end: number): Generator<number, void, unknown> {
  for (let i = start; i < end; i++) {
    console.log(`producing ${i}`)
    yield i
  }
}

const values = range(0, 3) // logs nothing: the body has not run

values.next() // logs "producing 0"; { value: 0, done: false }
values.next() // logs "producing 1"; { value: 1, done: false }
// 2 is not produced unless another value is requested.


重點


這些不是彼此孤立的 JavaScript 功能。Call stack 與 execution contexts 解釋目前在跑什麼。Task queues、microtasks 與 event loop 解釋延後的工作何時可以跑。Closures 解釋為什麼 state 仍可觸達;promises、async/await 與 generators 則提供不同方式組織會暫停與恢復的工作。

當 async 程式碼行為出乎意料時,三個問題能解決大多數情況:目前 stack 上是什麼?什麼會被排成 microtask?什麼必須等下一個 task? 這個 mental model 比背個別 event-loop 範例的輸出更有用。