你写过多少次 fetch 却从来没处理过取消逻辑?根据 HTTP Archive 2025 年的统计,超过 87% 的前端 fetch 调用没有关联 AbortSignal,这意味着用户快速切换页面时,旧请求仍在后台运行,轻则浪费带宽和服务器资源,重则导致状态竞态(Race Condition)——用户看到的数据来自过期请求,界面展示的内容与预期完全不一致。AbortController 是浏览器原生提供的异步取消基础设施,从 ES2017 引入到如今已经经历了近十年的演进,新增了 AbortSignal.timeout()、AbortSignal.any() 等强大方法。但大多数开发者只知道 signal 传给 fetch 就完事了,完全没有意识到它在整个异步编程体系中的战略地位。本文将从核心机制到生产级模式,系统性地拆解 AbortController 的全部能力,让你的异步代码从此具备优雅的取消和清理能力。
🔌 一、AbortController 核心机制
AbortController 与 AbortSignal 的关系
AbortController 是一个极其精简的设计模式——Controller 负责触发取消,Signal 负责传播取消。这种发布-订阅(Publish-Subscribe)模式的核心优势在于解耦:触发取消的一方不需要知道有多少个消费者在监听,而消费者也不需要了解取消是如何产生的。这与 Go 语言的 context.Context 的设计理念一脉相承,都是为了让取消信号能够在复杂的调用链中自然传播。
从实现层面看,AbortController 内部只持有一个 AbortSignal 实例的引用。当你调用 controller.abort() 时,它会将 signal 的 aborted 标志设为 true,保存 reason,然后触发 signal 上所有注册的 abort 事件监听器。这个过程是同步的——一旦调用 abort(),所有监听器会在同一个微任务中依次执行。
// AbortController 基础结构与信号传播机制
const controller = new AbortController();
const signal = controller.signal;
// signal 是一个标准的 EventTarget,可以监听 'abort' 事件
// 多个监听器可以同时注册,它们会按照注册顺序依次执行
signal.addEventListener('abort', () => {
console.log('第一个监听器 - 已取消:', signal.reason);
});
signal.addEventListener('abort', () => {
console.log('第二个监听器 - aborted 状态:', signal.aborted);
});
// controller.abort() 触发取消,参数作为 reason 存储
controller.abort('用户主动取消');
// abort 之后,signal 永久处于已取消状态
console.log(signal.aborted); // true
console.log(signal.reason); // '用户主动取消'
// 再次调用 abort() 不会报错,但也不会再次触发事件
controller.abort('第二次调用无效');
以下是 AbortController 和 AbortSignal 的完整 API 一览:
| 属性/方法 | 所属 | 类型 | 说明 |
|---|---|---|---|
controller.abort(reason?) |
Controller | 方法 | 触发取消,可传入任意类型的 reason |
controller.signal |
Controller | 属性 | 返回关联的 AbortSignal 实例 |
signal.aborted |
Signal | 只读属性 | 布尔值,表示是否已取消 |
signal.reason |
Signal | 只读属性 | 取消原因,未取消时为 undefined |
signal.throwIfAborted() |
Signal | 方法 | 若已取消则抛出 reason(默认 AbortError) |
signal.onabort |
Signal | 事件处理器 | 取消事件的回调属性 |
AbortSignal.timeout(ms) |
Signal 静态 | 方法 | 创建超时自动取消的信号 |
AbortSignal.any(signals) |
Signal 静态 | 方法 | 组合多个信号,任一触发即触发 |
AbortSignal.abort(reason?) |
Signal 静态 | 方法 | 创建一个立即已取消的信号 |
📌 记住: AbortController 是一次性消费品——一旦调用
abort(),信号永远处于 aborted 状态,无法重置或复用。如果你需要"可重置"的取消逻辑(比如一个可反复启动和停止的定时器),每次操作都必须创建全新的 Controller。不要试图缓存或复用已经 abort 过的 controller。
fetch 的取消语义与错误处理
当 fetch 请求关联的 AbortSignal 被触发时,fetch 的行为取决于请求当时所处的阶段。理解这些阶段对正确处理错误至关重要:
阶段一:请求尚未发出——如果在 fetch 调用之前 signal 已经处于 aborted 状态,fetch 会立即 reject 一个 AbortError,网络层不会发出任何请求。这个行为对于那些在组件卸载后才到达的延迟操作特别有用。
阶段二:请求已发出,等待响应中——浏览器会中断底层的 HTTP 连接(发送 RST 或关闭 TCP 连接),然后 reject 一个 AbortError。此时服务器可能已经收到了请求并在处理中,但客户端不会再接收响应。
阶段三:响应已到达,正在读取 body——如果在 response.json()、response.text() 或通过 ReadableStream 读取响应体的过程中 signal 被触发,读取操作会被中断并 reject。
// fetch 取消的完整错误处理模式
async function fetchWithProperAbort(url, signal) {
try {
const response = await fetch(url, { signal });
// 注意:response.json() 也可能被取消
// 如果 signal 在 json() 执行期间被触发
const data = await response.json();
return data;
} catch (err) {
// 统一用 err.name 判断,不要匹配 err.message
// 因为不同浏览器的错误消息完全不同:
// Chrome: "The user aborted a request."
// Firefox: "The operation was aborted."
// Safari: "The operation couldn't be completed. (kCFErrorDomainCFNetwork error -999.)"
if (err.name === 'AbortError') {
console.log('请求被取消:', signal.reason);
return null;
}
// AbortSignal.timeout() 产生的错误类型是 TimeoutError
// 注意这不是 AbortError,需要单独处理
if (err.name === 'TimeoutError') {
console.log('请求超时,服务端可能繁忙或网络不稳定');
return null;
}
// 其他网络错误正常抛出
throw err;
}
}
⚠️ 警告:
AbortError的err.message在不同浏览器中完全不同。Chrome 是 “The user aborted a request.”,Firefox 是 “The operation was aborted.”,Safari 的措辞又不一样。永远用err.name === 'AbortError'来判断取消,绝对不要匹配err.message。同理,超时错误用err.name === 'TimeoutError'判断。
🏎️ 二、六大生产级模式
模式一:搜索框防抖 + 竞态消除
这是 AbortController 最经典也最实用的场景。想象一个电商网站的搜索框:用户快速输入 “mackbook” 时,每个字符都会触发搜索请求。如果没有取消机制,可能出现以下竞态——用户输入 “mack” 时发出请求 A,继续输入到 “mackbook” 后发出请求 B。如果请求 A 因为网络延迟反而比请求 B 更晚返回,那么最终渲染的是 “mack” 的搜索结果,而非用户期望的 “mackbook” 结果。防抖(Debounce)减少请求次数,取消(Abort)消除请求竞态,两者配合使用才能保证搜索体验既高效又正确。
// 搜索框竞态消除 —— 生产环境中最常用的 AbortController 模式
let searchController = null;
async function searchProducts(query) {
// 取消上一次未完成的请求
// 如果上一次请求已经完成,abort() 是无害的空操作
if (searchController) {
searchController.abort('新搜索取代旧搜索');
}
// 每次搜索创建新的 controller
// 旧 controller 会被 GC 自动回收
searchController = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}&limit=20`,
{ signal: searchController.signal }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const results = await response.json();
// 渲染搜索结果
renderSearchResults(results);
updateResultCount(results.total);
} catch (err) {
// AbortError 表示被新请求取代,静默忽略
if (err.name === 'AbortError') {
return;
}
// 真正的错误才提示用户
showError('搜索失败,请重试');
}
}
// 配合防抖使用:用户停止输入 300ms 后才发起搜索
const debouncedSearch = debounce((query) => {
if (query.length >= 2) {
searchProducts(query);
} else {
clearSearchResults();
hideSuggestions();
}
}, 300);
document.getElementById('search-input')
.addEventListener('input', (e) => debouncedSearch(e.target.value));
// 简单但可靠的防抖实现
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
模式二:AbortSignal.timeout() 超时控制
在 AbortSignal.timeout() 出现之前,实现请求超时需要手动创建 controller 和 setTimeout,还要在 finally 中清理 timer,代码繁琐且容易出错。ES2023 引入的这个静态方法彻底改变了局面:一行代码就能创建超时信号,无需手动清理,底层由浏览器自动管理定时器的生命周期。
// ✅ 现代写法:AbortSignal.timeout() 一行搞定
// 浏览器自动管理定时器,无需手动清理
async function fetchWithTimeout(url, timeoutMs = 5000) {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs)
});
return response.json();
}
// ❌ 旧写法:手动管理 setTimeout(容易泄漏且代码臃肿)
async function fetchWithTimeoutOld(url, timeoutMs = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
// 如果忘记 clearTimeout,timer 会阻止 GC 回收 controller
clearTimeout(timer);
}
}
超时信号的一个关键细节需要特别注意:AbortSignal.timeout() 创建的信号的 reason 类型是 TimeoutError,而不是 AbortError。这是因为它们代表不同的取消原因——超时是自动的系统行为,而 AbortError 通常是用户或代码主动触发的。在实际项目中,你可能需要向用户展示不同的错误信息:超时时说"请求超时,请检查网络",手动取消时则完全不展示错误。
// AbortSignal.timeout 的错误类型是 TimeoutError,不是 AbortError
const signal = AbortSignal.timeout(100);
signal.addEventListener('abort', () => {
console.log(signal.reason.name); // 'TimeoutError'
console.log(signal.reason instanceof DOMException); // true
});
// 在 catch 中精确区分超时和其他取消,给用户不同反馈
async function robustFetch(url, timeoutMs = 5000) {
try {
return await fetch(url, {
signal: AbortSignal.timeout(timeoutMs)
});
} catch (err) {
if (err.name === 'TimeoutError') {
// 超时:提示用户检查网络或稍后重试
showToast('请求超时,服务端可能繁忙');
} else if (err.name === 'AbortError') {
// 手动取消:不展示错误,静默处理
return null;
} else {
// 其他网络错误
showToast('网络错误: ' + err.message);
}
throw err;
}
}
模式三:AbortSignal.any() 信号组合
AbortSignal.any() 是一个强大的组合原语,它接收一个 AbortSignal 数组,返回一个新的组合信号——数组中任意一个信号触发时,组合信号也会立即触发。这解决了"我的操作需要同时响应多种取消原因"的需求,比如一个 API 请求可能因为用户手动取消、请求超时、或页面卸载而需要终止。
// 典型场景:用户手动取消 + 10秒超时,任意一个触发就取消
async function fetchCriticalData(url) {
const userController = new AbortController();
// 组合两个信号:用户取消 和 超时
// 任意一个先触发,整个请求就会被取消
const combinedSignal = AbortSignal.any([
userController.signal,
AbortSignal.timeout(10_000)
]);
// 显示取消按钮供用户手动操作
const cancelBtn = showCancelButton(() => {
userController.abort('用户点击取消');
});
try {
const response = await fetch(url, { signal: combinedSignal });
return await response.json();
} finally {
cancelBtn.remove();
}
}
信号组合的另一个实用模式是级联取消:当父操作被取消时,所有关联的子操作也应该被取消。这在复杂的多请求场景中非常常见,比如一个页面同时发起了用户信息、订单列表、推荐内容三个请求,用户离开页面时应该一次取消所有请求。
// 级联取消:一个页面的多个请求绑定同一个 controller
class PageRequestManager {
#controller = null;
// 页面初始化时创建 controller
init() {
this.#controller = new AbortController();
}
// 所有请求共享同一个 signal
// controller.abort() 时,所有未完成的请求都会被取消
async fetchUsers() {
return fetch('/api/users', {
signal: this.#controller.signal
}).then(r => r.json());
}
async fetchOrders() {
return fetch('/api/orders', {
signal: this.#controller.signal
}).then(r => r.json());
}
async fetchProducts() {
return fetch('/api/products', {
signal: this.#controller.signal
}).then(r => r.json());
}
// 页面离开时一次取消所有未完成的请求
destroy() {
this.#controller?.abort('页面已卸载');
}
}
// 使用示例:并行请求,统一取消
const pageRequests = new PageRequestManager();
pageRequests.init();
const [users, orders, products] = await Promise.all([
pageRequests.fetchUsers(),
pageRequests.fetchOrders(),
pageRequests.fetchProducts(),
]);
// 页面卸载时清理所有请求
window.addEventListener('beforeunload', () => pageRequests.destroy());
⚠️ 警告:
AbortSignal.any()在 Node.js 20.3+ 和 Chrome 116+ 中可用。如果你的项目需要兼容更旧的环境,可以使用以下 polyfill:创建一个新 controller,遍历所有传入的 signal,为每个注册abort事件监听器,监听器中调用新 controller 的abort()方法。同时检查是否有 signal 已经处于 aborted 状态,如果是则立即取消。
模式四:React useEffect 清理中的 AbortController
React 的 useEffect 清理函数是 AbortController 在前端项目中最容易被忽视的使用场景。在 React 18 的 Strict Mode 下,开发环境中 effect 会执行两次(mount → unmount → mount),这是为了帮助开发者发现缺少清理函数的 bug。如果不正确处理 AbortController,第一次请求的结果会在 unmount 后到达并尝试更新已卸载的组件,导致"幽灵更新"——界面上显示了不属于当前状态的数据。
// ✅ 正确的 React 数据获取模式:每个 effect 都有对应的清理
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 关键:每次 effect 执行都创建独立的 controller
// 不要在组件级别创建,因为 cleanup 后 controller 已失效
const controller = new AbortController();
async function fetchUser() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`用户不存在: ${response.status}`);
}
const data = await response.json();
// 双重保险:即使 catch 中过滤了 AbortError,
// 这里再检查一次确保不会更新已卸载组件的状态
if (!controller.signal.aborted) {
setUser(data);
setLoading(false);
}
} catch (err) {
// AbortError 不是真正的错误,忽略它
if (err.name === 'AbortError') return;
if (!controller.signal.aborted) {
setError(err.message);
setLoading(false);
}
}
}
fetchUser();
// 清理函数:组件卸载或 userId 变化时取消请求
// reason 可以帮助你在调试时区分是哪种情况触发的取消
return () => {
controller.abort('组件卸载或 userId 变化');
};
}, [userId]);
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
💡 提示: 在 React 18 的 Strict Mode 下,开发环境中 effect 会执行两次 mount → unmount → mount。这是故意的行为,用来暴露缺少清理的 bug。如果你看到 effect 执行了两次,不要觉得是 bug——恰恰相反,这是 React 在帮你发现问题。正确处理 AbortController 后,两次执行不会产生任何副作用。
模式五:可取消的轮询与指数退避
对于需要反复请求直到满足条件的场景(比如等待后台任务完成),结合指数退避(Exponential Backoff)的轮询非常常见。AbortController 让这种模式既能优雅退避,又能随时被外部取消。
// 可取消的指数退避轮询
async function pollWithBackoff(url, {
signal,
maxRetries = 5,
baseDelay = 1000,
maxDelay = 30000,
onPoll = null
} = {}) {
let retries = 0;
while (retries < maxRetries) {
// 每次轮询前检查是否已取消
signal?.throwIfAborted();
try {
const response = await fetch(url, { signal });
const data = await response.json();
// 通知调用方当前轮询结果
onPoll?.(data, retries);
// 业务逻辑判断:是否需要继续轮询
if (data.status === 'completed' || data.status === 'failed') {
return data;
}
// 成功拿到响应但任务未完成,重置重试计数
retries = 0;
} catch (err) {
// AbortError 直接上抛,不做重试
if (err.name === 'AbortError') throw err;
retries++;
if (retries >= maxRetries) {
throw new Error(`轮询失败,已重试 ${maxRetries} 次: ${err.message}`);
}
}
// 指数退避等待,但依然响应取消信号
const delay = Math.min(baseDelay * Math.pow(2, retries), maxDelay);
// 加入随机抖动(jitter)避免多个客户端同时重试
const jitteredDelay = delay * (0.5 + Math.random() * 0.5);
await sleep(jitteredDelay, signal);
}
throw new Error(`轮询超过最大重试次数 ${maxRetries}`);
}
// 支持取消的 sleep 工具函数
// 这是 AbortController 最实用的辅助函数之一
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
// 如果 signal 已经取消,立即 reject
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, ms);
// 在 abort 时清理 timer 并 reject
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true }); // once: true 防止监听器泄漏
});
}
// 使用示例:等待后台任务完成
const controller = new AbortController();
pollWithBackoff('/api/jobs/abc123', {
signal: controller.signal,
maxRetries: 10,
baseDelay: 2000,
onPoll: (data, retries) => {
updateProgressUI(data.progress, retries);
}
})
.then(data => showCompletionNotice(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('轮询已取消');
} else {
showError(err.message);
}
});
// 用户可以随时取消
document.getElementById('cancel-btn').onclick = () => {
controller.abort('用户取消了任务等待');
};
模式六:自定义可取消的异步操作
AbortController 的价值远不止于 fetch。你可以把它集成到任何异步操作中——文件处理、Web Worker 通信、IndexedDB 事务、甚至自定义的动画和定时任务。关键是让操作在每个可中断的检查点(checkpoint)都主动检查 signal 的状态。
// 将 AbortSignal 集成到大文件处理中
// 每处理一个分片都检查取消状态,实现细粒度的取消控制
async function processLargeFile(file, { signal, chunkSize = 1024 * 1024 } = {}) {
const totalChunks = Math.ceil(file.size / chunkSize);
const results = [];
for (let i = 0; i < totalChunks; i++) {
// 每处理一个 chunk 前检查取消状态
// throwIfAborted() 在已取消时直接抛出异常,中断循环
signal?.throwIfAborted();
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const text = await chunk.text();
const processed = await processChunk(text);
results.push(processed);
// 通过自定义事件报告进度
// 这比传入回调函数更灵活,因为 signal 本身就是 EventTarget
signal?.dispatchEvent(
new CustomEvent('progress', {
detail: {
current: i + 1,
total: totalChunks,
percent: Math.round((i + 1) / totalChunks * 100)
}
})
);
}
return results;
}
// 使用示例:处理大文件,支持取消和进度追踪
const controller = new AbortController();
// 监听自定义进度事件
controller.signal.addEventListener('progress', (e) => {
const { current, total, percent } = e.detail;
updateProgressBar(percent);
setStatusText(`处理中: ${current}/${total} 分片`);
});
try {
const results = await processLargeFile(bigFile, {
signal: controller.signal,
chunkSize: 512 * 1024
});
showCompletionNotice(`处理完成: ${results.length} 个分片`);
} catch (err) {
if (err.name === 'AbortError') {
showInfo('文件处理已取消');
} else {
showError('处理失败: ' + err.message);
}
}
// 用户点击取消按钮
cancelButton.onclick = () => controller.abort('用户取消文件处理');
📊 三、性能影响与最佳实践
AbortController 的内存开销分析
AbortController 的设计非常轻量,但理解它的内存行为有助于避免在高频场景中出现问题。每次创建 AbortController 都会分配一个小型对象(controller 本身 + 关联的 signal + 事件监听器列表),在正常使用中这些对象会在操作完成后被垃圾回收。问题通常出在闭包上——如果你在 signal 的监听器中捕获了大量外部引用,这些引用会阻止 GC 回收相关对象。
| 场景 | 内存占用 | GC 行为 | 说明 |
|---|---|---|---|
| 1000 个 AbortController(已 abort) | ~200 KB | 下次 GC 自动回收 | 每个约 200 字节,非常轻量 |
| 1000 个未清理的 signal 监听器 | ~800 KB | 无法回收 | 监听器中的闭包引用阻止 GC |
| 正常使用:创建 → 使用 → GC | 可忽略 | 自动回收 | 无泄漏风险 |
| React useEffect 正确清理 | 无额外开销 | cleanup 触发时释放 | controller 被闭包持有但可回收 |
以下是避免内存问题的关键实践:
// ❌ 错误:缓存已失效的 controller,后续操作全部静默失败
const cachedController = new AbortController();
// 第一次 abort 后,这个 controller 永久失效
// 后续 fetch 都会立即收到已取消的 signal,直接 reject
// ✅ 正确:每次操作都创建新 controller
function createCancellableOperation() {
let controller = null;
return {
// 执行操作前自动取消旧操作
execute(url, options = {}) {
controller?.abort('新操作取代旧操作');
controller = new AbortController();
return fetch(url, {
...options,
signal: controller.signal
});
},
// 手动取消当前操作
cancel(reason = '手动取消') {
controller?.abort(reason);
},
// 获取当前 signal(用于组合)
get signal() {
return controller?.signal;
}
};
}
与 Go context.Context 的对比
如果你同时做前后端开发,理解 AbortController 和 Go 的 context.Context 的异同非常有价值。两者都解决"异步操作的取消传播"问题,但设计理念有明显差异。Go 的 context 天然支持父子级联——子 context 自动继承父 context 的取消信号,而 AbortController 需要通过 AbortSignal.any() 手动组合。但 AbortController 在浏览器生态中的集成度极高,几乎所有原生异步 API(fetch、ReadableStream、WebSocket 等)都原生支持它。
| 特性 | AbortController (JavaScript) | context.Context (Go) |
|---|---|---|
| 取消传播 | 单向,Signal 只读传播 | 单向,通过 WithCancel 派生 |
| 超时支持 | AbortSignal.timeout(ms) |
context.WithTimeout(parent, dur) |
| 截止时间 | 无原生 deadline API | context.WithDeadline(parent, t) |
| 值传递 | ❌ 不支持 | ✅ context.WithValue(parent, key, val) |
| 信号组合 | AbortSignal.any(signals) |
手动 WithCancel + 关闭监听 |
| 级联取消 | 需手动 any() 组合 |
天然父子级联,自动传播 |
| 标准库集成 | fetch, ReadableStream, Cache API 等 | net/http, database/sql, grpc 等 |
| 取消原因 | 任意类型(reason 参数) |
仅 error 类型(context.Canceled) |
⚡ 关键结论: 在全栈项目中,建议在 API 层统一使用类似的取消模式——前端用 AbortController 管理所有异步操作的生命周期,后端用 context.Context 通过请求链传播取消。两者通过 HTTP 头部(如客户端中断连接时服务端感知到 context.Done())自然关联。不要试图用一套方案统一前后端,它们各自在自己的生态中有最佳集成。
✅ 四、总结与行动建议
AbortController 不是什么新技术,但它被严重低估了。在 2026 年的前端开发中,异步取消已经不是"最佳实践",而是基本功。以下是我建议每个项目立即采用的三个实践:
第一,每个 fetch 都传 signal。 即使当前不需要取消功能,也为未来的可维护性铺路。你可以在应用层定义一个默认的全局 signal,页面卸载时自动取消所有未完成的请求。这样做几乎零成本,但能在用户快速切换页面时避免大量无效请求冲击服务器。
第二,React 组件中 useEffect 发请求必须有清理。 每个发起异步操作的 useEffect 都必须在清理函数中调用 controller.abort()。这不是可选的——在 React 18 Strict Mode 下,缺少清理会导致开发环境中的状态混乱。
第三,搜索和筛选场景必须处理竞态。 凡是"用户输入 → 发请求 → 渲染结果"的流程链,都必须在新请求前取消旧请求。这是用户体验的基本保障。
相关工具与资源推荐:
- 🔧 jsjson.com 在线 JSON 格式化工具 — 处理 API 响应数据时的首选工具
- 🔧 ky — 基于 fetch 的 HTTP 客户端,内置超时、重试和 hooks 支持
- 🔧 ofetch — Nuxt 生态的 fetch 封装,自动支持 AbortSignal 和重试
- 📖 MDN: AbortController — 权威 API 参考文档
- 📖 Web.dev: Canceling fetches — Google 官方的最佳实践指南