JavaScript Promise.withResolvers 怎样拆开 Promise 控制器
来源:17golang原创
时间:2026-11-02 14:54:49 126浏览 收藏
我第一次把“事件回调里完成 Promise”写得稍微复杂一点时,最别扭的地方不是 await,而是 resolve 和 reject 明明属于 Promise,却被迫藏在构造函数执行器里。Promise.withResolvers() 正好把这三个控制点拆成一个普通对象:promise 负责被等待,resolve 和 reject 负责在外部结算。
官方参考:
官方地址:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
最小用法就是解构出 { promise, resolve, reject }。它适合“事件发生后再决定结果”的一次性控制;如果事件会连续产生批次,就要在一个批次结算后重新创建下一组控制器。
先把 Promise 控制器拆开
Promise.withResolvers() 不接收参数,返回一个普通对象,里面有一个 Promise 和两个函数。它表达的仍然是普通 Promise 的状态机:只能从 pending 进入 fulfilled 或 rejected,已经结算后再次调用另一边不会让状态倒退。
// 三个变量属于同一个控制周期,便于让外部事件决定结果
const { promise, resolve, reject } = Promise.withResolvers();
// 调用方只等待 promise,不需要知道结算函数放在哪里
promise.then((value) => {
console.log("收到结果:", value);
});
// 事件回调可以在稍后完成这个 Promise
setTimeout(() => {
resolve({ ok: true, source: "外部事件" });
}, 0);
// 如果业务失败,应调用 reject;同一控制周期只会采纳第一次结算
// reject(new Error("外部事件失败"));

传统写法需要先声明两个变量,再把构造函数里的参数赋给它们:
// 旧写法需要让 resolve 和 reject 逃逸到外层作用域
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// withResolvers() 只是把这个意图写成了一个明确的返回对象
const controller = Promise.withResolvers();
两种写法表达的 Promise 语义相同,区别在于后者没有额外的 let 和执行器嵌套,控制器也更容易作为一个整体传递。
用一次性外部事件完成等待
一个很实用的场景是把一次回调式事件包装成 Promise。例如组件需要等待“用户确认”事件,事件监听器和等待逻辑不在同一个函数里,这时可以把控制器放在一个小对象中,并在结束时清理监听器。
function waitForConfirm(button) {
// 每次调用都创建独立控制器,避免不同调用共享状态
const { promise, resolve, reject } = Promise.withResolvers();
const onClick = () => {
// 先移除监听,避免一次确认影响后续逻辑
button.removeEventListener("click", onClick);
resolve({ confirmed: true });
};
const onAbort = () => {
button.removeEventListener("click", onClick);
reject(new Error("用户取消了操作"));
};
button.addEventListener("click", onClick, { once: true });
// 业务代码可以把 onAbort 绑定到取消按钮或 AbortSignal
return { promise, onAbort };
}
// 调用方只关心 Promise 的结果,外部事件负责触发 resolve/reject
const pending = waitForConfirm(document.querySelector("#confirm"));
pending.promise.then((result) => {
console.log(result.confirmed);
}).catch((error) => {
console.error(error.message);
});
这里的关键不是“把 Promise 暴露出去”,而是明确控制权的边界:promise 可以交给等待方,resolve 和 reject 只应留在能判断事件结果的那一侧。若多个模块都能拿到结算函数,就需要额外封装,避免任意模块提前结束等待。
连续事件要为每个批次换一组控制器
我在处理连续数据事件时容易踩的坑是复用同一个 Promise。Promise 一旦 fulfilled 或 rejected,就不会因为下一次事件再次变回 pending。因此,连续事件的结构应该是“当前批次有一组控制器,批次结束后创建下一组”,而不是让同一个 resolve 无限复用。
async function* readableToAsyncIterable(stream) {
// 当前批次的 Promise 只负责等待当前 readable 事件
let controller = Promise.withResolvers();
const onReadable = () => controller.resolve();
const onEnd = () => controller.resolve();
const onError = (error) => controller.reject(error);
stream.on("readable", onReadable);
stream.on("end", onEnd);
stream.on("error", onError);
try {
while (stream.readable) {
// 等待当前批次的事件通知
await controller.promise;
let chunk;
while ((chunk = stream.read()) !== null) {
// 每次只产出已经从当前批次读取到的内容
yield chunk;
}
// 旧 Promise 已经结算,下一轮必须更换整组控制器
controller = Promise.withResolvers();
}
} finally {
// 生成器被提前关闭时,释放监听器,避免保留外部引用
stream.off("readable", onReadable);
stream.off("end", onEnd);
stream.off("error", onError);
}
}

这个例子里,监听器可以保持不变,但它调用的 controller.resolve 必须始终指向当前批次。实际项目中如果事件可能在换控制器的间隙到达,还要结合事件源的缓存语义设计;withResolvers() 本身不会替你补齐事件丢失策略。
几个容易误用的边界
| 场景 | 建议 | 原因 |
|---|---|---|
| 一次性回调 | 创建一组控制器,结算后清理监听 | Promise 只会采纳第一次结算 |
| 连续数据事件 | 每个批次重新创建控制器 | 已结算的 Promise 不会重新 pending |
| 多个模块都能调用 resolve | 只暴露 promise,内部保留控制函数 | 减少非预期提前结算 |
| 没有取消路径 | 补上 reject、超时或 AbortSignal | 避免等待永久挂起 |
兼容性方面,MDN 标记该静态方法自 2024 年 3 月起在主流浏览器中可用,但项目仍应按自己的浏览器矩阵确认;需要覆盖更旧运行环境时,可以保留传统构造函数写法或使用经过评估的 polyfill。不要仅因为语法更短,就把所有 Promise 都改成外部可控对象。
我会怎样选择这两个写法
如果 Promise 的结算点天然就在构造函数内部,普通 new Promise((resolve, reject) => {}) 仍然更直白;如果结算动作来自监听器、队列或跨函数边界,Promise.withResolvers() 能让作用域关系更清楚。我的判断标准是:谁拥有结算权、一次结算覆盖多长生命周期、结束后能否清理外部引用。
相关问题
Promise.withResolvers() 会自动创建多个 Promise 吗?
不会。一次调用只创建一组 promise、resolve 和 reject;连续场景需要你显式再次调用。
resolve 和 reject 都调用时哪个生效?
同一个 Promise 只接受第一次有效结算,后续调用不会改变已经确定的状态,但外部副作用仍可能已经发生,所以事件监听器仍要正确清理。
它和 Deferred 是同一个概念吗?
它提供了常见 Deferred 模式需要的三件东西,但是否暴露控制器、如何处理取消和生命周期,仍由你的封装决定。
-
Golang · Go教程 | 3个月前 | 前端开发 · Go教程 · html/template · Go html/template CurrentPath 导航高亮 template.FuncMap Go网页模板409 收藏
-
306 收藏
-
194 收藏
-
427 收藏
-
480 收藏
-
357 收藏
-
110 收藏
-
450 收藏
-
434 收藏
-
331 收藏
-
151 收藏
-
465 收藏
-
101 收藏
-
228 收藏
-
219 收藏
-
260 收藏
-
117 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习