每位 JavaScript 开发者都至少写过一次这样的代码:

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

// 稍后…
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 接口。不同之处在于现在意图是明确的:你是有意创建一个延迟的 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

相同的逻辑,更少的仪式。该函数返回一个 Promise,根据 socket 的事件来解析或拒绝——这正是你从干净版本中读到的内容。旧版本迫使你绕过逃离样板代码才能理解。

异步队列与消息传递

延迟 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();

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

// 生产者(其他地方)
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() 调用干净地重置门。

何时不该使用它

延迟 Promise 是针对特定情况的工具,而不是 Promise 构造函数形式的通用替代品。如果你同时控制设置和解析——fetchfs.readFile,任何你直接 await 的异步 API——请直接使用 async/await 或 Promise 构造函数。延迟模式专门用于设置和解析发生在不同的执行上下文中,无法干净地共享回调的情况。

一个具体的反模式:如果你发现自己在创建延迟的同一个函数中同步调用 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.withResolversBaseline 2024:Chrome 119、Firefox 121、Safari 17.4、Node.js 22。如果你面向的是现代环境,无需 polyfill 即可使用。对于较旧的目标,旧的逃离模式仍然正确——withResolvers 是你可以始终手动编写的语法糖。

总结

在代码库中搜索 let resolvelet reject 后跟 new Promise 的模式。这些都是以困难方式编写的延迟 Promise。Promise.withResolvers() 为该模式命名,消除了逃离样板代码,并使意图一目了然。结果相同;代码明显更简洁。


感谢阅读!让我们保持联系: