写JS这么多年,我经常需要手里面保留promise的resolve和reject引用,用于在某个异步回调发生时触发状态。比如一个懒加载的图片、一个请求互斥锁、一个等待用户确认的弹窗。以前的标准做法是:先声明一个空变量,再new Promise,然后把这个变量的值赋给resolve。代码倒也能跑,但总觉得像在给JS打补丁。
好在 ES2024 带来了Promise.withResolvers,现在拿到promise和它的控制函数只需要一行调用。今天这篇文章我就结合几个真实的开发场景,聊聊这个API怎么用,以及它如何让我的代码变得不再别扭。
一、过去我们怎么手动保存 resolve
举一个最常见的等待对话框回执的例子。页面里有一个异步操作,需要等待用户点击“确认”或“取消”后才继续执行。以前我会这样写:
function waitForUserConfirm() {
let resolveFn;
const promise = new Promise((resolve) => {
// 把resolve存在外部变量上
resolveFn = resolve;
});
// 这里拿到resolveFn,但没法立即用,因为赋值是在执行器里异步发生的
// 实际上这里resolveFn可能还是undefined,得用queueMicrotask或者setTimeout处理
// 这就是问题所在
}
通常为了解决这个延迟赋值,我们不得不这样写:
let resolveFn;
const promise = new Promise((resolve) => {
resolveFn = resolve;
});
// 此时resolveFn已经可以被同步使用,因为执行器是同步执行的
// 这样写虽然能行,但包裹一层new Promise只是为了拿到resolve,总感觉绕。
但如果你有多个promise需要同时管理,比如一个并发池,需要动态添加任务,那手动保存的变量就会越来越难维护。
二、Promise.withResolvers 做了什么
一句话:它直接返回一个对象,包含promise、resolve、reject三个属性。不需要你再手动new Promise并且把resolve存到外部变量上。
const { promise, resolve, reject } = Promise.withResolvers();
就这么简单。同时,promise和resolve已经建立联系,调用resolve可以改变promise状态。你可以立即保存resolve引用,甚至把resolve传递给其他函数。
三、实战案例1:把定时器封装成可取消的等待
以前我们写一个“延迟一段时间并且支持外部取消”的函数,需要手动用new Promise把clearTimeout包起来。现在有了withResolvers,清楚多了。
function wait(ms, signal) {
const { promise, resolve, reject } = Promise.withResolvers();
const timer = setTimeout(resolve, ms);
if (signal) {
if (signal.aborted) reject(new DOMException('取消', 'AbortError'));
else signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('取消', 'AbortError'));
}, { once: true });
}
return promise;
}
注意这里我没有用setTimeout(() => resolve(), ms),而是直接把resolve作为setTimeout的回调。因为setTimeout会忽略传入函数的this,而且resolve不接收参数,所以没问题。
这个代码在ES6里也能写,但withResolvers让整个结构更加扁平。你会发现你不需要再内部定义let _resolve之类的东西了。
四、实战案例2:将Node的fs.readFile转Promise
Node里有些API还是回调风格。做一个转换器,以前是这样:
const fs = require('fs');
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
用withResolvers也能写:
const fs = require('fs');
function readFilePromise(path) {
const { promise, resolve, reject } = Promise.withResolvers();
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
return promise;
}
虽然看起来差别不大,但是在需要把resolve或reject传给其他函数时,优势就出来了。
五、实战案例3:懒启动的异步任务(懒初始化)
有时候我们想执行一个异步任务,但又不想立刻开始,而是等某个条件满足时才真正启动。这个异步任务本身有自己的resolve/reject,我们可以在外部触发启动,同时还能等待它完成。
举个例子:实现一个可重用的“异步锁”。当一个异步操作正在进行时,其他调用方需要等待。等第一个完成后,锁释放,等待的promise才resolve。
class AsyncLock {
constructor() {
this._locked = false;
this._waiters = [];
}
acquire() {
if (!this._locked) {
this._locked = true;
return Promise.resolve(true);
}
const { promise, resolve } = Promise.withResolvers();
this._waiters.push(resolve);
return promise;
}
release() {
if (this._waiters.length > 0) {
const resolve = this._waiters.shift();
resolve(true);
} else {
this._locked = false;
}
}
}
这里withResolvers让我们不用再在类属性里手动存resolve,直接使用对象上的resolve。整个逻辑看起来非常舒服。
六、更高级的玩法:把回调API包装成“可中途取消”
我最近写一个文件上传的模块,用的是第三方的SDK,那个SDK只支持回调方式,而且提供取消方法。以往要用Promise封装上传,还得保存取消函数,非常麻烦。
function uploadFile(file, options) {
const { promise, resolve, reject } = Promise.withResolvers();
const task = sdk.upload(file, {
onSuccess: resolve,
onError: reject,
onCancel: () => reject(new Error('用户取消上传')),
});
// 返回promise的同时,带上cancel方法
return {
promise,
cancel: () => task.cancel()
};
}
然后调用端:
const { promise, cancel } = uploadFile(file);
// 如果用户点击取消
cancel();
// 此时promise会reject,可以捕获到错误
注意,因为withResolvers把resolve和reject暴露在外部,所以cancel方法可以访问到reject,并且通过SDK的cancel触发onCancel。这个模式比之前用new Promise然后小心翼翼保存reject要简单得多。
七、结合AbortSignal实现更统一的取消机制
在浏览器里,fetch已经支持AbortSignal,但第三方库不一定。我们可以用withResolvers将任意异步操作与AbortSignal联动。
function runWithAbort(asyncCallback, signal) {
const { promise, resolve, reject } = Promise.withResolvers();
const abortHandler = () => {
reject(new DOMException('任务已取消', 'AbortError'));
};
if (signal.aborted) {
reject(new DOMException('任务已取消', 'AbortError'));
} else {
signal.addEventListener('abort', abortHandler, { once: true });
}
// 让asyncCallback可以往promise转发结果
asyncCallback(resolve, reject);
// promise settle后移除事件监听,避免内存泄漏
promise.finally(() => {
signal.removeEventListener('abort', abortHandler);
});
return promise;
}
这样你就能把任何基于回调的API包装成可取消的Promise,而无需在回调内部反复检查signal。
八、多个Promise.withResolvers组合使用
如果你想实现一个“等待多个外部事件”的Promise,比如等待两个条件都满足,可以这样:
const first = Promise.withResolvers();
const second = Promise.withResolvers();
// 当某个异步回调触发时调 first.resolve(...)
// 当另一个条件满足时调 second.resolve(...)
Promise.all([first.promise, second.promise]).then(([a, b]) => {
console.log('两个条件都满足了', a, b);
});
这种写法在事件驱动里非常有用。比如在WebSocket中,需要同时等待连接打开和订阅成功,两个消息都返回后才继续。
以前你不得不用两个变量,还要初始化成null,再在某个地方赋值。现在直接解构出来,不用考虑顺序问题。
九、注意:别滥用
虽然withResolvers很爽,但它并不意味着你应该用它替代普通的Promise构造器。如果你的异步操作逻辑本身就在一个函数内部,可以直接写new Promise((resolve, reject) => {...}),那根本不需要withResolvers。只有当你需要把resolve或reject传递到当前作用域之外,或者与其他代码共享控制权时,它才是最佳选择。
还有一个约定:如果你用了withResolvers,确保promise的settle状态不会因为resolve被调用多次而出现问题。Promise本身已经处理了状态锁,所以放心。
十、浏览器兼容性
这个API已经在Chrome 119、Firefox 121、Safari 17.4中支持了,Node.js 22.0.0开始支持。所以现在完全可以放心使用。如果你要兼容更老的环境,可以自己写一个polyfill,但没必要了。
总结
Promise.withResolvers并没有引入新的语义,它只是把原本需要手动书写样板代码的模式简化了。但正是这种简化,让代码真的流畅了很多。就像你用解构赋值一样,没什么牛的技术含量,但就是爽。
我自己的老项目里,重构完这类的临时变量,代码行数少了差不多十分之一。而且读起来更接近“我们的目的是等待结果”,而不是“让我们折腾一下resolve的存储位置”。
如果你还没用过这个API,我建议你试试。它也许没法让你写出更炫酷的算法,但至少让你写异步代码时少点难受。

