为何说AbortController无法真正取消请求?

前有科技后进阶 2025-01-10 04:20:34

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

1. 浏览器如何取消 HTTP 请求

首先需要弄懂以下几个点:

没有取消 HTTP 请求这回事HTTP 规范不允许在请求从客户端发送到服务器后取消或中止请求一旦请求发出则会经过各种路由到达服务器,客户端无法阻止

浏览器可以做的是选择直接中断网络请求 (Kill the Pathway) 或者忽略响应,即请求发出后则完全 “不管” 它。

对于 HTTP/1.1 来说,每个请求对应 1 个 TCP 连接,取消请求时浏览器只需关闭 TCP 连接 (keep-alive 除外)。对于 HTTP/2, 每个 TCP 连接服务多个请求,浏览器可能会关闭处理连接的特定流或关闭整个 TCP 连接。

当然,浏览器具有复杂的内部工作原理,每个浏览器的处理方式也可能不同。下面是 Chromium Fetch 的源码实现:

ScriptPromise<Response> FetchManager::Fetch(ScriptState* script_state, FetchRequestData* request, AbortSignal* signal, ExceptionState& exception_state) {if (signal->aborted()) { // 如果 aborted 则直接将 Promise 设置为 rejected return ScriptPromise<Response>::Reject(script_state, signal->reason(script_state));}request->SetDestination(network::mojom::RequestDestination::kEmpty);auto* resolver = MakeGarbageCollected<ScriptPromiseResolver<Response>>( script_state, exception_state.GetContext());// 垃圾回收auto promise = resolver->Promise();auto* loader = MakeGarbageCollected<Loader>( GetExecutionContext(), this, resolver, request, script_state, signal);loaders_.insert(loader);// TODO(ricea): Reject the Response body with AbortError, not TypeError.loader->Start(exception_state);return promise;

从 Chromium 源码来看,如果一个请求被 aborted 则立即将 Promise 的状态转为 rejected,否则在处理响应后调用相应的垃圾收集器。

值得注意的是,Chromium 在 HTTP/1.1 和 HTTP/2.0 中针对 aborted 请求的 TCP 连接的处理可以自行搜索相应资料。

2. Node.js 通过 AbortController 取消 fetch

Node.js 中官方 fetch 实现的维护者针对 HTTP 请求的取消问题也做了如下回复:

中止请求意味着必须停止下载,并告诉服务器客户端不介意没有收到响应。由于 除了在 TCP 级别关闭之外没有其他方法,因此 Undici 会在响应主体 body 被消费后立即执行此操作 。因此,即使客户端不想使用响应体也要销毁,特别是当响应体不大的时候。

而针对 Undici 会有两种方法消费响应主体:

抛出错误前消费响应体const fetchAndValidate = async (resource, options) => { const res = await fetch(resource, options); if (!res.ok) { for await (const _ of res.body) { } throw new Error(`HTTP ${res.status} - ${res.statusText}`); } return res;};抛出错误前取消请求const fetchAndValidate = async (resource, options) => { const ac = new AbortController(); const res = await fetch(resource, { ...options, signal: ac.signal}); if (!res.ok) { ac.abort(); // 取消请求 throw new Error(`HTTP ${res.status} - ${res.statusText}`); } return res;};

第一个方法要求开发者进行迭代并等待每个 chunk 被消耗(使用 AsyncIterator),而第二个方法在内部使响应体 body 被立即丢弃以便进行 GC。

值得注意的是,虽然 request 的 body 被丢弃,但连接如果设置了 keep-alive 则依然不会立即关闭。如果还要自定义 TCP 的行为,开发者可以为 fetch 提供一个自定义代理。

import {fetch, Agent} from "undici";const res = await fetch("https://example.com", { // Mocks are also supported dispatcher: new Agent({ keepAliveTimeout: 10, keepAliveMaxTimeout: 10, }),});const json = await res.json();console.log(json);3. 为什么要取消请求

需要取消请求的场景主要包括:

节省带宽:想象一下搜索栏,即使在 Debounce 之后仍然可以触发多个请求,但只有最后一个请求才重要。因此,开发者可以选择取消所有先前的请求。竞争条件:在搜索栏示例中,如果最后一个请求在先前的请求之前完成,则会出现竞争条件。上一个请求稍后完成,并且 UI 状态会使用先前的搜索词结果进行更新,从而导致用户看到错误的数据。不必要的执行逻辑:例如触发请求的组件或页面已卸载而不再需要该请求。如果不取消请求,则回调函数仍会被触发,从而执行不必要的计算。参考资料

https://medium.com/@itskishankumar98/api-cancellation-on-the-browser-5504bb68c974

https://stackoverflow.com/questions/69195007/what-happends-after-a-fetch-request-is-aborted-via-abortcontroller

https://github.com/nodejs/undici/discussions/2194

https://www.zhihu.com/question/5169164365/answer/42056407107

https://x.com/SimonHoiberg/status/1438766456959799297

https://samthor.au/2023/abortcontroller-for-expiry/

https://www.youtube.com/watch?v=IBBpTG8FZYU

0 阅读:0
前有科技后进阶

前有科技后进阶

感谢大家的关注