JavaScriptTC39PromiseAsyncFrontend Architecture

Promise.withResolvers 實戰:語意邊界、正確範例與導入策略

用前端工程視角拆解 Promise.withResolvers 的真正價值:語法便利、事件橋接、queue handoff 與 Baseline 2024 相容性邊界。

· 5 分鐘閱讀

這篇會幫你把 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>();

語意上要記住三件事:

  1. Promise constructor 的 executor 仍是同步執行語意;withResolvers 不是在發明新的排程模型。
  2. resolve/reject 只決定這個 promise 的 settle 結果,不會自動幫你處理 timeout、cancel、cleanup。
  3. 價值在於「把 resolver 與 promise 放在同一層 scope」,不是效能捷徑。

正確實戰範例

範例 1:DOM 事件 bridge(成功/失敗分流要正確)

下面是可直接用在前端的圖片載入 bridge。重點是:loadresolveerrorreject,並在任一分支完成後確實清理 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 保留。

導入邊界與相容性

何時適合用

  1. 事件驅動流程(event bus、WebSocket、Worker)需要把 settle 控制權留在 listener。
  2. queue / stream handoff,需要在未來某個 callback 才決定結果。
  3. 測試中等待一次性 side effect,且需要 timeout 與 cleanup。

何時不建議用

  1. 能直接 return fetch(...)return someAsyncFn() 的地方,硬包 withResolvers 只是增加層次。
  2. resolve/reject 暴露到太大範圍(例如掛 global store)會讓狀態來源失控。
  3. 沒有 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-jses-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 的基本語意。

實務上最穩的導入路線是:

  1. 先鎖定事件 bridge、queue handoff 這些高價值場景。
  2. 每個 deferred 都要有 timeout/cleanup/close 策略。
  3. 以 capability check 控制相容性,不寫無條件支援敘述。

這樣你得到的是可維護的 async 邊界,而不是另一種更隱晦的 callback 地獄。