Appearance
生命周期能力:取消、Loading、重试、文件传输
本页对应学习路径的阶段六。这四个能力互相独立,可以按需要挑着看;它们都挂在 上一页建立的「逻辑请求」生命周期上。
取消:两层都要管
text
逻辑请求控制器(client.ts)
├─ 调用方传入的 signal
└─ 客户端内部 controller
物理请求控制器(request-control.ts)
└─ 每次 Axios 发送各建一个cancelAll() 两层都要取消:正在退避等待、还没发出下一次尝试的请求只存在于逻辑 层;已经在传输途中的请求只存在于物理层。只取消一层都会漏。
合并信号优先用原生 AbortSignal.any(),没有就手写。手写那半有两个坑:必须返回 dispose(否则监听器泄漏),必须先检查有没有已经中止的信号(晚注册的监听器收不到 早已发生的 abort)。
还有一处容易忽略:物理请求结束后要把 config.signal 换回原来那个。config 对象会 被复用(401 重放就是拿同一个再发一次),留着上一轮那个已中止的合成信号,重放会在发出 瞬间就被判为取消。
Loading:为什么是布尔回调而不是 Adapter
计数只在 0↔1 边界通知外部:
text
第一个请求开始 → 计数 1 → onLoadingChange(true)
第二个请求开始 → 计数 2 → 不通知
第一个结束 → 计数 1 → 不通知
第二个结束 → 计数 0 → onLoadingChange(false)项目端只提供 onLoadingChange(active) 一个布尔回调,不为 Loading 定义 Adapter 接口。Adapter 抽象的价值在于「项目端存在多种实现需要替换」,而 Loading 的项目端 实现永远只有显示和隐藏两个动作,加一层接口和一个文件只会多出导入路径。
重试:三个保守决定
重试是「一次逻辑请求产生多次物理尝试」的第一个来源(另一个是 401 重放,见 下一页)。
决定一:写请求永不重试,即使调用方显式要求。
ts
const isSafeRead = ["get", "head", "options"].includes(method);
if (requestConfig.retry && isSafeRead) { ... }传输层看不出一个失败的写请求到底有没有在服务端落库。重试就可能变成重复下单。要重试 的写操作应当由业务层带幂等键自己发起。
决定二:次数上限之外还要有总时间预算。
只有 retries 时,指数退避会让总耗时迅速放大——retries: 5 配 baseDelayMs: 200, 光退避总和就接近 6 秒,再加上每次请求自己的 timeout,用户看到的是一个长时间不动 的 Loading。每一次都没超时,加起来却久得离谱。
预算检查放在准备退避之前,所以它只决定「要不要再试一次」,从不打断已经发出的 尝试——那样会让一个其实就要成功的请求平白失败。
决定三:退避要加抖动。
ts
const jitter = 0.75 + Math.random() * 0.5;
const delay = baseDelay * 2 ** attempt * jitter;服务端刚恢复时,如果所有客户端都在同一毫秒发起第二次尝试,会立刻把它再打垮一次。
接了 TanStack Query / SWR 之后重试归谁?
归上层,HTTP 层的 retry 保持关闭。除了「上层 3 次 × HTTP 层 3 次 = 9 个物理请求」 这种次数相乘,更根本的原因是两层掌握的信息不同:上层持有查询身份,知道这次读取是否 仍被界面需要、是否已被新查询取代;HTTP 层只看得到一次孤立的传输。
HTTP 层的 retry 留给不经过数据请求层的调用——一次性读取、轮询、启动引导请求。 这也是它默认关闭的原因:接入数据请求层时不需要回头去关一个全局默认值。
文件传输:两条下载路径
| 带得上 Authorization | 能读文件名 | 能报进度 | 内存 | |
|---|---|---|---|---|
fetchFile + saveFile | 能 | 能 | 能 | 整个文件进内存 |
downloadDirect | 不能(靠 URL 签名) | 靠调用方给 | 不能 | 浏览器接管 |
transfer.ts 里有两处安全边界:
文件名消毒。 名字来自服务端的 Content-Disposition,是不可信输入,完全可能是 ../../../.bashrc。控制字符和路径分隔符全部替换,开头的 . 也换掉(避免生成隐藏 文件)。
直链协议白名单。 downloadDirect 会把 url 赋给 <a href> 然后点击,所以必须 先解析、只放行 http: 和 https:。放行 javascript: 等于给了一个 XSS 执行点。 校验必须在创建 <a> 之前。
顺带两个实践细节:上传 FormData 时不要手写 Content-Type,浏览器会自己填上 带 boundary 的值;createObjectURL 建立的引用必须 revoke,否则整个 Blob 一直钉在 内存里。
自己验证
test/failure-budgets.test.ts 的 「stops retrying a safe read once the budget cannot fit another attempt」:一个总是返回 503 的服务,配 { retries: 5, baseDelayMs: 40, totalTimeoutMs: 250 },断言实际发出的 请求数少于 6 次——没有预算时它会跑满 6 次、约 1.2 秒退避。
本页源码
构建时从 docs/projects/axios-http/ 的真实文件直读,和测试跑的是同一份。每个文件头 注释是该文件的地图。
ts
/**
* 物理尝试这一层的取消控制。和 client.ts 是一对:
*
* client.ts 逻辑请求——调用方眼里的一次请求
* request-control 物理尝试——真正发出去的每一次 HTTP 请求
*
* 一次逻辑请求可能产生多次物理尝试(重试、401 刷新后重放),所以两层各有一套
* AbortController,cancelAll() 也要两层都取消:正在退避等待、还没发出下一次尝试的
* 请求只存在于逻辑层;已经在传输途中的请求只存在于这一层。
*/
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
type RequestRuntimeMeta = {
controller: AbortController;
originalSignal?: InternalAxiosRequestConfig["signal"];
disposeCombinedSignal: () => void;
};
type ControlledRequestConfig<Body = unknown> =
InternalAxiosRequestConfig<Body> & {
__requestControlMeta?: RequestRuntimeMeta;
__requestAttempts?: RequestAttempts;
};
// 物理尝试的计数器。对象由 client.ts 的 execute() 创建并塞进 config,在这里自增,
// 最后回到 execute() 写进错误上下文。用类而不是数字,是因为要跨这三处共享同一个
// 引用——传数字的话每一层拿到的都是副本。
export class RequestAttempts {
count = 0;
}
/**
* 把多个 AbortSignal 合成一个:任意一个中止,合成信号就中止。
*
* 优先用原生 `AbortSignal.any()`;它不在的环境(较老的浏览器)退回手写实现。
* 手写那半要注意两件事:
* · 返回 dispose,调用方在请求结束时必须调,否则监听器会一直挂在调用方的 signal
* 上。长生命周期的 signal(比如整个页面共用一个)配上频繁请求,就是内存泄漏。
* · 先检查有没有已经中止的信号。晚注册监听器收不到早已发生的 abort 事件。
*/
export function combineAbortSignals(signals: AbortSignal[]) {
const AbortSignalWithAny = AbortSignal as typeof AbortSignal & {
any?: (signals: AbortSignal[]) => AbortSignal;
};
if (AbortSignalWithAny.any) {
return {
signal: AbortSignalWithAny.any(signals),
dispose: () => {},
};
}
const controller = new AbortController();
const subscriptions: Array<{ signal: AbortSignal; listener: () => void }> = [];
function dispose() {
subscriptions.forEach(({ signal, listener }) => {
signal.removeEventListener("abort", listener);
});
subscriptions.length = 0;
}
const abortedSignal = signals.find((signal) => signal.aborted);
if (abortedSignal) {
controller.abort(abortedSignal.reason);
return { signal: controller.signal, dispose };
}
signals.forEach((signal) => {
const listener = () => {
controller.abort(signal.reason);
dispose();
};
subscriptions.push({ signal, listener });
signal.addEventListener("abort", listener, { once: true });
});
return { signal: controller.signal, dispose };
}
export function installRequestControl(axiosInstance: AxiosInstance) {
const pendingControllers = new Set<AbortController>();
// 每次物理请求发出前都会走到这里——包括重试和 401 重放,因为它们都是重新进入
// Axios 的请求链。计数器加在这里,统计到的才是真实的尝试次数。
function prepare(config: ControlledRequestConfig) {
if (config.__requestAttempts) {
config.__requestAttempts.count += 1;
}
const controller = new AbortController();
const originalSignal = config.signal;
const combined = originalSignal
? combineAbortSignals([originalSignal as AbortSignal, controller.signal])
: {
signal: controller.signal,
dispose: () => {},
};
const meta: RequestRuntimeMeta = {
controller,
originalSignal,
disposeCombinedSignal: combined.dispose,
};
config.__requestControlMeta = meta;
config.signal = combined.signal;
pendingControllers.add(controller);
return config;
}
// 请求结束后必须把 config 恢复原样:解绑监听器,把 signal 换回调用方原来那个。
//
// 恢复这一步不是洁癖。config 对象会被复用——401 重放就是拿同一个 config 再发一次。
// 如果留着上一轮那个已经中止的合成信号,重放会在发出的瞬间就被判为取消。
function finish(config?: ControlledRequestConfig) {
const meta = config?.__requestControlMeta;
if (!meta || !config) {
return;
}
meta.disposeCombinedSignal();
config.signal = meta.originalSignal;
config.__requestControlMeta = undefined;
pendingControllers.delete(meta.controller);
}
axiosInstance.interceptors.request.use((config) => {
return prepare(config as ControlledRequestConfig);
});
// 成功和失败两条路都要 finish()。只在成功分支清理的话,一个总是超时的接口会把
// 监听器和控制器一直攒着。
axiosInstance.interceptors.response.use(
(response) => {
finish(response.config as ControlledRequestConfig);
return response;
},
(error: unknown) => {
if (axios.isAxiosError(error)) {
finish(error.config as ControlledRequestConfig | undefined);
}
return Promise.reject(error);
},
);
return {
cancelAll() {
pendingControllers.forEach((controller) => controller.abort());
pendingControllers.clear();
},
};
}ts
/**
* 指数退避重试。由 client.ts 的 execute() 调用,只包住「一次物理尝试」。
*
* 边界要划清楚:这一层管的是**同一个请求**的网络抖动,不管数据的新鲜度。缓存、
* 去重、失效后重取那些属于数据获取层(TanStack Query / SWR)的职责,不要在这里长。
* 两层叠加时,把重试关掉交给上层统一管也是合理选择。
*/
import { HttpError } from "./errors";
export interface RetryOptions {
retries?: number;
baseDelay?: number;
totalTimeoutMs?: number;
signal?: AbortSignal;
shouldRetry?: (error: unknown) => boolean;
}
// 重试的总时间预算。没有它的话,单次 timeout 10s × 3 次尝试 + 退避等待,用户可能
// 要盯着 Loading 转半分钟——每一次都没超时,加起来却久得离谱。
const DEFAULT_TOTAL_TIMEOUT_MS = 30_000;
// 等待期间也要能被取消,否则 cancelAll() 之后请求还会在退避结束时冒出来。
// 取消用 HttpError kind: "cancel" 表达,让上层跟其他取消走同一条路径。
function wait(delay: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(
new HttpError({
kind: "cancel",
message: "Request canceled",
cause: signal.reason,
}),
);
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort);
resolve();
}, delay);
function abort() {
clearTimeout(timer);
reject(
new HttpError({
kind: "cancel",
message: "Request canceled",
cause: signal?.reason,
}),
);
}
signal?.addEventListener("abort", abort, { once: true });
});
}
export async function retry<Result>(
task: () => Promise<Result>,
options: RetryOptions = {},
): Promise<Result> {
const retries = options.retries ?? 2;
const baseDelay = options.baseDelay ?? 200;
const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
const startedAt = Date.now();
// 默认只重试「重试确实可能成功」的失败:网络断、超时,以及 502/503/504 这三个
// 明确表示上游临时不可用的状态码。
//
// 反过来说,4xx 一律不重试——参数错了、没权限、资源不存在,再发一百次也是同样的
// 结果,只是在给服务端添堵。500 同样不在名单里:它代表服务端逻辑出错,不是临时性的。
const shouldRetry =
options.shouldRetry ??
((error: unknown) => {
if (!(error instanceof HttpError)) {
return false;
}
return (
error.kind === "network" ||
error.kind === "timeout" ||
(error.kind === "http" && [502, 503, 504].includes(error.status ?? 0))
);
});
for (let attempt = 0; ; attempt += 1) {
try {
return await task();
} catch (error) {
if (attempt >= retries || !shouldRetry(error)) {
throw error;
}
// 退避时间乘一个 0.75~1.25 的随机抖动。服务端刚恢复时,如果所有客户端都在
// 同一毫秒发起第二次尝试,会立刻把它再打垮一次;抖动把这波流量摊开。
const jitter = 0.75 + Math.random() * 0.5;
const delay = baseDelay * 2 ** attempt * jitter;
// 预算检查放在等待**之前**:只判断「这次等待加上已花的时间会不会超预算」,
// 超了就直接把当前错误抛出去。它从不打断已经发出的尝试——那样会让一个其实
// 就要成功的请求平白失败。
if (Date.now() - startedAt + delay >= totalTimeoutMs) {
throw error;
}
await wait(delay, options.signal);
}
}
}ts
/**
* 文件上传与下载。这些是纯函数,第一个参数收 HttpClient——不挂在客户端上是因为它们
* 依赖 DOM(document、URL.createObjectURL),而客户端本身在 Node 里也要能跑。
*
* 下载有两条路,用途不同,别混:
*
* fetchFile + saveFile 走封装发请求,拿到 Blob 再触发保存。带得上 Authorization
* 头,能读 Content-Disposition 拿真实文件名,能报进度。
* 代价是整个文件先进内存。
* downloadDirect 直接交给浏览器下载。适合大文件和已签名的 URL;带不上
* 自定义请求头,所以鉴权只能靠 URL 里的签名。
*
* 本文件有两处安全考量,都在注释里单独标了:文件名消毒、直链协议白名单。
*/
import type { AxiosProgressEvent } from "axios";
import type { HttpClient, HttpRequestConfig } from "./client";
import { HttpError } from "./errors";
export interface UploadFileOptions
extends Omit<HttpRequestConfig<FormData>, "data" | "method" | "url"> {
fieldName?: string;
filename?: string;
fields?: Record<string, string>;
onProgress?: (progress: AxiosProgressEvent) => void;
}
export interface DownloadFileOptions<Body = unknown>
extends Omit<
HttpRequestConfig<Body>,
"data" | "method" | "responseType" | "url"
> {
fallbackFilename?: string;
method?: "get" | "post";
data?: Body;
}
export interface DownloadedFile {
blob: Blob;
filename: string;
}
export interface DirectDownloadOptions {
filename?: string;
}
// 文件名消毒。它处理的是**不可信输入**:名字来自服务端的 Content-Disposition 头,
// 完全可能是 `../../../.bashrc` 或者带控制字符的东西。
//
// 第一条 replace 干掉控制字符和路径分隔符,防止写到目标目录外面去
// 第二条 replace 开头的 `.` 一律换掉,避免生成 Linux 下的隐藏文件
// 兜底 清干净之后可能什么都不剩,那就给个默认名
function sanitizeFilename(filename: string) {
const cleaned = filename
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "_")
.replace(/^[._]+/, "_")
.trim();
return cleaned || "download";
}
// 直链下载的协议白名单,是本文件另一处安全防线。
//
// downloadDirect 会把 url 赋给 `<a href>` 然后点击它,所以 url 必须先解析、只放行
// http 和 https。放行 `javascript:` 等于给了一个 XSS 执行点,放行 `data:`、`blob:`
// 则可以用来投递本地构造的内容。**在创建链接之前**就拒绝,而不是创建完再检查。
function readDirectDownloadUrl(url: string) {
let parsedUrl: URL;
try {
parsedUrl = new URL(url, document.baseURI);
} catch (cause) {
throw new HttpError({
kind: "configuration",
message: "Direct download URL is invalid",
cause,
});
}
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new HttpError({
kind: "configuration",
message: "Direct download URL must use HTTP or HTTPS",
});
}
return parsedUrl.href;
}
/**
* 从 Content-Disposition 头里取文件名。三级降级,顺序就是 RFC 6266 的优先级:
*
* 1. filename*=UTF-8''... RFC 5987 编码形式,中文文件名只能靠它
* 2. filename="..." 带引号,名字里有空格时用这种
* 3. filename=... 不带引号的裸值
*
* `filename*` 必须排在第一位:两者同时出现时(服务端为兼容老客户端常常都发),
* `filename` 里的中文往往已经是乱码了。
*
* 每一条出口都过 sanitizeFilename,一个都不能漏。
*/
export function readDownloadFilename(
contentDisposition: string | null | undefined,
fallback = "download",
) {
if (!contentDisposition) {
return sanitizeFilename(fallback);
}
const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(contentDisposition)?.[1];
if (encoded) {
try {
return sanitizeFilename(decodeURIComponent(encoded));
} catch {
// 服务端把 % 转义写坏了会让 decodeURIComponent 抛错。这时退回原始字符串,
// 名字丑一点也比整个下载失败好。
return sanitizeFilename(encoded);
}
}
const quoted = /filename\s*=\s*"([^"]+)"/i.exec(contentDisposition)?.[1];
if (quoted) {
return sanitizeFilename(quoted);
}
const plain = /filename\s*=\s*([^;]+)/i.exec(contentDisposition)?.[1];
return sanitizeFilename(plain?.trim() || fallback);
}
export function uploadFile<Result>(
http: HttpClient,
url: string,
file: Blob,
options: UploadFileOptions = {},
): Promise<Result> {
const {
fieldName = "file",
filename,
fields,
onProgress,
...requestConfig
} = options;
const formData = new FormData();
// 不设 Content-Type。浏览器会自己填上 multipart/form-data 并附带 boundary 参数,
// 手写这个头反而会因为缺 boundary 让服务端解析不出内容。
if (filename) {
formData.append(fieldName, file, filename);
} else {
formData.append(fieldName, file);
}
Object.entries(fields ?? {}).forEach(([key, value]) => {
formData.append(key, value);
});
return http.post<Result, FormData>(url, formData, {
...requestConfig,
onUploadProgress: onProgress,
});
}
export async function fetchFile<Body = unknown>(
http: HttpClient,
url: string,
options: DownloadFileOptions<Body> = {},
): Promise<DownloadedFile> {
const {
data,
fallbackFilename = "download",
method = "get",
...requestConfig
} = options;
// 这里必须用 raw() 而不是普通请求,有两个原因:文件名藏在响应头里,普通请求只
// 给 data 拿不到头;而且 responseType: "blob" 的响应体本来就不是信封,raw() 顺带
// 让 Envelope 拦截器跳过解包。
const response = await http.raw<Blob, Body>({
...requestConfig,
data,
method,
responseType: "blob",
url,
});
const contentDisposition = response.headers["content-disposition"];
return {
blob: response.data,
filename: readDownloadFilename(
typeof contentDisposition === "string" ? contentDisposition : undefined,
fallbackFilename,
),
};
}
// 触发浏览器保存。整段的要点在 finally:createObjectURL 建立的引用会一直把整个 Blob
// 钉在内存里,必须 revoke。一个几十 MB 的导出反复下载几次,漏掉这一步就是几百 MB
// 回收不掉。锚点元素同理要摘掉。
export function saveFile(file: DownloadedFile) {
const objectUrl = URL.createObjectURL(file.blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = file.filename;
anchor.style.display = "none";
document.body.append(anchor);
try {
anchor.click();
} finally {
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
}
export function downloadDirect(
url: string,
options: DirectDownloadOptions = {},
) {
const anchor = document.createElement("a");
anchor.href = readDirectDownloadUrl(url);
if (options.filename) {
anchor.download = sanitizeFilename(options.filename);
}
anchor.style.display = "none";
document.body.append(anchor);
try {
anchor.click();
} finally {
anchor.remove();
}
}