我先坦白一件事。大概两年前我在写一个消息队列的消费调度逻辑,需要一个能在外部手动触发 resolve 的 Promise,很自然地写出了下面这种代码:
let resolveFunction;
const promise = new Promise((resolve) => {
resolveFunction = resolve;
});
// 然后在别的地方调用 resolveFunction('完成')
功能是跑通了,但每次看到那段把内部函数赋值给外部变量的代码,都觉得有点变扭。更头疼的是,如果多个地方需要独立的 `resolve` 和 `reject`,就得重复这个模式,变量命名稍微不注意还会出现覆盖隐患。当时就在想,Promise 规范里能不能给出一个更直接的方法。直到 ES2024 正式把 `Promise.withResolvers()` 纳入标准,我翻了一遍提案文档,突然觉得之前那些别扭的写法终于可以体面地收尾了。
这篇文章不会罗列枯燥的 API 参数表,而是从几个真实场景出发,把这个新方法的用法和边界一次性讲清楚。
一、先理解 Promise.withResolvers 到底解决了什么
在 ES6 引入 Promise 的很多年里,要想创建一个“在外部分配状态”的 Promise,唯一的办法就是利用构造函数暴露 `resolve` 和 `reject` 给外部作用域。这种写法在逻辑上没错,但有两个明显的缺陷:
- 代码结构不干净:你需要提前声明一个或两个变量,然后在一个不执行任何实际异步工作的构造函数里做赋值。这导致变量声明和实际赋值逻辑在物理距离上被隔开,读代码的人需要上下跳跃才能理解整个流程。
- 类型推断困难:在 TypeScript 里,你需要手动给外部变量标注准确的类型,否则就会得到 `any` 或者联合类型,增加维护负担。
`Promise.withResolvers()` 的初衷就是提供一个工厂方法,一次性返回一个对象,包含 `promise`、`resolve`、`reject` 三个属性。你不需要自己声明外部变量,也不需要写一个空壳构造函数。提案里把这种模式称为“将 Promise 的创建与其控制权的分配解耦”,而实际上对开发者来说,最直接的好处就是少写了好几行并不增加语义的样板代码。
// 旧写法
let resolveExternal;
const promise = new Promise((resolve) => {
resolveExternal = resolve;
});
// 新写法
const { promise, resolve: resolveExternal, reject } = Promise.withResolvers();
顺带一提,`resolve` 和 `reject` 都是普通函数,在脱离 Promise 构造函数后依然保持正确的 `this` 绑定,调用它们就等于确定了 `promise` 的最终状态。这个行为没有魔法,它本质上还是在构造一个 Promise,只是通过更优雅的接口暴露了控制权。
二、基本用法与错误处理
先看一个最简单的超时拒绝案例。假设你要给一个异步请求加一个超时机制,超过一定时间就自动 reject,否则正常 resolve。过去你可能会写一个 `timeout` 工具函数,内部用 `Promise.race`,但那个方案在超时时无法取消正在进行的请求(除非配合 AbortController)。现在用 `withResolvers`,可以构建一个更清晰的控制结构。
function fetchWithTimeout(url, timeoutMs = 5000) {
const { promise, resolve, reject } = Promise.withResolvers();
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
reject(new Error(`请求超时: ${url}`));
}, timeoutMs);
fetch(url, { signal: controller.signal })
.then(response => {
clearTimeout(timer);
resolve(response);
})
.catch(err => {
clearTimeout(timer);
reject(err);
});
return promise;
}
// 使用方式
try {
const response = await fetchWithTimeout('https://api.example.com/data', 3000);
const data = await response.json();
} catch (error) {
console.error('请求失败或超时:', error.message);
}
代码里 `resolve` 和 `reject` 作为普通函数被传递到了 `then` 和 `catch` 回调里,没有任何作用域污染。注意一点:`reject` 可以被多次调用,但 Promise 只会接受第一次的状态变更,后续调用会被静默忽略。所以在 `fetch` 的 `catch` 分支和超时回调里都调用 `reject` 是安全的,不会导致未捕获异常,但这确实意味着如果超时先触发,后面 `fetch` 返回的错误会被抛弃,需要根据业务决定是否需要额外处理。
三、实战场景一:异步任务队列的优雅消费
回到开头提到的消息队列场景。假设我们有一个任务调度器,它从队列里取出待处理项,但处理过程是异步的,并且需要在某个任务完成后立即通知调度器取下一个。传统做法会用事件或者回调,但用 `withResolvers` 可以构造一个“可等待的令牌”,每次消费前等待上一个令牌的 resolve。
class AsyncTaskQueue {
constructor() {
this.queue = [];
this.processing = false;
// 当前正在等待的令牌
this.waitToken = null;
}
// 往队列里添加任务
enqueue(task) {
this.queue.push(task);
this.tryProcess();
}
async tryProcess() {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
const task = this.queue.shift();
// 创建一个新的令牌,用于等待任务完成
const { promise, resolve } = Promise.withResolvers();
this.waitToken = resolve;
try {
// 执行实际任务,并将 resolve 传递进去,由任务完成后调用
await task(resolve);
} catch (err) {
console.error('任务执行失败:', err);
// 即使失败也要继续处理下一个
resolve();
}
// 等待当前令牌被消费(任务内部调用了传入的 resolve)
await promise;
}
this.processing = false;
this.waitToken = null;
}
}
// 使用示例
const queue = new AsyncTaskQueue();
queue.enqueue(async (done) => {
console.log('开始处理任务1');
await new Promise(r => setTimeout(r, 2000)); // 模拟异步工作
console.log('任务1完成');
done(); // 通知队列可以取下一个任务
});
queue.enqueue(async (done) => {
console.log('开始处理任务2');
await new Promise(r => setTimeout(r, 1000));
console.log('任务2完成');
done();
});
在这个例子里,每一个入队的任务都会收到一个 `done` 函数(也就是 `resolve`),任务结束后调用它,`tryProcess` 里的 `await promise` 才会继续往下走。这样做的好处是调度逻辑完全由队列内部控制,外部只需要关心任务本身的执行。而且每个令牌都是独立的,不存在之前提到的变量覆盖问题。
四、实战场景二:一次性事件监听器的 Promise 化
在浏览器端,我们经常需要把一次性的 DOM 事件转成 Promise,比如等待某个元素的动画结束,或者等待一个弹窗的“确认”按钮被点击。`withResolvers` 让这件事变得非常直白,而且顺手解决了事件清理的问题。
function waitForElementEvent(element, eventName) {
const { promise, resolve, reject } = Promise.withResolvers();
function handler(event) {
// 事件触发后立即清理监听器,防止内存泄漏
element.removeEventListener(eventName, handler);
resolve(event);
}
element.addEventListener(eventName, handler);
// 这里没有直接返回 resolve,因为还需要清理逻辑
return promise;
}
// 使用:等待一个按钮被点击
const button = document.querySelector('#submitBtn');
try {
const clickEvent = await waitForElementEvent(button, 'click');
console.log('按钮被点击了', clickEvent);
} catch (err) {
// 如果未来增加超时或取消逻辑会进入这里
}
当然,真实场景中你可能会加上超时控制。可以直接把前面 `fetchWithTimeout` 的思路搬过来,对外暴露一个带超时的版本。关键是 `resolve` 和 `reject` 两个函数你可以自由地传递给任何回调或者组合,不受构造函数作用域的限制。
五、一些小众但实用的组合技
组合技一:把回调式 API 转换为可取消的 Promise。有时候需要桥接一些老旧的回调异步接口,比如 `setTimeout`、`fs.readFile`(在老式 Node.js 代码中),以前用 `new Promise` 包装,现在用 `withResolvers` 可以让代码看起来更连贯,尤其是在需要同时处理成功、失败和取消三种情况的场景里。
function createCancellableTimeout(ms) {
const { promise, resolve, reject } = Promise.withResolvers();
const timer = setTimeout(() => resolve('完成'), ms);
// 返回一个包含promise和取消方法的对象
return {
promise,
cancel: () => {
clearTimeout(timer);
reject(new Error('已取消'));
}
};
}
const { promise: timerPromise, cancel } = createCancellableTimeout(5000);
timerPromise.then(console.log).catch(console.error);
// 当需要提前取消时
setTimeout(() => cancel(), 2000);
组合技二:与 AbortController 联动。在支持 AbortSignal 的环境中,可以把 `signal` 的 `abort` 事件直接映射到 `reject`,形成一个干净的中止通道。这个模式和 `fetch` 的官方用法非常贴合,而且不用自己维护额外的标志位。
function abortableAsyncOperation(signal) {
const { promise, resolve, reject } = Promise.withResolvers();
if (signal.aborted) {
reject(new DOMException('操作已被中止', 'AbortError'));
return promise;
}
signal.addEventListener('abort', () => {
reject(new DOMException('操作已被中止', 'AbortError'));
}, { once: true });
// 模拟异步工作,完成后调用 resolve
setTimeout(() => resolve('数据返回'), 3000);
return promise;
}
这种写法让 `AbortSignal` 和 Promise 之间的桥接非常自然,不需要额外的 Promise 包裹层。
六、与 new Promise 的对比:什么时候不用急着换
虽然 `withResolvers` 很干净,但并不意味着所有 `new Promise` 的地方都要替换。最典型的一个场景是:当异步逻辑完全封装在构造函数内部,不存在需要把 `resolve`/`reject` 暴露到外部的情况时,用 `new Promise` 反而更紧凑。例如一个简单的 `sleep` 函数:
// 这种情况下没必要用 withResolvers
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
另外,如果你用的是像 `p-limit` 这类基于 `new Promise` 构建的并发控制库,也不建议仅仅为了使用新 API 而去改动它们。`withResolvers` 的价值主要体现在你需要把控制权分发到多个独立上下文的时候——外部函数、事件处理器、定时器回调等等。
七、浏览器与 Node.js 支持情况
由于 `Promise.withResolvers` 是 ES2024 正式规范的一部分,主流平台跟进得非常迅速。写这篇文章的时候,Chrome 119+、Firefox 123+、Safari 17.4+ 都已经原生支持,Node.js 从 20.11.0 开始可用。如果你的项目需要兼容稍旧一点的环境,可以用一个极小的 polyfill 来垫一下:
if (!Promise.withResolvers) {
Promise.withResolvers = function() {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
这个 polyfill 只有几行,但行为上完全等价,可以放心使用。
八、写在最后
`Promise.withResolvers` 大概属于那种“用之前觉得不过是语法糖,用之后才发现回不去”的 API。它没有引入任何新的异步模型,只是在已有的 Promise 体系上补了一个很多人默默期待多年的缺口。当你在项目里需要构建复杂异步流,或者只是想少写几行别扭的变量声明时,它会像一个细小但顺手的工具,让代码的意图更直白。
下次再需要在某个事件触发后 resolve 一个 Promise,不妨直接打开控制台敲一遍 `const { promise, resolve } = Promise.withResolvers()`,体验一下那种不用先声明变量再赋值的轻快感。

