前端知库

Promise.withResolvers 适合解决什么问题?怎样避免 deferred 泄漏?

说明 Promise.withResolvers 如何把 Promise、resolve 和 reject 一起暴露出来,适合事件桥接和一次性完成信号,但不能替代队列、取消或状态机;重点讨论重复结算、异常传播、超时和资源清理。

参考答案

简答

  • Promise.withResolvers()(ES2024)一次返回 promise、resolve 和 reject。
  • 适合把一次性的事件或回调桥接成 Promise。
  • Promise 只结算一次,连续消息应改用队列或流。
  • 它不提供取消;超时和取消要另外设计,并在结束时移除监听器。

有些异步 API 的结果不是由当前函数里的 await 直接产生的,而是由稍后到达的事件、消息或回调完成。例如,组件要等待一次 Worker 响应,或者一个事件适配器要把一次 load/error 回调转换成 Promise。以前常见的写法是先声明变量,再在 Promise 构造器里给它们赋值:

JavaScript
let resolveReady;
let rejectReady;
const ready = new Promise((resolve, reject) => {
  resolveReady = resolve;
  rejectReady = reject;
});

这种写法能工作,但变量初始化时机、类型推断和代码阅读都比较别扭。Promise.withResolvers() 返回同一组对象:promise、resolve 和 reject,让“等待结果”和“以后完成结果”的边界更明显。

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

setTimeout(() => resolve('Worker 已准备好'), 20);
promise.then(console.log);

它只改变 Promise 的创建方式,不会创建线程、启动任务或自动监听事件。resolve 和 reject 只是完成这个 Promise 的入口,真正的资源生命周期仍由调用方负责。

它最适合桥接一次性事件

下面是一个等待 Worker 首次发送 ready 或 error 的适配器。事件监听器在 Promise 结算后立即移除,避免 Worker 永远存活时保留闭包和调用方对象:

JavaScript
function waitForWorkerReady(worker, timeout = 5000) {
  const { promise, resolve, reject } = Promise.withResolvers();
  let timer;

  const cleanup = () => {
    worker.removeEventListener('message', onMessage);
    worker.removeEventListener('error', onError);
    clearTimeout(timer);
  };

  const finish = (callback, value) => {
    cleanup();
    callback(value);
  };

  function onMessage(event) {
    if (event.data?.type === 'ready') {
      finish(resolve, event.data.version);
    }
  }

  function onError(event) {
    finish(reject, event.error ?? new Error('Worker 启动失败'));
  }

  worker.addEventListener('message', onMessage);
  worker.addEventListener('error', onError);
  timer = setTimeout(() => {
    finish(reject, new Error('等待 Worker 启动超时'));
  }, timeout);

  return promise;
}

事件可能先后触发多次,但 Promise 只有第一次结算有效;resolve 或 reject 后再次调用不会改变已经观察到的结果。不过不能因此省略清理:后续消息仍会调用监听器,监听器可能触发额外日志、更新状态或持有资源。示例用 cleanup 让“完成 Promise”和“释放观察者”成为同一个结束路径。

外部完成器不是取消机制

把 resolve 保存到对象上并不等于可以取消底层工作:

JavaScript
function waitForResult() {
  const deferred = Promise.withResolvers();
  longRunningTask(() => deferred.resolve('完成'));
  return deferred.promise;
}

调用方即使不再等待返回值,longRunningTask 仍可能继续占用网络、定时器、Worker 或文件资源。若任务需要取消,应同时设计 AbortSignal 或显式 cancel(),并让底层操作真正响应它:

JavaScript
function waitForMessage(worker, signal) {
  const { promise, resolve, reject } = Promise.withResolvers();

  const cleanup = () => {
    worker.removeEventListener('message', onMessage);
    worker.removeEventListener('error', onError);
    signal?.removeEventListener('abort', onAbort);
  };

  function onMessage(event) {
    cleanup();
    resolve(event.data);
  }

  function onError(event) {
    cleanup();
    reject(event.error ?? new Error('Worker 通信失败'));
  }

  function onAbort() {
    cleanup();
    reject(signal.reason ?? new DOMException('操作已取消', 'AbortError'));
  }

  if (signal?.aborted) {
    onAbort();
  } else {
    worker.addEventListener('message', onMessage);
    worker.addEventListener('error', onError);
    signal?.addEventListener('abort', onAbort, { once: true });
  }

  return promise;
}

这里的取消只拒绝等待方并移除监听器;Worker 当前正在执行的同步循环仍可能继续。需要停止它时,Worker 任务本身要在检查点读取取消状态,或者在确认可以丢弃任务时调用 terminate()。Promise.withResolvers() 不会改变这些底层语义。

一个 deferred 只能表示一次结果

Promise.withResolvers() 很容易被写成一个公共的“事件总线”:每次消息到达都调用 resolve,期待下一个调用者继续收到结果。Promise 不是队列,第一次 resolve 后后续值都会被丢弃:

JavaScript
const next = Promise.withResolvers();
next.resolve('第一条');
next.resolve('第二条');

console.log(await next.promise); // 第一条

示例中的顶层 await 只能在 ES 模块中运行,经典脚本需要包进 async 函数。

如果需要连续消息,应使用 AsyncIterator、ReadableStream、明确的数组队列或事件订阅接口,并定义缓冲上限和背压。一个 Promise 只适合表达“一次准备完成”“一次关闭”“一次请求结果”这类单次信号。

同样,不要把 deferred 当成跨请求全局状态。请求结束后仍保留 resolve,会让请求上下文、用户数据或数据库连接被闭包持有。组件卸载、请求超时、服务器关闭和测试结束时,都应让待处理的 Promise 进入明确的完成、拒绝或取消路径。

超时要与真正的清理绑定

给 Promise 套一个 Promise.race 只能让调用方更早得到超时结果,并不会自动结束另一个 Promise 背后的工作:

JavaScript
const timeout = new Promise((_, reject) => {
  setTimeout(() => reject(new Error('超时')), 1000);
});

await Promise.race([waitForExternalTask(), timeout]);

如果 waitForExternalTask() 仍在监听 socket 或占用 Worker,超时后它还会继续运行。更可靠的设计是让超时触发同一个取消信号,并在 finally 中清理资源:

JavaScript
async function withTimeout(start, milliseconds) {
  const controller = new AbortController();
  const timer = setTimeout(
    () => controller.abort(new Error('任务超时')),
    milliseconds,
  );

  try {
    return await start(controller.signal);
  } finally {
    clearTimeout(timer);
    controller.abort(new Error('任务已结束'));
  }
}

这个辅助函数要求 start 真正把 signal 传入下游;如果下游不支持取消,仍只能停止等待并在结果返回后检查任务是否已经过期。对于数据库写入、支付和发布消息等副作用,超时也不代表服务端没有成功,结果需要通过幂等键或状态查询确认。

错误传播和同步异常要保持一致

事件回调中的同步异常不会自动变成等待 Promise 的拒绝,除非回调适配器显式捕获:

JavaScript
function fromCallback(register) {
  const { promise, resolve, reject } = Promise.withResolvers();

  try {
    register(resolve, reject);
  } catch (error) {
    reject(error);
  }

  return promise;
}

如果 register 在注册过程中抛错,调用者仍然可以通过 await 的 try/catch 统一处理。生产适配器还应处理注册函数返回的清理方法、回调重复触发和参数校验;不能因为 Promise 只结算一次,就忽略重复回调对外部状态的影响。

兼容性和选型边界

Promise.withResolvers() 由 ES2024 标准化。项目需要根据目标浏览器、Node.js 版本和构建目标确认支持情况;不支持时可以用 Promise 构造器封装一个等价的工厂,但要保留同样的清理、取消和错误语义。不要仅因为 TypeScript 能找到类型声明,就认为运行时一定存在该方法。

它适合把一次性回调或事件转换成 Promise,改善初始化和完成信号的表达。它不适合替代:

  • 多条消息的队列或流;
  • 需要中止底层工作的取消协议;
  • 需要状态转换、重试和幂等的后台任务;
  • 需要等待多个资源并统一排空的生命周期管理。

面试中完整的回答应先说清 Promise 的一次性结算性质,再说明 withResolvers 只是把已有的 resolve/reject 暴露方式标准化。真正决定实现是否可靠的,是监听器何时注册和清理、超时是否能停止底层工作、取消是否与副作用语义一致,以及多次事件是否需要改用队列或流。