📋 目錄
這篇會幫你把
Promise.withResolvers()用在正確的位置,避免把它當成萬用 async 魔法。
前言
Promise.withResolvers() 已經是 ES2024 的標準功能。很多人第一眼會把它當成「比較潮的 deferred」,但在 production code 裡,重點不是語法短,而是你有沒有把控制權邊界切乾淨。
這個 API 最容易被誤解的地方有兩個:第一,把它說成能避開 new Promise(executor) 的成本;第二,把所有 callback 都硬包成 deferred,最後變成難追蹤的 pending promise。這兩件事都會讓程式更難維護。
先校正時效事實(截至 2026-04-01):Promise.withResolvers() 屬於 Stage 4 / ES2024;MDN 標記為 Baseline 2024,並寫明自 2024 年 3 月起在最新版瀏覽器可用。這代表你仍要看產品支援矩陣,不應寫成「所有環境都保證可用」。
API 與語意:它是語法便利,不是新 primitive
Promise.withResolvers() 回傳 { promise, resolve, reject },本質上等價於傳統寫法:
let resolve!: (value: string) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<string>((res, rej) => {
resolve = res;
reject = rej;
});
對應的新寫法:
const { promise, resolve, reject } = Promise.withResolvers<string>();
語意上要記住三件事:
Promiseconstructor 的 executor 仍是同步執行語意;withResolvers不是在發明新的排程模型。resolve/reject只決定這個 promise 的 settle 結果,不會自動幫你處理 timeout、cancel、cleanup。- 價值在於「把 resolver 與 promise 放在同一層 scope」,不是效能捷徑。
正確實戰範例
範例 1:DOM 事件 bridge(成功/失敗分流要正確)
下面是可直接用在前端的圖片載入 bridge。重點是:load 走 resolve,error 走 reject,並在任一分支完成後確實清理 listener。
export function loadImage(src: string): Promise<HTMLImageElement> {
const { promise, resolve, reject } = Promise.withResolvers<HTMLImageElement>();
const img = new Image();
const cleanup = () => {
img.removeEventListener("load", onLoad);
img.removeEventListener("error", onError);
};
const onLoad = () => {
cleanup();
resolve(img);
};
const onError = () => {
cleanup();
reject(new Error(`Image load failed: ${src}`));
};
img.addEventListener("load", onLoad, { once: true });
img.addEventListener("error", onError, { once: true });
img.src = src;
return promise;
}
這種寫法比 Promise.race([once(load), once(error)]) 更不容易把錯誤路徑寫反。
範例 2:單次事件等待器(測試與 UI handshake 常用)
如果你常在測試等某個 event 一次,withResolvers 可以把「訂閱事件」和「回傳 promise」的責任分開。
type EventMap = Record<string, Event>;
export function waitForEvent<T extends EventMap, K extends keyof T>(
target: EventTarget,
eventName: K,
timeoutMs = 5000,
): Promise<T[K]> {
const { promise, resolve, reject } = Promise.withResolvers<T[K]>();
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for event: ${String(eventName)}`));
}, timeoutMs);
const handler = (event: Event) => {
cleanup();
resolve(event as T[K]);
};
const cleanup = () => {
clearTimeout(timer);
target.removeEventListener(String(eventName), handler);
};
target.addEventListener(String(eventName), handler, { once: true });
return promise;
}
這比在外部維護 let done / setInterval 輪詢更可讀,也比較容易寫 deterministic test。
範例 3:前端 queue handoff(比 mock 重綁更有實務價值)
這個模式適合 WebSocket、SSE 或 Worker 訊息緩衝:消費者 await queue.shift(),生產者在事件到來時 push()。
class AsyncMessageQueue<T> {
private values: T[] = [];
private waiting: Array<{ resolve: (value: T) => void; reject: (reason?: unknown) => void }> = [];
push(value: T) {
const waiter = this.waiting.shift();
if (waiter) {
waiter.resolve(value);
return;
}
this.values.push(value);
}
shift(): Promise<T> {
const existing = this.values.shift();
if (existing !== undefined) return Promise.resolve(existing);
const { promise, resolve, reject } = Promise.withResolvers<T>();
this.waiting.push({ resolve, reject });
return promise;
}
close(reason = new Error("Queue closed")) {
// 中文註解:關閉時把所有等待中的 consumer 一次 reject,避免永遠 pending
while (this.waiting.length) {
const waiter = this.waiting.shift();
waiter?.reject(reason);
}
}
}
這類情境才是 withResolvers 的典型強項,因為 resolver 的生命週期真的需要跨 callback 保留。
導入邊界與相容性
何時適合用
- 事件驅動流程(event bus、WebSocket、Worker)需要把 settle 控制權留在 listener。
- queue / stream handoff,需要在未來某個 callback 才決定結果。
- 測試中等待一次性 side effect,且需要 timeout 與 cleanup。
何時不建議用
- 能直接
return fetch(...)或return someAsyncFn()的地方,硬包withResolvers只是增加層次。 - 把
resolve/reject暴露到太大範圍(例如掛 global store)會讓狀態來源失控。 - 沒有 close/error 策略就累積 deferred,容易造成永遠 pending 與記憶體問題。
相容性與 fallback
如果你的產品仍涵蓋較舊環境,建議採 capability check:
export const supportsPromiseWithResolvers =
typeof Promise.withResolvers === "function";
export function createDeferred<T>() {
if (supportsPromiseWithResolvers) {
return Promise.withResolvers<T>();
}
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
若需要全域 polyfill,再評估 core-js 或 es-shims,但請先確認 bundle 與目標環境需求,不要先加再說。
常見問題 / 注意事項
Promise.withResolvers() 比 new Promise 快嗎?
沒有這種保證。它主要改善的是可讀性與作用域結構,不是效能魔法。
可以把它當 cancellable promise 嗎?
不行。取消語意要自己設計,例如搭配 AbortController、timeout、close() 協定。
在 React 專案能不能大量替換?
不建議一刀切。先從事件橋接與 queue 類場景導入,避免把一般 async function 反向包成 deferred anti-pattern。
TypeScript 需要特殊設定嗎?
通常不需要額外語法設定,但你要確保 TypeScript 版本、runtime 與 CI 一致,避免「本機可用、部署炸掉」。
總結
Promise.withResolvers() 的定位很明確:它讓你在「必須延後 resolve/reject」的場景寫得更乾淨,但它沒有改變 Promise 的基本語意。
實務上最穩的導入路線是:
- 先鎖定事件 bridge、queue handoff 這些高價值場景。
- 每個 deferred 都要有 timeout/cleanup/close 策略。
- 以 capability check 控制相容性,不寫無條件支援敘述。
這樣你得到的是可維護的 async 邊界,而不是另一種更隱晦的 callback 地獄。