当前位置:首页 > 文章列表 > 文章 > 前端 > Promise.withResolvers 怎么把事件回调封装成可取消等待

Promise.withResolvers 怎么把事件回调封装成可取消等待

来源:17golang原创 2026-10-13 11:06:24 0浏览 收藏

用 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移除本轮监听器不负责停止事件源和其他订阅
事件适配器、Promise能力对象和取消信号的静态接口关系
图1:原创结构说明图。事件输入和取消输入都关联适配器的结算能力;Promise 是交付对象,不代表事件源被停止。连线表示静态接口关系。

用同一个结束入口清理两类监听器

以下实现面向浏览器原生 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 监听器。清理应由拥有本轮订阅的适配器负责。

统一结束入口、清理函数和两类监听器的静态所有权关系
图2:原创静态关系说明图。finish 与 finished 约束一次结束,cleanup 持有目标事件监听器和 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

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
农产品加工企业建立批次追溯记录要记录哪些字段农产品加工企业建立批次追溯记录要记录哪些字段
上一篇
农产品加工企业建立批次追溯记录要记录哪些字段
冷链食品运输交接时怎样整理温度记录
下一篇
冷链食品运输交接时怎样整理温度记录
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    456次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    521次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    536次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    484次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    304次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码