不知道你写前端的时候有没有这种体验:明明只是发个请求,却要小心翼翼处理用户反复点击、请求乱序返回、超时没有反应这些破事。我干了四年前端,最烦的就是处理竞态和取消。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 这个方法很简单,你调用它,它会返回一个对象,里面有 promise、resolve、reject 三个东西。它的作用就是让你不用再把 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 的类型定义。不然你会看到一堆红色波浪线,非常扫兴。
这就是我这段时间在异步控制上的一些折腾。代码贴在上面,感兴趣的可以直接拿去跑一跑。为什么不用框架?其实框架底层做同样的事也就是这么几行。清楚了原理,以后用什么都不怕。

