每位 JavaScript 開發者至少都寫過一次:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

// Later…
eventBus.on('done', () => resolve(result));

Enter fullscreen mode Exit fullscreen mode

你需要在建構子外部觸發 Promise——可能是從事件監聽器、回呼或訊息處理常式中。因此你把 resolvereject 洩漏到外層作用域來捕獲它們。這樣可行,但每次寫的時候都覺得不太對,因為你手動逃出一個本不該被逃出的閉包。

ES2024 為此新增了正確的工具:Promise.withResolvers

Promise.withResolvers 會回傳什麼

與其建構一個 Promise 再逃出它的回呼,不如呼叫一個靜態方法,一次就把三個東西都交給你:

const { promise, resolve, reject } = Promise.withResolvers();

Enter fullscreen mode Exit fullscreen mode

promise 是一個普通的 Promise。resolvereject 是它的解決函式,已經放在你的作用域中。不需要再逃出閉包。原本需要四行的模式,現在只需一行。

舊模式與新模式的行為完全相同——相同的微任務時機、相同的錯誤傳遞、相同的 .then/.catch 介面。差別在於現在意圖變得明確:你是有意建立一個 deferred Promise,而不是把它當作權宜之計。

事件轉 Promise 的橋樑

最清楚的使用情境是把事件型 API 包裝成 Promise 介面。之前:

function waitForOpen(socket) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  socket.addEventListener('open', () => resolve());
  socket.addEventListener('error', (e) => reject(e));
  return promise;
}

Enter fullscreen mode Exit fullscreen mode

之後:

function waitForOpen(socket) {
  const { promise, resolve, reject } = Promise.withResolvers();
  socket.addEventListener('open', () => resolve());
  socket.addEventListener('error', (e) => reject(e));
  return promise;
}

Enter fullscreen mode Exit fullscreen mode

邏輯相同,但少了許多繁文縟節。函式回傳一個根據 socket 事件解析或拒絕的 Promise——這正是你從乾淨版本中看到的結果。舊版本則需要你繞過逃逸樣板才能理解。

非同步佇列與訊息傳遞

deferred Promise 反覆出現的地方是非同步佇列——消費者等待下一個項目,生產者稍後呼叫 push

function createQueue() {
  const pending = [];
  let deferred = Promise.withResolvers();

  return {
    push(item) {
      pending.push(item);
      deferred.resolve();
      deferred = Promise.withResolvers();
    },

    async *[Symbol.asyncIterator]() {
      while (true) {
        await deferred.promise;
        while (pending.length) {
          yield pending.shift();
        }
      }
    },
  };
}

const queue = createQueue();

// Consumer
(async () => {
  for await (const item of queue) {
    console.log('received:', item);
  }
})();

// Producer (elsewhere)
queue.push('hello');
queue.push('world');

Enter fullscreen mode Exit fullscreen mode

每次呼叫 Promise.withResolvers() 都會建立一個新的閘門。消費者等待它;生產者在有東西可讀時解析它。若沒有 withResolvers,每一個閘門都需要逃逸樣板。有了它,佇列邏輯就能直接讀懂。

受控刷新:等待一批資料

另一種模式:收集項目進行批次操作,當批次觸發時一次解析所有等待者。

class Batcher {
  #items = [];
  #deferred = Promise.withResolvers();

  add(item) {
    this.#items.push(item);
    return this.#deferred.promise;
  }

  async flush() {
    const batch = this.#items.splice(0);
    const { resolve } = this.#deferred;
    this.#deferred = Promise.withResolvers();
    const results = await this.#processBatch(batch);
    resolve(results);
    return results;
  }
}

Enter fullscreen mode Exit fullscreen mode

呼叫者執行 await batcher.add(item),並在批次刷新時取得結果——無論是透過計時器、大小限制還是明確的使用者操作觸發。每次刷新都會用單一 Promise.withResolvers() 呼叫乾淨地重置閘門。

什麼時候不該使用它

deferred Promise 是針對特定情境的工具,而不是 Promise 建構子的一般替代方案。如果你同時控制設定與解析——fetchfs.readFile,或任何你直接 await 的非同步 API——請直接使用 async/await 或 Promise 建構子。deferred 模式專門用於設定與解析發生在不同執行情境,無法乾淨地共用回呼的情況。

一個具體的反模式:如果你發現自己在建立 deferred 的同一個函式中同步呼叫 resolve,那你可能只是想要 new Promise(res => res(value))——也就是 Promise.resolve(value)

TypeScript 支援

TypeScript 在 5.4 版加入了 Promise.withResolvers。回傳型別已正確標註——PromiseWithResolvers<T> 會保留型別:

const { promise, resolve, reject } = Promise.withResolvers<string>();

resolve('hello');       // ✅ string
resolve(42);            // ❌ type error

Enter fullscreen mode Exit fullscreen mode

如果你使用較舊的 TS 目標,請在 tsconfig.jsonlib 中加入 "ES2024"

瀏覽器支援

Promise.withResolvers 已成為 Baseline 2024:Chrome 119、Firefox 121、Safari 17.4、Node.js 22。如果你針對的是現代環境,無需 polyfill 即可使用。對於較舊的目標,舊的逃逸模式仍然正確——withResolvers 只是在你原本就能手寫的東西上加了語法糖。

重點摘要

在你的程式碼庫中搜尋 let resolvelet reject 後面接 new Promise 的模式。每一處都是以困難方式寫成的 deferred Promise。Promise.withResolvers() 為這個模式命名,消除了逃逸樣板,並讓意圖一目了然。結果相同,但程式碼明顯更乾淨。


感謝閱讀!讓我們保持聯繫: