登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

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("外部事件失败"));
Promise withResolvers 的外部控制边界与 Promise 结果关系结构图
图1:Promise.withResolvers() 把两个结算函数放到 Promise 外部,关系图只表达控制点归属,不是运行截图。

传统写法需要先声明两个变量,再把构造函数里的参数赋给它们:

// 旧写法需要让 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);
  }
}
连续 readable 事件中当前 Promise 与下一 Promise 控制器的静态关系图
图2:连续事件场景中的当前批次与下一批次各自拥有 Promise 控制器,结构图不代表真实运行输出。

这个例子里,监听器可以保持不变,但它调用的 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 模式需要的三件东西,但是否暴露控制器、如何处理取消和生命周期,仍由你的封装决定。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>