Promise.withResolvers 实战:让用户确认框不再把 resolve 晾在外面

昨天翻代码,看到几个月前写的结算页面里又有一大坨把 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。

可读性,有时候比语法糖还值钱。

Promise.withResolvers 实战:让用户确认框不再把 resolve 晾在外面
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript Promise.withResolvers 实战:让用户确认框不再把 resolve 晾在外面 https://www.taomawang.com/web/javascript/2738.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务