Promise.withResolvers 怎么把事件回调封装成可取消等待
来源:17golang原创
时间:2026-10-13 11:06:24 327浏览 收藏
用 Promise.withResolvers() 取得 promise、resolve 和 reject,把目标事件回调接到 resolve,把 AbortSignal 的 abort 回调接到 reject;两条路径结束时都移除监听器。还要在注册前检查 signal.aborted,否则拿一个已经取消的信号开始等待,可能一直悬挂。
它不是“可取消 Promise”语法:Promise 只承载结果,取消语义由适配函数实现。用户取消的是本次订阅和等待,不是事件源自身。以页面上的“确认继续”和“取消等待”为例,取消后按钮仍然可以发出点击事件,但不应再唤醒这轮等待。
把事件桥接和取消请求分开
这种小型适配可以叫作一次性事件桥接。调用方希望用 await 描述“等一次确认再继续”,浏览器却通过监听器交付点击;两者之间缺的是结果结算和资源释放的共同约定。
withResolvers 返回三个成员,方便同一作用域的不同回调共享结算能力。我们只向调用方返回 promise,把 resolve、reject 留在适配器内部。否则业务代码也能任意结算这轮等待,清理规则会变得难以保证。它没有增加 Promise 的状态种类,也没有自动识别 AbortSignal。
| 对象 | 职责 | 不能代替的工作 |
|---|---|---|
| EventTarget | 提供目标事件 | 不会知道调用方是否仍在等待 |
| Promise | 交付一次成功或拒绝 | 不会自动解绑监听器 |
| AbortSignal | 表达取消状态与原因 | 不会自动拒绝任意 Promise |
| cleanup | 移除本轮监听器 | 不负责停止事件源和其他订阅 |

用同一个结束入口清理两类监听器
以下实现面向浏览器原生 EventTarget、字符串事件类型和可选的原生 AbortSignal。它等待注册之后交付到该监听器的第一个事件,不回放历史事件,也不判断事件的业务内容。
function waitForEvent(target, type, { signal } = {}) {
// 仅桥接原生 EventTarget;返回一次事件,而不是持续订阅
const { promise, resolve, reject } = Promise.withResolvers();
let finished = false;
function cleanup() {
// 添加和移除均使用 capture=false,回调引用必须相同
target.removeEventListener(type, onEvent, false);
signal?.removeEventListener("abort", onAbort, false);
}
function finish(ok, value) {
// 一次结束只清理一次,避免后来的回调改变业务处理
if (finished) return;
finished = true;
cleanup();
if (ok) resolve(value);
else reject(value);
}
function onEvent(event) {
finish(true, event);
}
function onAbort() {
// 原样保留取消原因,调用方不必只依赖 AbortError 名称
finish(false, signal.reason);
}
if (signal?.aborted) {
reject(signal.reason);
return promise;
}
try {
target.addEventListener(type, onEvent, { once: true, capture: false });
signal?.addEventListener("abort", onAbort, { once: true, capture: false });
// 补查状态;已经取消的 signal 不会为新监听器重放 abort
if (signal?.aborted) onAbort();
} catch (error) {
// 注册异常也走统一清理,不遗留已添加的监听器
finish(false, error);
}
return promise;
}
finished 是本轮业务结束标记,不是读取 Promise 状态的方法。原生 Promise 自身已经防止重复结算;这里的额外标记让资源清理和业务结束共用一个入口。cleanup 在 resolve 或 reject 前执行,因此成功分支不会把 abort 回调留到以后,取消分支也不会留下目标事件回调。
预先取消直接以 signal.reason 拒绝,而且还没添加监听器。注册之后再检查一次状态是防御性处理,不意味着原生同步注册过程中存在任意异步线程插入;重点是不能只监听未来 abort 而完全忽略已有取消状态。本文不覆盖重写了监听器方法、带特殊副作用的非原生事件对象。
这里特意显式移除监听器,而不是单纯给 addEventListener 传入 signal。后者可以在取消时移除事件监听器,但仍然需要让 promise 拒绝;成功时也需要解除我们自己注册的 abort 监听器。清理应由拥有本轮订阅的适配器负责。

在确认按钮与取消按钮之间等待一次
把前面的 waitForEvent 定义与下面代码放入同一个页面。按钮不是生成的界面图片,也不是运行证据;这是读者可自行试用的最小标记与脚本。
async function runConfirmation() {
const confirm = document.getElementById("confirm");
const cancel = document.getElementById("cancel");
const status = document.getElementById("status");
const controller = new AbortController();
const cancelled = new DOMException("用户取消等待", "AbortError");
function onCancel() {
// 取消本次等待,不禁用按钮,也不停止整个页面
controller.abort(cancelled);
}
cancel.addEventListener("click", onCancel, { once: true });
status.textContent = "正在等待确认";
try {
await waitForEvent(confirm, "click", { signal: controller.signal });
status.textContent = "已确认继续";
} catch (error) {
// 使用本次取消原因区分主动取消与其他异常
if (error === cancelled) status.textContent = "已取消等待";
else {
status.textContent = "等待失败";
console.error(error);
}
} finally {
// 成功时取消按钮没有被点击,once 不会替我们移除它
cancel.removeEventListener("click", onCancel);
}
}
// 页面只启动这一轮;每轮等待都需要新的 controller
void runConfirmation();
先点确认,状态文字应进入确认分支;重新载入页面后先点取消,应进入取消分支。这里的“应”描述示例预期,不是跨浏览器测试记录。调用方通过 catch 及时接住拒绝,也把取消按钮自己的监听器放入 finally 清理:适配器只管理它创建的监听器,不会替调用方管理整个页面。
若组件卸载或用户关闭对话框,同样应让本轮 controller.abort() 被调用。每次新等待创建新的 controller;已经 aborted 的信号不能恢复成未取消状态。未提供信号而且目标事件永远不发生时,等待会保持未结束,因此组件生命周期中不要省略取消入口。
只用 once 或 Promise.race 为什么不够
once 只在对应事件实际交付后移除那个监听器。确认成功时,abort 可能永远不会发生;取消发生时,目标点击也可能永远不会发生。它们各自的 once 都不会清理另一条路径,正是显式 cleanup 必须处理两边的原因。
Promise.race 可以选出较早的结果,但不会取消输掉的等待。如果事件 Promise 只是永久挂着一个监听器,即使外层 race 已经因取消返回,这个监听器仍可能保留闭包和引用。把“外层 await 已结束”当成“底层订阅已释放”,才是最常见的漏项。
移除时必须保留相同的事件类型、回调引用和 capture 值。重新写一个同内容箭头函数是新的函数对象,无法匹配旧监听器。本文把 capture 固定为 false,不把复杂的监听选项透传给调用方,减少清理约定的分歧。
如果事件回调更早进入 finish,成功结果不会被后续 abort 反转;反过来也一样。判断依据是适配器先处理了哪条结束路径,而不是两个输入在用户感觉上哪个更早。取消已成功结束的等待,并不能撤回已经开始的后续业务动作。
什么时候不该把事件变成一次等待
确认、一次准备完成通知适合这种桥接。持续的鼠标移动、实时消息订阅并不适合塞进同一个 promise:它只交付一次结果,后续事件应使用持续监听或其他明确的订阅接口。本文也不包含失败事件转换;如果事件源另有 error 事件,必须额外设计失败分支和它的清理,而不能假设这里会自动捕获。
采用前可检查:事件目标是否是约定的 EventTarget;调用是否先于需要等待的事件;取消后只需解除等待还是还需停止底层操作;是否及时接住拒绝;组件退出时是否发送取消;正常成功后是否释放 abort 监听器。把这些职责写清,再选择是否采用 withResolvers。
旧浏览器没有 withResolvers 怎么办?
使用局部能力函数,替换 waitForEvent 开头获取三个成员的一行即可。以下适配面向原生 Promise,不是支持任意 Promise 子类的完整标准 polyfill,也不意味着旧环境同时支持 AbortSignal 的所有属性。
function createResolvers() {
// 局部兼容适配,不修改全局 Promise
if (typeof Promise.withResolvers === "function") {
return Promise.withResolvers();
}
let resolve, reject;
const promise = new Promise((accept, fail) => {
// 原生 Promise 的 executor 同步执行,两个引用在返回前已赋值
resolve = accept;
reject = fail;
});
return { promise, resolve, reject };
}
替换后调用 createResolvers() 获取对象;仍需按实际部署环境检查 AbortSignal.reason 等所用接口。语法转译也不等于自动补上运行时静态方法。
取消为什么不一定得到 AbortError?
AbortController.abort 可以携带自定义原因,signal.reason 不必是 Error。本文原样传递它;示例用本次创建的取消对象比较,不把所有异常都误判为用户取消。
只需要等一个事件,没有取消入口也能用吗?
可以不传 signal,但生命周期中仍要确保事件最终可达。如果页面或组件可能离开,缺少取消入口会让挂起等待难以回收。withResolvers 的简洁不能代替资源所有权设计。
文档与规范
Promise 能力说明:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
取消接口约定:https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
监听器移除规则:https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
ECMAScript 规范:https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-promise.withresolvers
-
130 收藏
-
414 收藏
-
163 收藏
-
137 收藏
-
160 收藏
-
文章 · 前端 | 1小时前 | 前端 · javascript · SyntaxError Web Components customElements.whenDefined Custom Elements 自定义元素名称223 收藏
-
222 收藏
-
440 收藏
-
120 收藏
-
387 收藏
-
308 收藏
-
222 收藏
-
103 收藏
-
253 收藏
-
158 收藏
-
474 收藏
-
111 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习