配了多年 fetch abort,今天才发现取消的 reason 在 Response 层面从来没传过来——Chrome 154 把这件事彻底修好了
痛点故事
你写了一个搜索组件:用户每次按键都发一个新请求,上一个请求用 AbortController.abort('user typed') 取消。取消时传了 reason = 'user typed newer request'。
问题是:下游代码拿到被 abort 的 Response 后,读 response.body 得到的是一个已取消的 ReadableStream,catch 块里只有 AbortError 字符串——你亲手传的那个 reason 去哪了?
答案是:之前根本传不过去。reason 只在 signal.reason 上有,Response 层面完全感知不到。Chrome 154 把这件事修掉了。
旧行为(Chrome 153 及以前)
const controller = new AbortController();
// 下游代码拿到 response 和 signal
async function fetchData(signal) {
const response = await fetch('/api/search', { signal });
return response;
}
// 调用方
controller.abort('user navigated away');
try {
const resp = await fetchData(controller.signal);
// resp 本身不知道被 abort 了
// signal.reason === 'user navigated away'
// 但 resp.body / resp.json() 层面没有这个 reason
const data = await resp.json(); // 可能抛出异常,但 reason 信息丢失
} catch (e) {
// e.name === 'AbortError'
// e.message === 'The user aborted a request.'
// signal.reason === 'user navigated away' — 只有 signal 上有
// Response 层面完全不知道原始 reason
}
问题总结:
response.bodycancel 后,reason 信息只存在于 signal.reason- 中间件/拦截器层如果只持有 response,读不到 reason
- 无法区分「用户取消」「超时」「主动放弃」等不同 abort 场景
新行为(Chrome 154)
Chrome 154 将 abort reason 传递到 Response 对象及其 ReadableStream 的方法中。现在你在 response.json() 的 reject 中也能拿到原始 reason:
const controller = new AbortController();
const reason = new Error('timeout');
async function fetchData(signal) {
const response = await fetch('/api/search', { signal });
// Chrome 154: body 方法层面也能感知到 abort reason
return response;
}
controller.abort(reason);
try {
const resp = await fetchData(controller.signal);
// 之前: response.body 方法层面读不到 reason
// 现在: body 方法会传播 reason
const data = await resp.json();
} catch (e) {
// Chrome 154: e.cause === reason (原始 Error 对象)
// 可以通过 e.cause 判断具体的 abort 原因
if (e.cause === reason) {
console.log('This fetch was aborted due to timeout');
}
}
核心变化:
AbortController.abort(reason)的reason通过response.body的 ReadableStream 传播- catch 块中
error.cause即为调用方传入的原始 reason 对象 signal.reason和 body 方法现在都能拿到同一个 reason- 规范对齐:WHATWG Fetch 规范一直要求这样,Chrome 之前实现不完整
实际应用场景
1. 搜索防抖:区分取消原因
class SearchService {
constructor() {
this.controller = null;
}
async search(query) {
// 取消上一个请求,附带原因
if (this.controller) {
this.controller.abort('superseded by newer query');
}
this.controller = new AbortController();
const reason = { type: 'search', query, startTime: Date.now() };
try {
const resp = await fetch(`/api/search?q=${query}`, {
signal: this.controller.signal
});
const data = await resp.json();
return data;
} catch (e) {
// Chrome 154: 通过 e.cause 判断是否为超时
if (e.cause?.type === 'timeout') {
console.log('Search timed out for:', query);
}
// 判断是否为被新请求覆盖
if (e.cause?.type === 'superseded') {
// 忽略,这是正常的防抖取消
return null;
}
throw e;
}
}
timeout(ms) {
if (this.controller) {
this.controller.abort({ type: 'timeout', ms });
}
}
}
2. 中间件层 reason 传递
// 之前:中间件只知道 signal 被 abort,不知道具体原因
// 现在:中间件可以从 body error 的 cause 中拿到原始 reason
async function authMiddleware(request, fetch) {
const response = await fetch(request);
if (!response.ok) {
return response;
}
return response;
}
// 包装 fetch,自动传播 reason
const smartFetch = (signal) => async (input, init = {}) => {
const response = await fetch(input, { ...init, signal });
// 将 signal.reason 附加到 response
// Chrome 154 这步由浏览器自动处理
return response;
};
技术细节
规范来源: WHATWG Fetch 规范要求 abort reason 应该传播到 Response 及其 body 消费者。Chrome 154 之前只实现了「promise reject 时 reason 在 signal 上」,但 body 的 ReadableStream 取消时 reason 没有正确传播。
影响范围: 所有使用 fetch() + AbortController 并且在 abort 后继续读取 response.body 的代码。
兼容性: Chrome 154+(2026-09 稳定版)。Firefox 尚不支持(Firefox 158 无相关记录)。Safari 未知。这是规范对齐修复,不是新 API,无需特性检测——行为改变是向规范靠拢。
与其他 AbortController 特性的关系:
AbortSignal.timeout()— 超时用的 signal.abortAbortSignal.any()— 组合多个 signal- 本次修复是这些场景下 reason 传递的底层保障
结论
AbortController.abort(reason) 传的是什么,现在整个 fetch 链路都能看到了:signal 上有,Response.body 层面也有。这是 Chrome 154 对 WHATWG Fetch 规范的完整实现——之前只做了一半。
如果你在中间件、service layer 或流处理代码中处理被取消的 fetch,现在可以准确知道取消原因,而不只是「abort error」。这让 abort 错误处理从二元(取消/未取消)变成多元(超时/用户取消/新请求覆盖/组件卸载),调试和业务逻辑都会清晰很多。
下一步: 检查你的项目中是否有 fetch(...).catch(e => { /* 只判断 e.name === 'AbortError' */ }) 的代码——它们现在可以通过 e.cause 拿到具体 reason 了。
评论区
登录后可评论。