跳至主要內容
返回

JavaScript 核心概念

前端

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

同步程式碼跑在 call stack 上。Host 在 stack 之外處理工作。Event loop 協調何時可以跑佇列中的 callbacks。Closures 保留 lexical state。Promises、async/await 與 generators 暫停並恢復工作。

這篇 note 講的是 code 何時執行。class、prototype 與 this 在 TypeScript Class 與 Runtime Identity。Queues 與 5 1 3 4 2 謎題:深入理解 JavaScript Event Loop。



  • 同步程式碼跑在 stack 上。
  • Host APIs 在 stack 外做完工作並 enqueue 一個 callback。
  • 當 stack 為空,loop 排空 microtask queue,然後把 一個 task 推上 stack。

1. Event Loop

瀏覽器每次 iteration 通常會跑 一個 task、排空 所有 microtasks、也許 render,然後再取另一個 task。


ts
log.push("sync start")
setTimeout(() => log.push("macrotask"), 0)
Promise.resolve().then(() => log.push("microtask"))
log.push("sync end")
// ["sync start", "sync end", "microtask", "macrotask"]

  • JavaScript 不會中斷目前在 stack 上的程式碼。
  • Timer 可能在背景結束;它的 callback 要等當前 task 及其 microtasks。
  • Failure: 零毫秒的 timer 仍然不是立刻執行。Delay 只是 timer task 的下限。

2. Call Stack

Call stack 是 engine 目前正在執行什麼的 last-in, first-out 記錄。呼叫 function 會 push 一個 frame;return 或 throw 會把它 pop 掉。只有最頂端的 frame 在跑。


ts
function factorialRecursive(n: number): number {
  if (n <= 1) return 1
  return n * factorialRecursive(n - 1)
}

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

  • 深層 recursion 每次 call 消耗一個 frame,直到 engine 撞上上限。
  • ES2015 規定了 proper tail calls;主流 engines 裡只有 JavaScriptCore 實作了。Recursion depth 仍是實務上限。
  • Failure: 緊密的 loop 並不分享 thread。它佔有 thread —— 在它 return 之前沒有 paint、clicks 或 timers。

3. Macrotask Queue

Macrotask 是非正式說法;HTML spec 通常說 tasks。Timers、用戶交互與 messages 會 enqueue 工作。瀏覽器可能維持多條 queues 並 選出 一個可跑的 task,所以「一條全域 FIFO」只是漫畫。

  • Loop 跑一個被選出的 task,然後做一次 microtask checkpoint,再跑另一個 task。
  • 嵌套的 setTimeout 調度的是 未來 的工作。典型順序:timeout-1 → timeout-3 → timeout-2。
  • Failure: 把 setTimeout(fn, 0) 當成「下一行之前」。它的意思是「一旦允許跑 timer task」。

4. Microtask Queue

Microtasks —— Promise handlers、queueMicrotask、MutationObserver —— 是 同一條 thread 上優先級更高的 queue。一個 task 結束且 stack 為空之後,runtime 會把它們排空,直到 queue 空掉。

  • 一個 microtask 再 queue 另一個 microtask,加進的是 同一次 checkpoint。典型順序:micro-1 → promise → micro-2 → macrotask。
  • 這就是為什麼 promise handler 會在已經就緒的 setTimeout 之前跑。
  • Failure: 無界的 microtask chain 會無限推遲 timers、input 與 rendering —— 沒有 while 的凍結。

5. Execution Context

Execution context 是 JavaScript 進入 global code 或呼叫 function 時建立的環境:bindings、outer lexical environment,以及 this。


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

obj.regular() // "object" — arrow captures the method's `this`

  • 每次 call 都有自己的 context 與 stack frame。
  • Function return 後 frame 被拿掉,但如果回傳的 function close over 了它,lexical environment 仍可觸達。
  • Arrow functions 不建立自己的 this;它們從周圍的 context 捕獲。

6. Closures

Closure 不是一種特殊的 function。Function 帶著它被建立時的 lexical environment 的存取權,即使外層 function 已經 return。


ts
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.value() // 11 — `count` is private to the closure

  • Private state、event handlers,以及許多 hook patterns 都是 closures。
  • 被捕獲的 objects 仍可觸達。Callback close over 一棵大 tree,就可以把它 leak 住。
  • Failure: 在 loop 裡用 var 建立的 callbacks 共用一個 function-scoped binding。let 每次 iteration 建立一個新 binding。Closure 是活的 environment,不是值的副本。

7. Promises

Promise 是 pending,然後變成 fulfilled 或 rejected。呼叫 .then、.catch 或 .finally 並不會立刻跑 handler;settled 的 promise 把它排成 microtask。


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

const user = delay({ name: "Ada" }, 100)
const settings = delay({ theme: "dark" }, 50)

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

  • Promise.all 快速失敗。Promise.allSettled 記錄每一個 outcome。Promise.race 隨第一個結果 settle。
  • 在 await 它們之前先啟動彼此獨立的 promises,讓底層工作重疊。
  • Failure: 把 timer 包進 Promise 並不會讓 timer 變成 microtask。setTimeout 仍然調度一個 task;.then 作為 microtask 跑在那個 task 之後。

8. Async/Await

async/await 是 promises 上的語法,不是第二套 concurrency 系統。async function 永遠回傳一個 promise。碰到 await 就 yield;promise settle 之後,其餘部分作為 microtask 繼續。


ts
async function loadDashboard(userId: string) {
  const userPromise = getJson<User>(`/api/users/${userId}`)
  const settingsPromise = getJson<Settings>("/api/settings")
  const [user, settings] = await Promise.all([userPromise, settingsPromise])
  return { user, settings }
}

  • await 不會擋住 JavaScript thread,也不會讓 JavaScript 變成多執行緒。它暫停的是 那個 async function。連 await Promise.resolve() 都會 yield。
  • response.json() as T assertion 描述期望的形狀;它並不驗證。
  • Failure: 對彼此獨立的 fetches 做順序 await 就是 waterfall。一起啟動,再 Promise.all。

9. Generators

Generator(function*)被呼叫時並不跑 body。它回傳一個 iterator。每次 .next() 恢復執行,直到下一個 yield 或 function return。


ts
function* range(start: number, end: number): Generator<number> {
  for (let i = start; i < end; i++) yield i
}

const values = range(0, 3) // body has not run
values.next() // { value: 0, done: false }
values.next() // { value: 1, done: false }
// 2 is not produced unless another value is requested.

  • 值只在被請求時才產生 —— lazy sequence,不是一次性的 eventual result。
  • Async generator 讓 .next() 回傳 promise;for await...of 等待每一個結果。這適合 paginated responses 與 streams。
  • Failure: 在非 async generator 裡把 yield 當成 await。Promises 協調一個 eventual value。Generators 協調一連串 pauses。

重點

Call stack 與 execution contexts 解釋 目前 在跑什麼。Task queues、microtasks 與 event loop 解釋延後的工作 何時 可以跑。Closures 解釋為什麼 state 仍可觸達。Promises、async/await 與 generators 組織會暫停與恢復的工作。

當 async 程式碼出乎意料時:目前 stack 上是什麼?什麼被排成 microtask?什麼必須等下一個 task?


Recap Q&A