Promise.withResolvers 适合解决什么问题?怎样避免 deferred 泄漏?
说明 Promise.withResolvers 如何把 Promise、resolve 和 reject 一起暴露出来,适合事件桥接和一次性完成信号,但不能替代队列、取消或状态机;重点讨论重复结算、异常传播、超时和资源清理。
简答
- Promise.withResolvers()(ES2024)一次返回 promise、resolve 和 reject。
- 适合把一次性的事件或回调桥接成 Promise。
- Promise 只结算一次,连续消息应改用队列或流。
- 它不提供取消;超时和取消要另外设计,并在结束时移除监听器。
有些异步 API 的结果不是由当前函数里的 await 直接产生的,而是由稍后到达的事件、消息或回调完成。例如,组件要等待一次 Worker 响应,或者一个事件适配器要把一次 load/error 回调转换成 Promise。以前常见的写法是先声明变量,再在 Promise 构造器里给它们赋值:
let resolveReady;
let rejectReady;
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
这种写法能工作,但变量初始化时机、类型推断和代码阅读都比较别扭。Promise.withResolvers() 返回同一组对象:promise、resolve 和 reject,让“等待结果”和“以后完成结果”的边界更明显。
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => resolve('Worker 已准备好'), 20);
promise.then(console.log);
它只改变 Promise 的创建方式,不会创建线程、启动任务或自动监听事件。resolve 和 reject 只是完成这个 Promise 的入口,真正的资源生命周期仍由调用方负责。
它最适合桥接一次性事件
下面是一个等待 Worker 首次发送 ready 或 error 的适配器。事件监听器在 Promise 结算后立即移除,避免 Worker 永远存活时保留闭包和调用方对象:
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 保存到对象上并不等于可以取消底层工作:
function waitForResult() {
const deferred = Promise.withResolvers();
longRunningTask(() => deferred.resolve('完成'));
return deferred.promise;
}
调用方即使不再等待返回值,longRunningTask 仍可能继续占用网络、定时器、Worker 或文件资源。若任务需要取消,应同时设计 AbortSignal 或显式 cancel(),并让底层操作真正响应它:
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 后后续值都会被丢弃:
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 背后的工作:
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('超时')), 1000);
});
await Promise.race([waitForExternalTask(), timeout]);
如果 waitForExternalTask() 仍在监听 socket 或占用 Worker,超时后它还会继续运行。更可靠的设计是让超时触发同一个取消信号,并在 finally 中清理资源:
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 的拒绝,除非回调适配器显式捕获:
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 暴露方式标准化。真正决定实现是否可靠的,是监听器何时注册和清理、超时是否能停止底层工作、取消是否与副作用语义一致,以及多次事件是否需要改用队列或流。