从前端竞态到超时取消,Promise.withResolvers 把异步控制变得像聊天一样简单

不知道你写前端的时候有没有这种体验:明明只是发个请求,却要小心翼翼处理用户反复点击、请求乱序返回、超时没有反应这些破事。我干了四年前端,最烦的就是处理竞态和取消。AbortController 出来的时候觉得有救了,但用起来还得包一层 Promise,写出来的代码还是不太顺眼。

直到最近翻 ECMAScript 2024 的提案,看到 Promise.withResolvers 已经进了标准,而且 Chrome 和 Node 都支持了。试了一把以后才反应过来,原来以前让我觉得别扭的不是 AbortController 本身,而是把 resolve、reject 跟 Promise 构造函数搅和在一起的那个写法。

这篇文章我就拿一个最常见的搜索框案例,把这些新东西串起来。你看完以后会发现,控制异步这件事也能写得像人话一样清楚。

老写法为什么让人心累

假设你要做一个搜索框,用户输入完等 300ms 再发请求,然后展示结果。但用户可能在请求还没回来的时候又输入了新的内容,这时候旧请求的返回结果不能覆盖新结果,不然就出 bug 了。

以前惯用做法是给每次请求套一个序号,只有最新的序号才允许更新界面:

let searchSeq = 0;

async function search(keyword) {
    const current = ++searchSeq;
    const res = await fetch(`/api/search?q=${keyword}`);
    const data = await res.json();
    if (current === searchSeq) {
        render(data);
    }
}

说实话这种方式也能用,问题是一旦你要结合“取消”功能,比如用户按 Esc 或者切走页面时把旧请求中断掉,光靠序号就有点束手束脚了。这时候就得请出 AbortController,老代码会写成这样:

function searchWithAbort(keyword) {
    const controller = new AbortController();
    const signal = controller.signal;

    return new Promise((resolve, reject) => {
        fetch(`/api/search?q=${keyword}`, { signal })
            .then(res => res.json())
            .then(resolve)
            .catch(err => {
                if (err.name === 'AbortError') return;
                reject(err);
            });
    });
}

看见没有?为了把一个 fetch 包装成“可取消的 promise”,又搞了 new Promise 嵌套,里面还有一堆 then 回调。如果同时还要做超时,得多弄一个 setTimeout 去调 controller.abort(),代码马上膨胀得没法看。

Promise.withResolvers 到底解决了什么事

Promise.withResolvers 这个方法很简单,你调用它,它会返回一个对象,里面有 promiseresolvereject 三个东西。它的作用就是让你不用再把 resolve 和 reject 困在 Promise 构造函数里,而是可以自由地把它们带到外层去用。

看一下基本用法:

const { promise, resolve, reject } = Promise.withResolvers();

setTimeout(() => resolve('时间到了'), 1000);

promise.then(value => console.log(value)); // 1秒后输出"时间到了"

就这么简单。但真正的威力在于,当你想在事件回调里 resolve,或者想同时结合多个取消来源时,这种写法比包一层 Promise 函数干净得多。

比如刚才那个“可取消 fetch”可以直接写成:

function cancellableFetch(url, { signal } = {}) {
    const { promise, reject } = Promise.withResolvers();

    const controller = new AbortController();
    const externalSignal = signal;

    function abortHandler() {
        controller.abort();
        reject(new DOMException('Aborted', 'AbortError'));
    }

    if (externalSignal?.aborted) {
        abortHandler();
    } else {
        externalSignal?.addEventListener('abort', abortHandler, { once: true });
    }

    fetch(url, { signal: controller.signal }).then(res => {
        externalSignal?.removeEventListener('abort', abortHandler);
        return res;
    });

    return promise;
}

等一下,这段代码里 promise 的 resolve 没用到?因为 fetch 本身返回的 promise 可以直接让外部直接调用,但这里为了统一返回值,还是把 fetch 的结果包装了一下。实际项目中你可以直接用 fetch 的 promise,然后单独处理 abort,不一定非要用 withResolvers。

我们来看一个更贴合实际场景的案例:搜索请求 + 超时 + 取消旧请求三者合一。

案例:构造一个带取消和超时的搜索请求函数

先说一下需求:用户输入关键词,我们发起一个请求,如果这个请求在 2 秒内没返回,就自动取消。并且当用户又输入新内容时,上一个请求必须被取消,防止它返回后把列表更新错乱。

用旧的 Promise 构造函数来写,你会发现在 2秒超时和外部取消之间要维护两个 AbortController,很麻烦。用了 withResolvers 以后,整个逻辑可以这样归置:

function createSearchRequest(keyword, { signal } = {}) {
    const { promise, resolve, reject } = Promise.withResolvers();

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 2000);

    function handleAbort() {
        clearTimeout(timer);
        controller.abort();
        reject(new DOMException('请求已取消', 'AbortError'));
    }

    if (signal?.aborted) {
        handleAbort();
    } else {
        signal?.addEventListener('abort', handleAbort, { once: true });
    }

    fetch(`/api/search?q=${keyword}`, { signal: controller.signal })
        .then(res => {
            clearTimeout(timer);
            signal?.removeEventListener('abort', handleAbort);
            return res.json();
        })
        .then(resolve)
        .catch(err => {
            clearTimeout(timer);
            signal?.removeEventListener('abort', handleAbort);
            reject(err);
        });

    return promise;
}

这里用 withResolvers 创建了一个外部可以手动 reject 的 promise,然后通过外部的 signal(比如来自 AbortSignal.timeout 或者另一个 AbortController)来触发这个 reject,同时把真正的 fetch 请求 abort 掉。这样函数的使用者只需要关心返回的 promise 是 resolve 还是 reject,内部怎么取消的全被封装起来了。

然后在搜索组件里,我们只需要为每次搜索创建一个新的 AbortController,把它传给上一个请求,上一个请求就自动被我们手动 abort 掉,并且它的 promise 会 reject。接着用 withResolvers 做一个“当前最新的请求”管理:

let currentSearch = null;

async function handleSearch(keyword) {
    // 取消上一次未完成的请求
    currentSearch?.abort();

    const controller = new AbortController();
    currentSearch = controller;

    try {
        const data = await createSearchRequest(keyword, { signal: controller.signal });
        renderResult(data);
    } catch (err) {
        if (err.name === 'AbortError') {
            console.log('这次搜索被取消了', keyword);
            return;
        }
        renderError(err);
    } finally {
        if (currentSearch === controller) {
            currentSearch = null;
        }
    }
}

看起来是不是舒服多了?没有一层套一层的 Promise 嵌套,也没有手动去判断什么请求序号。哪个请求是新的,把旧的 controller.abort() 一调,旧请求的 promise 就 reject 掉,我们只要在 catch 里判断错误名是不是 AbortError 就行。

关于 AbortSignal.timeout 的补充

刚才代码里自己写了 2 秒超时,其实现在浏览器里有一个更直接的 API 叫 AbortSignal.timeout()。它可以返回一个已经封装好定时器行为的 signal,时间一到自动触发 abort。配合 withResolvers 可以写成更简洁的版本:

function createSearchRequest(keyword, { signal } = {}) {
    const { promise, reject } = Promise.withResolvers();
    const controller = new AbortController();

    // 超时信号 和 外部信号,任意一个触发都让整个请求 abort
    function onAbort() {
        controller.abort();
        reject(new DOMException('请求已取消', 'AbortError'));
    }

    const timeoutSignal = AbortSignal.timeout(2000);
    const combinedSignal = AbortSignal.any([timeoutSignal, signal]);

    combinedSignal.addEventListener('abort', onAbort, { once: true });

    fetch(`/api/search?q=${keyword}`, { signal: controller.signal })
        .then(res => {
            combinedSignal.removeEventListener('abort', onAbort);
            return res.json();
        })
        .then(resolve)
        .catch(err => {
            combinedSignal.removeEventListener('abort', onAbort);
            reject(err);
        });

    return promise;
}

这里用到了 AbortSignal.any,这也是个新东西,可以把多个 signal 组合成一个,只要任何一个 signal 触发 abort,组合出来的 signal 也会跟着 abort。这个组合 API 已经得了广泛的浏览器支持,Chrome 116+ 就有了。

所以你看,现在的原生 JavaScript 已经能把以前想破头的异步控制变得像拼积木一样。有新 API 不用,硬靠旧语法绕来绕去,那才叫真的累。

跟 React/Vue 配合的实战片段

如果你在 React 里写搜索组件,你可以直接用 useEffect 的 cleanup 函数来触发 abort,代码变成这样:

useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
        try {
            const data = await api.search(keyword, { signal: controller.signal });
            setList(data);
        } catch (err) {
            if (err.name !== 'AbortError') {
                setError(err);
            }
        }
    }

    fetchData();

    return () => controller.abort();
}, [keyword]);

注意这里的 api.search 内部就是用了前面我们封装的那个 withResolvers 的函数。这样组件卸载时或者关键词变化时,旧请求会被自动取消,不会出现”内存泄漏“或”状态更新无效“的警告。

Vue 里也差不多,在 watch 或 onUnmounted 里调用 abort:

let controller = null;

watch(keyword, async (val) => {
    controller?.abort();
    controller = new AbortController();
    try {
        const data = await api.search(val, { signal: controller.signal });
        state.list = data;
    } catch (e) {
        if (e.name !== 'AbortError') {
            state.error = e.message;
        }
    }
});

几个你可能没注意到的细节

第一,Promise.withResolvers 跟我们平时使用 new Promise 没有本质区别,它只是一个简写。所以在没有这个原生方法的老浏览器里,你自己写 polyfill 也非常简单:

if (!Promise.withResolvers) {
    Promise.withResolvers = function () {
        let resolve, reject;
        const promise = new Promise((res, rej) => {
            resolve = res;
            reject = rej;
        });
        return { promise, resolve, reject };
    };
}

第二,使用 AbortSignal.timeout 时,如果请求在超时前完成了,记得把定时器清理掉,否则会有一个无用的定时器引用,虽然影响不大,但密集场景下也浪费点性能。上面的示例代码里通过 addEventListener 和 removeEventListener 处理了,这点要留意。

第三,有些老代码喜欢在 catch 里判断 message 是不是“Aborted”,这个很不靠谱。请用 err.name === 'AbortError' 来判断,这是规范标准。

这些新 API 让我感觉 JavaScript 真的在变好

我并不是一个喜欢追新语法的人,以前甚至觉得 Promise 能用的功能干嘛要一堆新 API。但这次 Promise.withResolvers 结合 AbortSignal.any 确实让我在真实项目里减少了很多样板代码。以前要写二十多行的控制器逻辑,现在十行内解决,而且逻辑更清晰了。

写前端这行,有时候真不是技巧多牛逼,而是能不能用最简单的方式把需求落地。如果你也经常被异步竞态、超时取消折磨,不妨试试这几个新伙计。浏览器原生支持已经相当好了,完全不用等构建工具转换。

对了,如果你用的是 TypeScript,注意版本需要 5.6 以上才包含这些新 API 的类型定义。不然你会看到一堆红色波浪线,非常扫兴。

这就是我这段时间在异步控制上的一些折腾。代码贴在上面,感兴趣的可以直接拿去跑一跑。为什么不用框架?其实框架底层做同样的事也就是这么几行。清楚了原理,以后用什么都不怕。

从前端竞态到超时取消,Promise.withResolvers 把异步控制变得像聊天一样简单
收藏 (0) 打赏

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

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

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

淘吗网 javascript 从前端竞态到超时取消,Promise.withResolvers 把异步控制变得像聊天一样简单 https://www.taomawang.com/web/javascript/2489.html

常见问题

相关文章

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

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