fetch 与 AbortController
fetch 请求全解:流式读取、超时取消与错误语义。
fetch 与 AbortController
fetch 是浏览器发起 HTTP 请求的标准接口,它返回 Promise,并围绕 Request 与 Response 两个对象组织数据;AbortController 则为请求提供了统一的取消开关。本篇以虚拟歌手音乐平台的购票场景为例,讲清 fetch 的对象模型、响应体流式读取、取消与超时控制、容易被误读的错误语义,最后封装一个生产可用的可取消请求函数。
前置知识
- 宿主环境与 Web API 总览:fetch 与 AbortController 都是宿主环境提供的 Web API。
- Web Streams 数据流:响应体流式读取依赖 ReadableStream 概念。
- 网络存储:请求结果常与本地缓存策略配合使用。
学习目标
- 能描述 fetch、Request、Response 三者的关系,并知道 Response 只能读取一次。
- 能用
response.body.getReader()分块读取大文件并计算下载进度。 - 能用 AbortController 实现手动取消与超时取消,并正确区分 AbortError 与网络错误。
- 能解释”fetch 只在网络层失败时 reject”的错误语义,避免吞掉 404/500。
- 能封装一个集状态检查、超时、最新请求自动取消于一体的请求函数。
一、fetch 基础:Request 与 Response 对象
fetch(input, init) 的第一个参数可以是 URL 字符串,也可以是一个 Request 实例;返回的 Promise 在响应头到达时即兑现,此时响应体可能还在传输中。Response 对象提供状态信息(status、ok、headers)与多种消费方式:json()、text()、blob()、arrayBuffer() 以及底层的 body 流。相比被它取代的 XMLHttpRequest,fetch 的回调层级更浅、与 Promise 及 Service Worker 的契合度更高,也是理解现代前端网络层的入口。Request 实例的价值在于可复用:同一个请求对象可以被多次 fetch(配合各自的 signal),也可以脱离调用点独立构造与测试。
// 用 Request 对象描述一次"查询演唱会余票"的请求
const request = new Request('https://api.fandex.dev/concerts/42/tickets', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const response = await fetch(request);
console.log(response.status, response.ok); // 200 true(ok 表示状态码在 200-299)
// Response 是一次性对象:body 只能读一次
const data = await response.json();
console.log(`剩余票数:${data.remaining}`);
// await response.json(); // 再读一次会抛 TypeError: body stream already read“body 只能读一次”是最常踩的坑之一:如果既想打印日志又想解析 JSON,需要先调用 response.clone() 获得一个可以独立消费的副本。
fetch 的第二个参数 init 汇集了请求的全部配置:method 指定 HTTP 动词,headers 携带头部,body 承载请求体,credentials 决定是否携带 Cookie,cache 与 mode 分别控制缓存策略与跨域语义。购票提交这类 POST 请求需要显式序列化 JSON 并声明 Content-Type:
// POST 提交购票请求:JSON 序列化 + 显式声明 Content-Type
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ concertId: '42', zone: 'S 区', count: 2 }),
});
if (!response.ok) throw new Error(`下单失败:HTTP ${response.status}`);
const order = await response.json();
console.log(`下单成功,订单号 ${order.orderId}`);二、响应体流式读取
演唱会现场直拍视频、全曲库打包下载这类大资源,如果等全部下载完才给用户反馈,体验会非常差。response.body 是一个 ReadableStream,可以用 reader 分块拉取,边下载边更新进度:
// 流式下载演唱会直拍视频,边下载边输出进度
const response = await fetch('/videos/magical-mirai-2026.mp4');
const total = Number(response.headers.get('Content-Length')) || 0;
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read(); // value 是 Uint8Array 分块
if (done) break;
received += value.byteLength;
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
console.log(`已下载 ${mb(received)}${total ? ` / 共 ${mb(total)}` : ''}`);
}
console.log('下载完成');这段”手动拉流”的写法是理解响应体本质的最佳途径:json()、text() 等便捷方法只是”读完整个流并解析”的快捷方式,底层一律是同一个 ReadableStream。掌握 reader 循环后,模块内 Web Streams 一篇中的管道变换、跨流对接等内容都会变得顺理成章;自定义进度条、断点续传记录偏移量等需求也都建立在这个循环之上。
分块数据是二进制的 Uint8Array;如果内容是文本(例如直播弹幕流),可以用 TextDecoder 的流式模式把块安全地解码成字符串。流式读取还让”边下边播、边下边渲染”成为可能,这也是许多播放器实现的首选路径。
// 文本流解码:直播弹幕按块到达,用 stream 模式避免拆散多字节字符
const reader = (await fetch('/live/danmaku')).body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 让跨块被截断的多字节字符在下一次解码时自动补全
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 末段可能不完整,留到下一块再处理
lines.forEach((line) => renderDanmaku(line));
}三、AbortController:取消与超时
用户切换演唱会、关闭弹窗、输入新关键词时,上一批在途请求已经没有意义,不仅浪费流量,还可能让旧响应晚到并覆盖新结果。AbortController 通过 signal 把”取消开关”交给 fetch:
// 最新请求获胜:新一轮搜索会取消上一次仍在途中的请求
let controller = null;
async function searchSongs(keyword) {
controller?.abort(); // 取消上一次搜索
controller = new AbortController();
try {
const res = await fetch(`/api/songs?q=${encodeURIComponent(keyword)}`, {
signal: controller.signal, // 把取消开关挂到本次请求上
});
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('旧搜索已取消,忽略即可'); // 主动取消不是失败
return [];
}
throw err; // 网络错误等其他异常照常上抛
}
}超时本质上是”由定时器代替用户执行 abort”。现代浏览器提供了现成的 AbortSignal.timeout(ms),不需要手写 setTimeout;如果需要同时支持”手动取消”与”超时”,可以用 AbortSignal.any 把多个信号合并:
// 秒杀开票瞬间服务器可能拥堵,10 秒拿不到结果就放弃
const res = await fetch('/api/flash-sale/tickets', {
signal: AbortSignal.timeout(10_000), // 到时自动 abort,抛出 TimeoutError
});
// 手动取消 + 超时二合一:任一信号触发即中止请求
function abortableFetch(url, timeoutMs) {
const controller = new AbortController();
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(timeoutMs),
]);
return { promise: fetch(url, { signal }), cancel: () => controller.abort() };
}signal 与 controller 的关系值得强调:一个 controller 只能 abort 一次,但它的 signal 可以同时挂到多个请求上——controller.abort() 会一次性取消所有使用该信号的请求。这正好适合”离开购票页时撤销整批在途请求”的场景:整批请求共享同一个 signal,组件卸载回调里一行 controller.abort() 即可全部撤回。
四、错误语义与 HTTP 状态陷阱
fetch 的 Promise 只在网络层失败(断网、DNS 解析失败、被 CORS 拦截、请求被 abort)时 reject;HTTP 404、500 等状态码依然会正常兑现。也就是说,“进入了 then 不代表业务成功”。必须显式检查 response.ok 或 status:
// 加载演唱会详情:先检查状态,再消费响应体
async function loadConcert(id) {
const res = await fetch(`/api/concerts/${id}`);
if (!res.ok) {
// ok 为 false 时状态码在 200-299 之外,例如 404、500
throw new Error(`加载演唱会失败:HTTP ${res.status}`);
}
return res.json();
}
loadConcert('42').catch((err) => {
// 只有网络错误与上面手动抛出的错误会进入这里
showRetryButton(err.message);
});一个反直觉的细节是:收到 404 后 response.json() 可能仍然成功(很多接口的错误响应也是 JSON)。如果先 json() 再判断状态,错误信息会被当作正常数据流进应用层,出现”界面显示一堆 undefined”的怪象。固定顺序应是:先看 ok,再读 body。
跨域配置错误在 fetch 中的表现同样是 reject,错误信息为 TypeError: Failed to fetch,与断网、DNS 失败的表象完全一致。排查时不要盲目重试,先打开 Network 面板确认请求是否被 CORS 策略拦截:预检请求(OPTIONS)失败、响应缺少 Access-Control-Allow-Origin 头,都会以这种”伪装成网络故障”的形式出现。
重试策略也要按状态码区分:404 表示资源不存在,重试没有意义;429 与 5xx 表示暂时性故障,配合指数退避的重试才有价值;401 交给统一的鉴权刷新逻辑。把”哪些状态码可重试、重试几次、间隔多少”写进封装的配置项,是请求层从”能用”走向”成熟”的标志之一。
五、封装一个可取消请求函数
把前四节的知识组合起来:统一的状态检查、超时控制、最新请求自动取消。封装成一个小工具后,购票页的每次查询都会自动作废上一次请求:
// 封装:带状态检查 + 超时 + 最新请求自动取消的 GET 工具
function createLatestGet(timeoutMs = 8000) {
let current = null; // 记录"当前有效"的那次请求的控制器
return async function get(url) {
current?.abort(); // 新请求到来,作废旧请求
current = new AbortController();
// 合并"手动取消"与"超时"两个信号,任一触发即中止
const signal = AbortSignal.any([
current.signal,
AbortSignal.timeout(timeoutMs),
]);
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`请求失败:HTTP ${res.status}`);
return res.json();
};
}
const getTickets = createLatestGet();
getTickets('/api/concerts/41/tickets'); // 用户切换场馆时
getTickets('/api/concerts/42/tickets'); // 第一次请求被自动取消,只有这次结果生效封装时还有两点值得顺手处理:其一,给调用方保留主动取消的能力(返回带 cancel 的对象或接受外部 signal);其二,把 AbortError 归一化为可识别的错误类型,避免上层把”用户主动取消”当成失败弹出报错弹窗。如果团队同时有 POST 场景,可以把同样的骨架推广为 createLatestRequest:把请求方法与请求体一并纳入参数,取消与超时逻辑完全复用。封装的边界也要守住——它只负责”传输层的横切关注点”,鉴权头注入、响应 schema 校验这类业务性逻辑应放在各自的层里,保持每层可独立测试。如果调用方还需要把自己的取消信号并入(例如路由离开时统一撤销),封装应预留 signal 参数,用 AbortSignal.any 把内部信号与外部信号合并后传给 fetch,三层取消(外部路由、内部最新请求、超时)互不冲突。取消体系一旦分层清晰,“请求发出去了但没人要结果”的资源浪费与状态错乱就都有了系统性出口。
六、文件上传与下载
fetch 的日常外延是文件传输:上传用 FormData 承载表单与文件,下载把响应转成 Blob 再交给浏览器保存。
// 文件上传:使用 FormData 自动设置 multipart/form-data
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('metadata', JSON.stringify({ name: file.name }));
// 注意:不要手动设置 Content-Type,浏览器会自动添加 boundary
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
return response.json();
}
// 带进度监控的上传:Fetch 暂不支持上传进度,需回退到 XMLHttpRequest
function uploadWithProgress(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
});
xhr.addEventListener('load', () => resolve(JSON.parse(xhr.responseText)));
xhr.addEventListener('error', () => reject(new Error('上传失败')));
const formData = new FormData();
formData.append('file', file);
xhr.send(formData);
});
}
// 文件下载:将 Blob 转为可下载 URL
async function downloadFile(url, filename) {
const response = await fetch(url);
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
a.click();
// 释放 Blob URL,避免内存泄漏
URL.revokeObjectURL(downloadUrl);
}三个易错点就藏在注释里:FormData 场景手动设置 Content-Type 会丢失 boundary,服务端直接解析失败;上传进度是 fetch 的能力空白(response.body 只覆盖下载方向),需要进度条时回退 XHR 的 upload.progress;URL.createObjectURL 创建的 Blob URL 不会自动回收,用完必须 revokeObjectURL,否则大文件会一直驻留内存。
6.1 Fetch 与 XMLHttpRequest 怎么选
| 维度 | Fetch | XMLHttpRequest |
|---|---|---|
| 设计范式 | Promise,链式调用 | 事件回调 |
| 取消能力 | 原生 AbortController | 调用 xhr.abort() |
| 上传进度 | 不支持(需 Stream API) | 原生支持 upload.progress |
| 流式响应 | 支持 response.body.getReader() | 不支持 |
| 超时控制 | AbortSignal.timeout() | xhr.timeout |
| 浏览器兼容 | IE 不支持 | 全部支持 |
| 代码简洁度 | 高 | 低 |
选型结论:新项目一律 Fetch;唯一需要 XHR 的场景是上传进度条(上文的 uploadWithProgress)。
七、工程实践:带重试与外部取消信号的封装
第五节的 createLatestGet 管住了「最新请求获胜」,生产环境还差一块:瞬时故障的重试(网络抖动、5xx),以及「调用方随时可以喊停」的外部取消。下面的 http 函数把超时、指数退避重试、外部信号传播三件事收进一个入口:
/**
* 生产级 Fetch 封装:支持超时、重试、取消、统一错误处理
* @param {string} url 请求 URL
* @param {Object} options Fetch 配置
* @param {number} options.timeout 超时毫秒,默认 10 秒
* @param {number} options.retries 重试次数,默认 0
* @param {AbortSignal} options.externalSignal 外部传入的取消信号
* @returns {Promise<Response>} Fetch Response
*/
async function http(url, options = {}) {
const {
timeout = 10000,
retries = 0,
externalSignal,
...fetchOptions
} = options;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// 监听外部信号:外部取消时取消内部请求
const onExternalAbort = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', onExternalAbort, { once: true });
}
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (externalSignal) {
externalSignal.removeEventListener('abort', onExternalAbort);
}
if (!response.ok) {
throw new HttpError(response.status, await response.text());
}
return response;
} catch (err) {
clearTimeout(timeoutId);
if (externalSignal) {
externalSignal.removeEventListener('abort', onExternalAbort);
}
// 外部取消不重试
if (err.name === 'AbortError' && externalSignal?.aborted) throw err;
lastError = err;
// 最后一次尝试不再重试
if (attempt === retries) break;
// 指数退避
const backoff = Math.min(1000 * Math.pow(2, attempt), 30000);
await new Promise((r) => setTimeout(r, backoff));
}
}
throw lastError;
}
class HttpError extends Error {
constructor(status, body) {
super(`HTTP ${status}`);
this.status = status;
this.body = body;
}
}逐段看设计取舍:每次尝试新建 controller——复用会撞上第五节易错点 5(abort 一次即报废);外部信号只在 aborted 时或通过一次性监听器传播——{ once: true } 确保监听器不堆积,removeEventListener 在每个出口都清理;外部取消不重试——用户已经不要结果了,重试是浪费;指数退避封顶 30 秒——防止服务恢复瞬间被重试风暴二次打挂。更系统的重试策略(按状态码分类、抖动、熔断)见 fetch 与 Web Streams 的请求重试与熔断一章。
延伸:Fetch 的形式化语义与取消的传播路径
(本节为总览篇迁入的形式化补充,供需要精确语义的读者选读。)
Fetch 操作可建模为一个三元组 。设 为 Request, 为 AbortSignal,则 Fetch 是从 Request 与 AbortSignal 到 Response Promise 的函数:
Response 内部是一个可读流,其类型签名为:
ReadableStream 满足下列异步迭代器协议:
取消的传播路径:当调用 controller.abort() 时,AbortSignal 触发 abort 事件,Fetch 内部传播取消信号到底层网络栈:
对于已发出的 HTTP 请求,浏览器会关闭 TCP 连接,导致服务器端可能收到 ECONNRESET。因此取消 Fetch 不能保证服务器未收到请求——购票扣款这类写操作不能依赖「取消 = 没发生」,业务层需配合幂等性设计(请求带幂等键,服务端去重)。
动手实践(补充)
- 可取消且带重试的下载器:结合本篇
http封装与第五节createLatestGet,实现「列表页快速切换时自动作废旧请求 + 网络抖动自动重试两次」的商品详情拉取。参考要点:每次尝试新建 controller;外部 signal 通过addEventListener('abort', ..., { once: true })桥接内部 controller;外部触发的取消直接上抛不重试。
易错点与最佳实践
- Response body 只能读一次。错误代码与修正:
const res = await fetch('/api/concerts/42');
// await res.json(); console.log(await res.text()); // 反例:第二次读取直接抛错
// 修正:先 clone 出副本,两个 body 各自独立消费
const copy = res.clone();
console.log(await res.json(), await copy.text());-
以为 catch 能接住 404/500。fetch 只在网络层失败时 reject,HTTP 错误状态需要用
res.ok显式判断后手动抛出,否则错误数据会静默进入业务层。 -
把 AbortError 当作真正的异常处理。用户主动取消或超时触发的
AbortError(超时为TimeoutError)不应弹错误提示、不应计入失败埋点,应按名字识别后静默处理。同时注意取消后的界面收尾:如果请求前把按钮置为”加载中”,取消路径也要把状态复位,否则界面会永远停在等待态。 -
拼接 URL 时不做编码。搜索关键词、歌曲名可能包含空格、中文或
&等字符,必须用encodeURIComponent(keyword)编码查询参数,否则请求会被拆解成完全不同的语义。这一点在服务端同样成立:拼接行为要有统一的查询串构建函数,而不是在每个调用点手写模板字符串。 -
信号对象跨请求复用。一个
AbortController只能 abort 一次,重复使用会让后续请求一开始就处于取消状态;应当为每次请求创建新的控制器。 -
POST JSON 请求体未声明 Content-Type。
body: JSON.stringify(data)只负责序列化,服务端拿到的是一串无法识别的文本。修正:init 中显式携带headers: { 'Content-Type': 'application/json' },让服务端按 JSON 解析请求体。
本篇小结
- fetch 以 Request 描述请求、以 Response 表达结果;响应头先到、响应体异步到达,body 是一次性流。
- 大资源用
response.body.getReader()分块读取,可实时计算进度;文本流配合TextDecoder解码。 - AbortController 为请求提供取消开关;
AbortSignal.timeout实现超时,AbortSignal.any组合多路取消。 - fetch 只在网络层失败时 reject,业务成败必须检查
response.ok,先判状态再读 body;重试策略按状态码分类才有意义。 - 封装统一的请求工具可以把状态检查、超时、最新请求获胜这三个模式沉淀为团队默认实践。
动手实践
-
带进度条的下载器:实现
downloadWithProgress(url, onProgress),在流式读取中把百分比回调给调用方。思路:Content-Length提供 total,received累加得到 loaded,注意服务器可能不返回该头部,此时退化为”已下载字节数”展示;进阶可以把 reader 循环放进 Worker,验证大文件下载期间主线程的动画帧率是否稳定。 -
搜索联想的防抖与取消结合:在输入框上实现”停止输入 300ms 后才请求,且新请求自动取消旧请求”。思路:
setTimeout延迟发请求,配合本篇createLatestGet的取消逻辑;思考为什么”取消”不能完全替代”防抖”——前者回收结果,后者减少请求次数,二者关注点不同。 -
并发限流的批量拉取:需要为 20 场演唱会拉取余票,但要求最多同时 4 个在途请求,且页面离开时全部取消。思路:用信号量或分批
Promise.all控制并发,把同一个AbortController.signal传给所有请求,controller.abort()一次即可整体撤回。
附录:fetch 语法速查
(自总览篇迁入,供复习时快速对照。)
基础请求
// 返回 Promise<Response>
const res = await fetch("/api/user");
const data = await res.json();带请求配置
// POST JSON
const res = await fetch("/api/user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tom" }),
});错误与状态处理
// fetch 仅在网络错误时 reject
if (!res.ok) throw new Error(`HTTP ${res.status}`);中止请求
// 超时或取消请求
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
const res = await fetch("/api", { signal: ctrl.signal });读取响应流
// 流式读取大响应(完整展开见 450 篇)
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value); // Uint8Array 分块
}