Skip to content

生命周期能力:取消、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: 5baseDelayMs: 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();
  }
}