JavaScript:從事件迴圈到手寫 Promise

完整教學體驗請參閱:[JavaScript:從事件迴圈到手寫 Promise](https://link.juejin.cn?target=https%3A%2F%2Fmcell.top%2Ftutorials%2Fjs-event-loop-to-promise)

JS 是單執行緒 → 必須有非同步 → 非同步靠事件迴圈落地 → 事件迴圈裡有微任務這種「插隊任務」→ 微任務催生了 Promise → Promise 的形狀由幾條不可妥協的約束逼出來 → 我們把這些約束翻譯成程式碼。

第一章 · 單執行緒與事件迴圈

為什麼 JS 是單執行緒?

JS 一開始的目標只是給瀏覽器寫「小動作」——表單驗證、顯示彈窗、操作 DOM 節點。Brendan Eich 在 1995 年用十天設計這門語言時,做了一個影響深遠的決定:所有 JS 程式碼都跑在同一個執行緒上

核心原因是 DOM 不是執行緒安全的。如果兩條 JS 執行緒同時改一個 DOM 節點(一个刪一个加),瀏覽器引擎得在每次存取節點時上鎖,效能和實作複雜度都吃不消。「單執行緒」等於把這種競態從語言層面直接消滅。

後來出現的 Web Worker、SharedArrayBuffer、Service Worker 看起來像「多執行緒」,但它們都遵守同一條原則:Worker 不能直接存取主執行緒的 DOM,要通訊只能 postMessage 把資料「搬過去」。本質上是隔離的多個單執行緒世界,而不是真正的共享記憶體多執行緒。

單執行緒的代價

只有一個主執行緒意味著:所有事情都得排隊走這一條線

代價不只是「頁面卡」。具體說有三層:

  1. 任意一段長任務會阻塞所有互動——點擊、捲動、動畫、網路回呼全得等。
  2. 瀏覽器一幀只有 ~16.7ms(60Hz 螢幕)。一旦你的 JS 跑超過這個預算,掉幀就發生了。
  3. CPU 密集型工作沒法在主執行緒做——加密、壓縮、大型資料處理都會讓頁面「假死」。

這段程式碼做了什麼

js 體驗AI代碼助手 程式碼解讀複製程式碼console.log('start')

const start = Date.now()
while (Date.now() - start < 3000) {}

console.log('end after blocking')

左邊的程式碼用一個 while 迴圈純粹忙等 3 秒。這 3 秒裡,主執行緒被這個 while 死死占住——任何計時器、任何點擊事件、任何渲染都得等它結束。

記住這個事實:JS 單執行緒的「死」,不是某個 API 設計得不好,而是物理事實。要繞過它,唯一的辦法就是——別在主執行緒上等。

非同步:把「等待」交出去

上一步程式碼的問題是:主執行緒親自在等。這一步程式碼做了一件根本上不一樣的事——它把「等 3 秒」這件事交給了宿主(瀏覽器或 Node),自己立刻返回。

這就是 JS 非同步執行的三件套心智模型:

  • call stack(呼叫堆疊):同步程式碼在這裡跑。堆疊一空,當前任務就算結束。
  • host APIs(宿主 API)setTimeoutfetch、檔案 IO、DOM 事件……這些「會等」的能力不屬於 JS 引擎,而是瀏覽器/Node 提供的。引擎只管把「任務 + 回呼」丟給它們。
  • task queue(任務佇列):宿主完成等待後,把回呼推進佇列。等主執行緒閒下來,事件迴圈再把它取出來執行。
js 體驗AI代碼助手 程式碼解讀複製程式碼console.log('start')

setTimeout(() => {
  console.log('after 3000ms')
}, 3000)

console.log('end')

setTimeout(cb, 3000) 在執行那一刻沒有讓執行緒睡覺。它做的是:

  1. JS 引擎把 cb 和 3000ms 這條資訊交給宿主。
  2. 宿主用自己的計時器機制(不在 JS 執行緒上)數 3 秒。
  3. 數到 3 秒後,宿主把 cb 推到任務佇列裡。
  4. 主執行緒跑完所有同步程式碼,事件迴圈從佇列裡取出 cb,執行。

所以輸出順序是:start → end → (3s 後) after 3000msend 出現在 setTimeout 之前不是因為它「插隊」,而是因為 setTimeout 的回呼根本沒在當前呼叫堆疊裡跑

一個常見誤解

很多教學把事件迴圈畫成一個「輪詢計時器的輪子」。這是錯的。

事件迴圈的工作不是「看時間到了沒」,而是「當前呼叫堆疊空了之後,從佇列裡取下一個任務」。它是個節拍器,不是個計時器。計時是宿主的事。

輸出順序的反直覺

js 體驗AI代碼助手 程式碼解讀複製程式碼console.log('1')

setTimeout(() => console.log('2'), 0)

Promise.resolve().then(() => console.log('3'))

console.log('4')

把這段程式碼丟給十個寫過 JS 的人,會有人答 1, 2, 3, 4,有人答 1, 4, 2, 3。正確答案是 1, 4, 3, 2

Promise.resolve().then(...) 看起來「立刻就 resolve 了」,但 then 註冊的回呼比 setTimeout(cb, 0) 跑得還早。這只能用一個事實解釋:任務佇列不只一條

引擎裡有兩條不同性質的佇列:

  • 宏任務佇列(macrotask queue):放 setTimeoutsetInterval、I/O、UI 事件等。
  • 微任務佇列(microtask queue):放 Promise.thenqueueMicrotaskMutationObserver 等。

對,現在你只需要先記住這兩條名字。下一步會給出它們之間的精確規則——但有了「兩條佇列」這個事實,已經能機械推出本步的輸出:

  1. 同步程式碼先跑完 → 印出 1, 4
  2. 同步程式碼結束這一刻,引擎做一次「清空微任務佇列」的動作 → 印出 3
  3. 微任務清空後,事件迴圈才取下一個宏任務 → 印出 2

如果你之前一直覺得 Promise 的執行時機是「玄學」,原因往往就是沒意識到佇列不只一條。

js 體驗AI代碼助手 程式碼解讀複製程式碼setTimeout(() => console.log('macro'), 0)

Promise.resolve().then(() => console.log('micro'))

第二章 · 宏任務與微任務

兩條不可妥協的規則

宏任務和微任務的全部關係,只用兩條規則就能講清楚:

  1. 一次只取一個宏任務執行。
  2. 每個宏任務跑完之後,立刻把目前微任務佇列全部清空,才允許去取下一個宏任務。

這兩條規則解釋了所有「輸出順序題」。本步程式碼給出最樸素的對照:同一時刻丟進去的 setTimeout(cb, 0)(宏任務)和 Promise.resolve().then(cb)(微任務),永遠是微任務先跑。

把微任務當成「插隊任務」

理解微任務最好的隱喻是插隊

當前這一輪宏任務結束、還沒輪到下一個宏任務之間,存在一個「窗口期」。微任務就是塞進這個窗口裡執行的。

所以微任務有兩個特性:

  • 優先級高於任意宏任務——再急的 setTimeout(cb, 0) 也排在 then 之後。
  • 可以連環觸發——微任務執行過程中再註冊的微任務,會被納入這次清空,而不是等下一輪。這意味著寫一個無限遞迴註冊微任務的程式碼,會讓事件迴圈永遠卡在微任務清空階段,連渲染都做不了——這是一個真實存在的反模式。

主腳本本身就是一個宏任務

這是初學者最容易漏掉的關鍵事實:

整段頂層 <script> 程式碼(或 Node 的入口模組)本身,被引擎當作一個宏任務來執行。

所以「同步程式碼先跑完,再清空微任務」這個觀察,其實就是規則 2 的特例——主腳本是目前正在執行的宏任務,它結束之前註冊的所有 then 都排在它的微任務尾巴上,主腳本一結束就被立刻清空。

抓住「主腳本是宏任務」,下一步那道綜合輸出題就能機械推導出來。

綜合輸出題:機械推導

js 體驗AI代碼助手 程式碼解讀複製程式碼console.log('A')

setTimeout(() => {
  console.log('B')
  Promise.resolve().then(() => console.log('C'))
}, 0)

Promise.resolve().then(() => {
  console.log('D')
  setTimeout(() => console.log('E'), 0)
})

queueMicrotask(() => console.log('F'))

console.log('G')

我們現在有了兩條規則 +「主腳本是宏任務」這個事實,就可以一步一步硬推出 A, G, D, F, B, C, E

第 0 階段(開始執行主腳本,本身就是一個宏任務)

  • 同步印出 A
  • 註冊一個 timer:把 B-and-then-C 這個回呼掛到宿主的計時器上。
  • 註冊一個微任務:then → 印出 D 並註冊 timer(E)
  • 註冊一個微任務:queueMicrotask → 印出 F
  • 同步印出 G

主腳本結束這一刻,狀態是:

  • 微任務佇列:[then→D, queueMicrotask→F](按註冊順序)
  • 宏任務佇列:[timer→B]

第 1 階段(主腳本這個宏任務結束 → 清空微任務)

  • 取出 then→D:印出 D。在它內部又同步執行 setTimeout(E) → 把 E 排進宏任務佇列 → 現在宏任務佇列變成 [timer→B, timer→E]
  • 取出 queueMicrotask→F:印出 F
  • 微任務佇列空了。

第 2 階段(取下一個宏任務)

  • 取出 timer→B:印出 B。它內部 Promise.resolve().then(C) → 把 then→C 推進微任務佇列。
  • 這個宏任務結束 → 清空微任務 → 印出 C

第 3 階段(再取下一個宏任務)

  • 取出 timer→E:印出 E

最終輸出:A, G, D, F, B, C, E

拿這套機械流程去解任何題

你會發現「輸出順序題」做完之後,沒有任何一步是靠「感覺」或「經驗」。只要嚴格按:

同步跑完 → 清空微任務 → 取一個宏任務 → 同步跑完 → 清空微任務 → …

去推,就一定對。這套流程看起來囉嗦,但它就是 V8 / SpiderMonkey 等引擎裡 Event Loop 的真實工作方式

Node 的兩個額外角色

瀏覽器和 Node 共享「宏任務 + 微任務」的雙佇列模型,但 Node 在外層套了一個 libuv 事件迴圈,多出兩個 API:process.nextTicksetImmediate

不必背 libuv 那六個階段(timers / pending / poll / check / close 等),只需要記住三層優先級:

層級代表 API何時被清空nextTick 佇列process.nextTick每個階段切換之間,比微任務更優先微任務佇列Promise.thenqueueMicrotask每個階段切換之間宏任務(按階段分)setTimeout / setImmediate / I/O 等libuv 當前階段輪到時```
js 體驗AI代碼助手 程式碼解讀複製程式碼setImmediate(() => console.log('setImmediate'))

setTimeout(() => console.log('setTimeout 0'), 0)

Promise.resolve().then(() => console.log('promise.then'))

process.nextTick(() => console.log('nextTick'))

console.log('sync')


所以本步的輸出順序大致是:

arduino 體驗AI代碼助手 程式碼解讀複製程式碼sync ← 主腳本(同步)
nextTick ← 比 then 更急的「獨立佇列」
promise.then ← 一般微任務
setTimeout 0 ← timers 階段
setImmediate ← check 階段


### 瀏覽器的渲染時機

事件迴圈不只跑你的 JS,**它還要插入渲染**。簡化版的瀏覽器一幀大致是:

體驗AI代碼助手 程式碼解讀複製程式碼取宏任務 → 清空微任務 → requestAnimationFrame 回呼 → 樣式 / 版面配置 / 繪製 → 進入下一幀


這就解釋了幾個常見現象:

- 大量微任務迴圈註冊會讓瀏覽器**永遠渲染不到**——它卡在「清空微任務」這一步出不來。
- `requestAnimationFrame` 比 `setTimeout(cb, 16)` 更準——前者跟著幀節奏走,後者只是計時。
- 在 `then` 裡改 DOM 通常很快就能看到——因為微任務清空後緊接著就是渲染。

事件迴圈這條線索到這裡告一段落。我們接下來要切換視角——從「執行環境怎麼調度非同步」切到「應用層怎麼寫出可維護的非同步」,這正是 Promise 出場的地方。

第三章 · 從回呼到 Promise 的動機
----------------------

### 三個具體痛點

js 體驗AI代碼助手 程式碼解讀複製程式碼function getUser(id, cb) {
setTimeout(() => cb(null, { id, name: 'mcell' }), 100)
}
function getOrders(userId, cb) {
setTimeout(() => cb(null, [{ id: 'o1' }]), 100)
}
function getDetail(orderId, cb) {
setTimeout(() => cb(null, { id: orderId, total: 99 }), 100)
}

getUser('u1', (err, user) => {
if (err) return console.error(err)
getOrders(user.id, (err, orders) => {
if (err) return console.error(err)
getDetail(orders[0].id, (err, detail) => {
if (err) return console.error(err)
console.log(detail)
})
})
})


每個寫過 Node 早期程式碼的人都見過左邊這種結構。它的問題被簡稱為「回呼地獄」,但**真正的問題不是巢狀醜**——那只是表象。痛點其實有三個,每一個都很具體:

**1. 結構和業務無關**

左邊程式碼的縮排有 3 層,僅僅是因為我們做了 3 次非同步呼叫。如果改成 6 次,縮排就有 6 層。**結構由 API 形態決定,而不是由業務複雜度決定**——這違反了「程式碼應該反映問題,而不是反映工具」的基本美感。

**2. 錯誤處理無法重用**

注意每一層都重複寫了 `if (err) return console.error(err)`。這不只是難看,還會**真的出 bug**——業務複雜之後,很容易某一層忘了檢查 err,錯誤就被靜默吞了。Node 的「error-first callback」約定本身就是個補丁,它沒有從根本上解決錯誤傳遞。

**3. 非同步函式沒有「返回值」**

`getUser` 的「結果」沒法被賦值給一個變數,因為它要非同步才知道結果。同步程式碼可以寫:

js 體驗AI代碼助手 程式碼解讀複製程式碼const user = getUser('u1')
const orders = getOrders(user.id)


非同步程式碼無論多努力,都沒法直接複刻這種寫法——除非有一個「還沒拿到結果但代表未來值」的物件。

### 我們到底需要一個什麼樣的物件?

把上面三個痛點反著看,需求就清楚了。我們需要一個物件,它:

- **代表「未來某個時刻才會有的值」**——可以現在就被傳遞、儲存、返回。
- **支援組合**——兩個這種物件可以串起來,得到第三個。
- **錯誤能在末端統一處理**——而不是每一層都寫 `if (err)`。
- **能向鏈路上下游傳遞例外**——同步程式碼裡的 `try/catch` 可以跨層捕獲,這個物件也應該能。

滿足這四點的物件就是 Promise。它不是憑空設計出來的,而是被這四個需求逼出來的。

### Promise 是一台一次性狀態機

Promise 的全部本質,可以畫成左邊程式碼那種小圖:**三態、單向、一次性**。

- `pending`:初始態。可以轉向 `fulfilled` 或 `rejected`,但只能轉一次。
- `fulfilled`:成功態。會帶一個值(`value`)。
- `rejected`:失敗態。會帶一個原因(`reason`)。

兩條不可妥協的約束:

1. **狀態不可逆**——一旦離開 pending,就再也回不去了,更不能在 fulfilled / rejected 之間跳。
2. **resolve / reject 只生效一次**——重複呼叫全部靜默忽略。

js 體驗AI代碼助手 程式碼解讀複製程式碼const p = new Promise((resolve, reject) => {
resolve(1)
resolve(2)
reject(new Error('x'))
})

p.then((v) => console.log(v))


本步程式碼做了一個驗證:`resolve(1)` 之後再 `resolve(2)` 和 `reject(...)` 都不會生效,最終 `then` 拿到的還是 `1`。

### 為什麼必須這麼嚴格?

這兩條約束看起來只是「小心翼翼」,但它們的存在讓**消費者程式碼**變得簡單。如果狀態可以反覆變,那 `then` 裡的回呼就可能被同一個 Promise 觸發多次(或者從成功翻車到失敗),消費者就得自己處理「我已經處理過一次了嗎?」這種狀態——這正是事件監聽器(`addEventListener`)的複雜度。Promise 透過單次性把這種複雜度從消費者那裡移走了。

這兩條約束,也是後面所有手寫程式碼裡 `if (state !== 'pending') return` 的來源。

接下來從 v1 到 v5,我們一行一行把這台狀態機翻譯成程式碼。

第四章 · 手寫 MyPromise
------------------

### v1 · 狀態機骨架

js 體驗AI代碼助手 程式碼解讀複製程式碼class MyPromise {
state = 'pending'
value = undefined
reason = undefined

constructor(executor) {
const resolve = (v) => {
if (this.state !== 'pending') return
this.state = 'fulfilled'
this.value = v
}
const reject = (e) => {
if (this.state !== 'pending') return
this.state = 'rejected'
this.reason = e
}
try {
executor(resolve, reject)
} catch (e) {
reject(e)
}
}

then(onFulfilled, onRejected) {
if (this.state === 'fulfilled') onFulfilled?.(this.value)
if (this.state === 'rejected') onRejected?.(this.reason)
}
}

new MyPromise((res) => res(1)).then((v) => console.log(v))


本步程式碼是手寫實作的最小骨架:一個 class,三個欄位(`state` / `value` / `reason`),`resolve` 和 `reject` 都有 `if (this.state !== 'pending') return` 守衛——這就是上一節「兩條約束」的程式碼翻譯。

注意幾個設計細節:

- `resolve` 和 `reject` 不是 `MyPromise` 的方法,而是建構函式裡的**閉包變數**。這樣外部拿到一個 `MyPromise` 實例後,**沒法**手動改它的狀態——狀態控制權牢牢被 executor 持有。
- `try { executor(...) } catch (e) { reject(e) }`——executor 同步拋錯應該被自動轉成 rejected。這條規則在原生 Promise 裡同樣存在。
- `then` 暫時只會把同步回呼**立即同步執行**——這就是 v1 的全部能力。

### v1 暴露的問題

把建構函式裡 `executor` 改成非同步觸發 resolve,例如:

js 體驗AI代碼助手 程式碼解讀複製程式碼new MyPromise((res) => setTimeout(() => res(1), 100)).then((v) =>
console.log(v),
)


`then` 註冊的那一刻,狀態還是 `pending`。v1 的 `then` 對 pending 這種情況什麼都不做——回呼被靜默丟掉了。100ms 後即使 `resolve(1)` 觸發,也沒人通知任何回呼。

修復辦法:在 pending 階段把 `then` 傳進來的回呼**先存起來**,等到 resolve / reject 真正觸發時再統一拿出來執行。這就是 v2。

### v2 · 把 pending 階段的回呼存起來

js 體驗AI代碼助手 程式碼解讀複製程式碼class MyPromise {
state = 'pending'
value = undefined
reason = undefined
onFulfilledCbs = []
onRejectedCbs = []

constructor(executor) {
const resolve = (v) => {
if (this.state !== 'pending') return
this.state = 'fulfilled'
this.value = v
this.onFulfilledCbs.forEach((cb) => cb(v))
}
const reject = (e) => {
if (this.state !== 'pending') return
this.state = 'rejected'
this.reason = e
this.onRejectedCbs.forEach((cb) => cb(e))
}
try {
executor(resolve, reject)
} catch (e) {
reject(e)
}
}

then(onFulfilled, onRejected) {
if (this.state === 'fulfilled') onFulfilled?.(this.value)
else if (this.state === 'rejected') onRejected?.(this.reason)
else {
if (onFulfilled) this.onFulfilledCbs.push(onFulfilled)
if (onRejected) this.onRejectedCbs.push(onRejected)
}
}
}

new MyPromise((res) => setTimeout(() => res(42), 50))
.then((v) => console.log(v))


v2 在兩個地方動了刀:

- 新增兩個陣列 `onFulfilledCbs` / `onRejectedCbs`,作為「等候佇列」。
- `then` 在 pending 時把回呼入隊;`resolve / reject` 觸發時遍歷佇列依序通知。

這其實就是經典的**訂閱者模式**:Promise 是發布者,每次 `then` 都是註冊一個訂閱者。

為什麼是陣列而不是單個回呼?因為同一個 Promise 可以被 `.then` 多次,比如:

js 體驗AI代碼助手 程式碼解讀複製程式碼const p = fetchData()
p.then(render)
p.then(report)
p.then(cache)


這三個 `.then` 都得拿到通知。所以佇列必須是陣列。

### v2 還有的問題

v2 已經能正確處理「executor 裡非同步 resolve」的情況了。但仔細看 `then`:當狀態已經是 `fulfilled` 時,它**同步**呼叫 `onFulfilled`。也就是說我們的 `MyPromise` 出現了一種很糟糕的「雙面性」——

- executor 裡同步 resolve 的 → `then` 同步執行回呼
- executor 裡非同步 resolve 的 → `then` 非同步執行回呼

**同一個 API、同樣的呼叫方式,行為卻隨上下文變化**。這種 API 在社群有個綽號叫 [Zalgo](https://link.juejin.cn?target=https%3A%2F%2Fblog.izs.me%2F2013%2F08%2Fdesigning-apis-for-asynchrony%2F)(「釋放邪神」),寫出來的上層邏輯會有一類極難重現的 bug——開發期間它「剛好」是非同步的所以一切正常,上線後某個分支 resolve 變同步了,就開始隨機翻車。

修法很簡單:讓 `then` 永遠非同步。

### v3 · 讓 then 永遠非同步

js 體驗AI代碼助手 程式碼解讀複製程式碼class MyPromise {
state = 'pending'
value = undefined
reason = undefined
onFulfilledCbs = []
onRejectedCbs = []

constructor(executor) {
const resolve = (v) => {
if (this.state !== 'pending') return
this.state = 'fulfilled'
this.value = v
this.onFulfilledCbs.forEach((cb) => cb())
}
const reject = (e) => {
if (this.state !== 'pending') return
this.state = 'rejected'
this.reason = e
this.onRejectedCbs.forEach((cb) => cb())
}
try {
executor(resolve, reject)
} catch (e) {
reject(e)
}
}

then(onFulfilled, onRejected) {
const runFulfilled = () =>
queueMicrotask(() => onFulfilled?.(this.value))
const runRejected = () =>
queueMicrotask(() => onRejected?.(this.reason))

if (this.state === 'fulfilled') runFulfilled()
else if (this.state === 'rejected') runRejected()
else {
  this.onFulfilledCbs.push(runFulfilled)
  this.onRejectedCbs.push(runRejected)
}

}
}

console.log('A')
new MyPromise((r) => r(1)).then((v) => console.log('then', v))
console.log('B')


v3 的改動只有一處但分量很重:在呼叫 `onFulfilled / onRejected` 之前,統統用 `queueMicrotask` 包一層。無論當前狀態是 fulfilled / rejected 還是 pending,回呼都被推遲到微任務裡去執行。

這一改之後,`MyPromise` 的執行時機和原生 `Promise` 一致了——都是微任務。看本步底部那段示例:

javascript 體驗AI代碼助手 程式碼解讀複製程式碼console.log('A')
new MyPromise((r) => r(1)).then((v) => console.log('then', v))
console.log('B')
// 輸出:A, B, then 1


即使 resolve 是同步觸發的,`then` 的回呼依然在 `B` 之後才印出,因為它被排進了目前這一輪的微任務佇列。

### 為什麼是 queueMicrotask 而不是 setTimeout?

兩個原因:

1. **語義對齊原生 Promise**:原生 `then` 就是微任務。如果我們用 `setTimeout`,`MyPromise.then` 會變成宏任務,跟原生在同一段程式碼裡混用就會出現微妙的順序差異。
2. **微任務比宏任務快得多**:`setTimeout(cb, 0)` 即使在最理想情況下也要等 4ms(HTML 規範規定的最小 clamp);`queueMicrotask` 緊接著當前任務就跑。Promise 的核心使用場景是「鏈式非同步」,這種場景裡慢哪怕幾毫秒,疊加起來都很可觀。

### v3 的隱藏收益

v3 還順手解決了一個 v4 才會用到的問題:**`then` 裡需要在閉包中引用一個還沒賦值完的 `promise2`**。把回呼推遲到微任務裡之後,等微任務真正跑起來時,`promise2` 一定已經從 `new MyPromise(...)` 表達式裡賦值出來了。這一點我們在 v5 處理 `resolvePromise` 時會再用到。

但 v3 還沒解決最關鍵的問題:`then` 沒有返回值,不能鏈式呼叫。

### v4 · 鏈式呼叫的本質

js 體驗AI代碼助手 程式碼解讀複製程式碼class MyPromise {
state = 'pending'
value = undefined
reason = undefined
fcbs = []
rcbs = []

constructor(executor) {
const resolve = (v) => {
if (this.state !== 'pending') return
this.state = 'fulfilled'
this.value = v
this.fcbs.forEach((cb) => cb())
}
const reject = (e) => {
if (this.state !== 'pending') return
this.state = 'rejected'
this.reason = e
this.rcbs.forEach((cb) => cb())
}
try { executor(resolve, reject) } catch (e) { reject(e) }
}

then(onFulfilled, onRejected) {
const fulfilled =
typeof onFulfilled === 'function' ? onFulfilled : (v) => v
const rejected =
typeof onRejected === 'function'
? onRejected
: (e) => { throw e }

const promise2 = new MyPromise((resolve, reject) => {
  const runFulfilled = () =>
    queueMicrotask(() => {
      try { resolve(fulfilled(this.value)) } catch (e) { reject(e) }
    })
  const runRejected = () =>
    queueMicrotask(() => {
      try { resolve(rejected(this.reason)) } catch (e) { reject(e) }
    })

  if (this.state === 'fulfilled') runFulfilled()
  else if (this.state === 'rejected') runRejected()
  else {
    this.fcbs.push(runFulfilled)
    this.rcbs.push(runRejected)
  }
})

return promise2

}
}

new MyPromise((r) => r(1))
.then((v) => v + 1)
.then((v) => v * 10)
.then(undefined)
.then((v) => console.log(v))


鏈式呼叫 `p.then(a).then(b)` 之所以能成立,是因為 `then` 本身**返回一個新的 Promise**——我們叫它 `promise2`——而 `promise2` 的狀態由 `a` 的執行結果決定:

- `a` 正常返回 `x` → `promise2` resolve(x)
- `a` 拋錯 → `promise2` reject(error)

所以 v4 的核心是把 `then` 的返回值改成 `new MyPromise((resolve, reject) => { ... })`,並把 `try { resolve(fulfilled(this.value)) } catch (e) { reject(e) }` 這段邏輯嵌進去。

### 值穿透 / 錯誤穿透

第 25-30 行處理了一個容易忽略的情況:`onFulfilled` 或 `onRejected` 不是函式(比如開發者直接寫 `.then(undefined, handler)` 或者只寫 `.then(handler)` 然後再 `.catch`)。

規範要求這種情況下:

- 沒有 `onFulfilled` → 用預設透傳 `(v) => v`,把當前值原樣傳給下游。
- 沒有 `onRejected` → 用預設拋出 `(e) => { throw e }`,讓下游能繼續 reject。

這就是「值穿透 / 錯誤穿透」。它讓 `.then(...).then(...).catch(handler)` 這種寫法能正確工作——錯誤能「跨過」中間沒寫錯誤處理的 `then`,一路落到末端的 `catch`。

### v4 還差最後一步

v4 已經能處理 `onFulfilled` 返回**普通值**(數字、字串、物件)的情況。但如果它返回的 `x` 本身又是一個 Promise 呢?比如:

js 體驗AI代碼助手 程式碼解讀複製程式碼fetchUser().then((u) => fetchOrders(u.id)) // 返回值是另一個 Promise


v4 會把這個 Promise 當作普通值丟進 `resolve` 裡,導致 `promise2.value === 那個 Promise 物件`。下游 `.then` 拿到的不是訂單資料,而是個 Promise。這顯然不是我們要的——下游應該等到內層 Promise 也 resolve 出真正的值之後再觸發。

這就是 `resolvePromise` 要解決的問題。

### v5 · resolvePromise · 規範 2.3 節

js 體驗AI代碼助手 程式碼解讀複製程式碼function resolvePromise(promise2, x, resolve, reject) {
if (promise2 === x) {
return reject(new TypeError('Chaining cycle detected for promise'))
}

if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let called = false
try {
const then = x.then
if (typeof then === 'function') {
then.call(
x,
(y) => {
if (called) return
called = true
resolvePromise(promise2, y, resolve, reject)
},
(e) => {
if (called) return
called = true
reject(e)
},
)
} else {
resolve(x)
}
} catch (e) {
if (called) return
called = true
reject(e)
}
return
}

resolve(x)
}


`resolvePromise` 是整個手寫過程裡最容易出錯的一段。它的工作是:拿到 `onFulfilled` 返回的 `x`,根據 `x` 的形態決定怎麼 resolve `promise2`。Promises/A+ 規範 2.3 節用了整整一頁篇幅描述它,對應到程式碼就是本步的 `resolvePromise` 函式。

它要應對四種情況:

**1. `x === promise2`(自我引用)**

`p2 = p1.then((v) => p2)` 這種寫法會讓 promise2 等自己——死迴圈。必須 reject 一個 `TypeError`,這是規範明確要求的。

**2. `x` 是另一個 Promise(包括 thenable)**

呼叫 `x.then(onFulfilled, onRejected)`,把 `x` 的最終狀態「傳染」給 `promise2`。注意是**遞迴**呼叫 `resolvePromise`——因為 `x` resolve 出來的 `y` 可能還是個 Promise。

**3. `x` 是普通物件(沒有 `.then` 或 `.then` 不是函式)**

直接當成值 resolve。

**4. `x` 是基本型別**(`null` / `undefined` / 數字 / 字串等)

直接 resolve。

### 兩個魔鬼細節

**`called` 標誌位**

第 7 行的 `let called = false` 看起來像在防禦什麼。它防禦的是這種「不規矩的 thenable」:

js 體驗AI代碼助手 程式碼解讀複製程式碼const evil = {
then(onFulfilled, onRejected) {
onFulfilled(1)
onFulfilled(2) // 重複呼叫
onRejected(new Error()) // 既 resolve 又 reject
throw new Error() // 還拋錯
},
}


第三方函式庫或使用者實作的 thenable 不一定遵守「只 settle 一次」的規則。`called` 標誌位讓我們的實作**對外嚴格遵守一次性**——無論 thenable 怎麼亂來,第一次拿到結果就鎖死。

**`const then = x.then` 這行可能拋錯**

第 9 行單獨用一個變數取出 `then`,是為了把「取屬性」的過程包在 `try` 裡。因為有些物件會用 getter 故意 throw:

js 體驗AI代碼助手 程式碼解讀複製程式碼const tricky = {
get then() {
throw new Error('boom')
},
}


如果直接寫 `if (typeof x.then === 'function')`,這個 throw 會逃出 `try/catch` 之外。規範在 2.3.3.2 明確要求「取 then 時拋錯也算 reject」,所以必須寫成「先取一次,存到變數裡,後續都用變數」。

把 v4 裡 `try { resolve(fulfilled(this.value)) }` 這一行改成:

js 體驗AI代碼助手 程式碼解讀複製程式碼try {
const x = fulfilled(this.value)
resolvePromise(promise2, x, resolve, reject)
} catch (e) {
reject(e)
}


到這裡,`MyPromise` 的核心就完成了。

js 體驗AI代碼助手 程式碼解讀複製程式碼MyPromise.all = (xs) => new MyPromise((resolve, reject) => {
const out = []
let done = 0
if (xs.length === 0) return resolve(out)
xs.forEach((p, i) => {
p.then(
(v) => {
out[i] = v
if (++done === xs.length) resolve(out)
},
reject,
)
})
})

MyPromise.race = (xs) => new MyPromise((resolve, reject) => {
xs.forEach((p) => p.then(resolve, reject))
})

MyPromise.allSettled = (xs) => new MyPromise((resolve) => {
const out = []
let done = 0
if (xs.length === 0) return resolve(out)
xs.forEach((p, i) => {
p.then(
(v) => {
out[i] = { status: 'fulfilled', value: v }
if (++done === xs.length) resolve(out)
},
(e) => {
out[i] = { status: 'rejected', reason: e }
if (++done === xs.length) resolve(out)
},
)
})
})

MyPromise.any = (xs) => new MyPromise((resolve, reject) => {
const errs = []
let failed = 0
if (xs.length === 0) {
return reject(new AggregateError([], 'All promises were rejected'))
}
xs.forEach((p, i) => {
p.then(resolve, (e) => {
errs[i] = e
if (++failed === xs.length) {
reject(new AggregateError(errs, 'All promises were rejected'))
}
})
})
})


第五章 · 靜態方法與規範驗證
---------------

### 四個常考靜態方法

`Promise.all / race / allSettled / any` 經常出現在面試裡,其實程式碼差異很小——重點是**語義差異**。

方法何時 fulfilled何時 rejected`all`全部成功 → `[v1, v2, ...]`任意一個失敗 → 立刻 reject 那個 reason`race`第一個 fulfilled 的值第一個 rejected 的 reason`allSettled`全部 settle → `[{status, value/reason}...]`永遠不會`any`任意一個成功 → 那個值全部失敗 → `AggregateError``all` 和 `any` 是鏡像關係——一個「任意失敗就 reject」、一個「任意成功就 resolve」。`race` 和 `allSettled` 處於兩個極端——`race` 搶第一個 settle 的、`allSettled` 等所有人 settle。

### 空陣列的邊界陷阱

每個靜態方法對空陣列的行為都不一樣,面試常考:

呼叫結果`Promise.all([])`resolve `[]``Promise.allSettled([])`resolve `[]``Promise.any([])`reject `AggregateError([])``Promise.race([])`**永遠 pending**(沒有任何 promise 來 settle 它)`race([])` 那條尤其陰險——程式不會報錯,也不會走任何分支,就是永遠卡住。如果你在線上看到一個「既不成功也不失敗」的鏈路,這是一個值得排查的方向。

### `any` 的 AggregateError

`any` 是 ES2021 才進規範的,配套引入了 `AggregateError`——一個能裝多個錯誤原因的特殊錯誤物件。本步程式碼裡 `new AggregateError(errs, 'All promises were rejected')` 第一個參數就是各路失敗原因的陣列,第二個參數是統一的 message。

這個設計的好處是:呼叫方可以透過 `err.errors` 拿到完整的失敗列表,決定是統一處理還是分別回報。如果只 reject 第一個失敗的 reason,資訊就丟了。

js 體驗AI代碼助手 程式碼解讀複製程式碼const adapter = {
deferred() {
let resolve
let reject
const promise = new MyPromise((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
},
resolved(value) {
return new MyPromise((resolve) => resolve(value))
},
rejected(reason) {
return new MyPromise((_, reject) => reject(reason))
},
}

export default adapter


### 用規範測試給自己打分

[`promises-aplus-tests`](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fpromises-aplus%2Fpromises-tests) 是 Promises/A+ 官方測試套件,包含 872 條用例,專門用來檢驗「是不是真的合規」。它的工作方式是:你提供一個 `adapter` 物件,暴露三個工廠函式(左邊程式碼);測試套件會用它們造出各種 Promise 來跑測試。

實際跑一遍的步驟:

1. 把 `MyPromise` 整理到一個獨立檔案,暴露 default export。
2. `pnpm add -D promises-aplus-tests`
3. 寫一個 `adapter.cjs`:
js 體驗AI代碼助手 程式碼解讀複製程式碼const MyPromise = require('./MyPromise.js').default
module.exports = {
  deferred() {
    /* 同左 */
  },
  resolved(v) {
    return new MyPromise((r) => r(v))
  },
  rejected(e) {
    return new MyPromise((_, r) => r(e))
  },
}
```
  1. 跑:npx promises-aplus-tests adapter.cjs
  2. 順利的話會看到 872 passing。如果某條 fail,套件會指明是哪一節哪一項不合規,對照規範回去補即可——v1~v5 這條主線已經覆蓋了 90% 以上的用例。

收束

回頭看,整套手寫 Promise 其實只用了兩條事實:

  1. JS 是單執行緒,非同步必須把「等待」交給宿主,回呼被排進任務佇列。
  2. 微任務是「插隊任務」——它讓 then 可以在當前輪事件迴圈結束前就被執行。

剩下所有程式碼——if (state !== 'pending') return、訂閱者陣列、queueMicrotask 包裹、promise2 鏈式、resolvePromise 的四種情況——都是在這兩條事實之上,加上「狀態不可逆」和「then 必須返回新 Promise」兩條約束逼出來的。

V8 等真實引擎的實作當然比這複雜得多——它們會用原生 job queue 取代 queueMicrotask,會用隱藏類、內聯快取等手段優化效能,也會增加 Promise.try / Promise.withResolvers 這些較新的 API。但形狀和我們手寫的這一版完全一致。

如果你能把「為什麼單執行緒 → 單執行緒的代價 → 非同步三件套 → 宏任務 vs 微任務 → 輸出順序機械推導 → Promise 狀態機 → v1 到 v5」這條因果鏈自己講一遍,那麼之後無論是面試被問到「輸出順序題」還是「手寫 Promise」,都不會再卡住。


原文出處:https://juejin.cn/post/7642645755761950771


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝20   💬11   ❤️1
586
🥈
alicec
📝1   ❤️2
83
🥉
我愛JS
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登