昨天翻代码,看到几个月前写的结算页面里又有一大坨把 Promise 构造函数当容器使用的代码。里面挂着两个 resolve 变量,上下隔了十几行,看得我太阳穴直跳。就是那种常见到不能再常见的情况:
let resolveConfirm;
let rejectConfirm;
const confirmationPromise = new Promise((resolve, reject) => {
resolveConfirm = resolve;
rejectConfirm = reject;
});
// 十几个回调里到处调用 resolveConfirm / rejectConfirm
我们当然知道这种写法的动机:Promise 构造器里的执行器是同步执行,但 resolve/reject 本身只是两个普通函数。想要在构造器外面结束 Promise,就必须先把这两个函数从闭包里“丢”出来。于是有人管这叫 Deferred 模式,听着挺专业,实际上就是把几个变量挂在一处,等着别的地方去 resolve。
直到 ES2024 带来了 JavaScript 原生版的 Promise.withResolvers()。第一眼看到时我就在想:哎哟,这不就是官方递给你一张写好的 defer 小抄么。
一次生产经历:做个确认弹窗
不编啥抽象例子了,直接讲我前两天遇到过的一个具体需求。有个后台换色器,用户改了主题色后,如果二十秒内没有再点“发布”,就弹一层透明面板问:“要不要临时保存这套配色?” 这个确认框不能阻塞页面其它操作,但业务逻辑又得等用户点完才能往后走。说白了,要一个可以被外部按钮触发的 Promise。
以前写这种弹窗,每次都得在最外层手动建一个承诺,再把 resolve 塞到按钮的事件回调里。特别是当弹窗的内容是动态生成时,很容易把事件监听器加错位置,或者是等弹窗关掉后监听器还留在旧 DOM 上。Promise.withResolvers 其实没有直接解决事件监听器管理问题,但它确实让“发起弹窗”和“结束弹窗”这两件事的边界短了不少。
下面这段就是一个可以直接拿去改造的完整函数,用来显示一条消息,等着用户点“确定”或“取消”,到时间没反应就自动 reject,并告诉你超时了:
function showConfirm(message, { timeout = 5000 } = {}) {
// 先创建一套最朴素的 UI,正式项目里你可以换成自己的组件
const overlay = document.createElement('div');
overlay.className = 'confirm-mask';
const box = document.createElement('div');
box.className = 'confirm-dialog';
const text = document.createElement('p');
text.textContent = message;
const okBtn = document.createElement('button');
okBtn.textContent = '确定';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '取消';
box.append(text, okBtn, cancelBtn);
overlay.appendChild(box);
// 核心主角:withResolvers 让 resolve/reject 直接暴露出来
const { promise, resolve, reject } = Promise.withResolvers();
let timer;
function finish(result, isError = false) {
clearTimeout(timer);
overlay.remove();
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
if (isError) {
reject(result);
} else {
resolve(result);
}
}
function onOk() {
finish(true);
}
function onCancel() {
finish(false);
}
function onTimeout() {
finish(new Error('用户超时,未确认操作'), true);
}
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
timer = setTimeout(onTimeout, timeout);
document.body.appendChild(overlay);
return promise;
}
注意我这里使用 finish 收口了所有“结束”路径。点击确定,resolve(true);点取消,resolve(false);超时,reject(Error)。每个结束分支都先把定时器、DOM、事件监听器清干净,最后才去改变 Promise 状态。
外面的调用方式会变得特别自然:
try {
const ok = await showConfirm('要临时保存当前主题色吗?', {
timeout: 10000,
});
if (ok) {
localStorage.setItem('theme-draft', currentTheme);
} else {
console.log('用户放弃了保存');
}
} catch (err) {
// 超时或者 close 事件里 reject 的错误
console.warn('主动取消或超时:', err.message);
}
这段代码如果沿用老写法,逻辑基本一样,但你一定会在外面看到至少七八行“临时变量”和“辅助函数”的特调。而现在,所有等待确认的逻辑都在 showConfirm 函数里,Promise 的触发条件完全由内部的事件调度负责。调用侧只需要识别 resolve 或 reject,代码直接少了一层噪波。
再顺着官方 API 看一眼:它只是拆开了 Promise
有同事问过我,Promise.withResolvers() 和 new Promise 的区别到底在哪?一句话解释:
const { promise, resolve, reject } = Promise.withResolvers();
它等价于:
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
所以它本质上就是标准化的 Deferred。特殊的是,它返回的不是一个 Promise,而是一个配好了解析函数的外部包装。有些人觉得这个写法会让 Promise 的“不可变性”受损,毕竟拿到 resolve 的人可以在状态已经变化后多调一次,不过状态机本身只会接受第一次变化,后面再调都是无效的。
再加上现在 Chrome 119+ 以及 Node 20 往后都已经支持了。在项目里用 Vite 或 Babel 编译时,多打一次 polyfill 也不麻烦。我是从实际业务出发,完全不反感它。
一个真正让减负生效的场景:等待多个外部事件
弹窗只是一个引子。真正让我决定把所有“手动 defer”地方改成 withResolvers 的,是一个下载并解析视频资源的操作。那个操作里同时要受三个外部来源控制:
- 用户点击“立即下载”;
- 网络请求被
AbortController终止; - 如果整个过程超过 15 秒,自动撤销。
旧代码得在三个不同的函数环境里同时引用两个不同的 resolve/reject,还要注意定时器清没清,终止监听有没有移除。之前我写过一版,硬撑了两周后重构。这类问题最大的痛苦不是 Promise 本身有多复杂,而是 resolve 和 reject 就像两名临时工,到处借调,没有任何固定归属地。
用 Promise.withResolvers 重写后,每个操作都可以单独建一个 { promise, resolve, reject } 小容器,再交给各个事件处理函数。比如给一个 Promise 添加 AbortSignal 支持,可以把 resolve 直接挂在 signal 的 abort 事件里,然后用 finally 清理,整个过程非常舒服:
function fromEventWithSignal(target, eventName, signal) {
const { promise, resolve, reject } = Promise.withResolvers();
function handler(event) {
target.removeEventListener(eventName, handler);
resolve(event.detail);
}
target.addEventListener(eventName, handler);
function abort() {
target.removeEventListener(eventName, handler);
reject(new DOMException('操作被中止', 'AbortError'));
}
if (signal) {
if (signal.aborted) {
abort();
} else {
signal.addEventListener('abort', abort, { once: true });
promise.finally(() => signal.removeEventListener('abort', abort));
}
}
return promise;
}
这个例子更体现了它和传统 Promise 构造器的微妙区别:在传统写法里,executor 内部一开始并不会主动去绑定事件,总得手动把这些引用往外放。但现在只要一个对象解构,就什么都有了。像这种需要多个事件同时等待的场景,越用越顺手。
要不要所有 Promise 都改用 withResolvers?别。
如果逻辑依然是一个独立请求、一份普通定时器,你就继续用 new Promise 好了,没有任何问题。 withResolvers 存在的意义并不是替代所有 Promise,而是为那些需要“提前把钥匙拿出来”的流程提供标准做法。也就是说,只要你的代码里出现:
let resolveSomething;
let rejectSomething;
然后下一行才写 new Promise,就可以把整段统一替换成 withResolvers。
最后补一句,Promise.withResolvers 带来的最大改变可能并不是省了那两三行代码,而是让每个外部触发型 Promise 的“状态开关”不再是藏起来的秘密,而是语法的显性部分。当你读一个项目,看到 const { promise, resolve } = Promise.withResolvers() 时,你立刻就会明白:这个 Promise 肯定不是同步返回的,一定有什么别的东西会在未来调用这个 resolve。
可读性,有时候比语法糖还值钱。

