Skip to content

目标与判断

你的现状:页面里混着 api.d.ts / axios.d.ts / common.ts 等类型与封装,随着“对象入参 + 零破坏兼容”推进,类型边界和目录粘在一起,开始“乱”。

结论(大厂常用做法):保持 axios 原语义不覆写,新增对象式入口(如 http.call);类型集中管理代码生成运行时校验 组合;请求层分三层(core/http、api/generated、api/modules),再用查询层(TanStack Query/Vue Query)管理缓存与重试;本地与集成测试统一用 MSW 拦截网络层。这些都是一线公司里稳定落地的模式。(axios-http.com, orval.dev, GitHub, tanstack.com, mswjs.io)


推荐目录(企业级分层)

txt
src/
  lib/
    http/                      # Core 请求层(只做“通用能力”)
      client.ts                # axios 实例与拦截器(不覆写 axios.request)
      call.ts                  # 新增对象式入口:http.call(options)
      interceptors.ts          # 拦截器拆分:auth、retry、logging
      cancel.ts                # 取消池/AbortController 管理
      types.ts                 # RequestOptions、HttpError、CodeResponse 等
      index.ts                 # 统一导出 http 实例与类型
  api/
    __generated__/             # OpenAPI 代码生成产物(只读)
      client.ts
      schemas.ts               # 可选:导出 Zod/类型
      README.md
    modules/                   # 领域 API(手写,薄封装)
      user.ts
      order.ts
      price.ts
  features/                    # 业务功能(视框架而定:React/Vue)
    price-trend/
      queries.ts               # TanStack Query/Vue Query 的 hooks
      components/...
  types/
    http/                      # 类型声明集中地(仅 .d.ts)
      axios.d.ts               # module augmentation:扩展 AxiosInstance(如 .call)
      api.d.ts                 # 全局响应范式、工具类型
    common.ts                  # 与请求无关的通用类型
tests/
  msw/
    server.ts                  # MSW 服务端/浏览器初始化
    handlers/                  # 接口级别的 mock 处理器
  • lib/http 只做“通用能力”(超时、重试、鉴权、取消、日志、错误规约),不写任何“业务 URL”;这能保证“可替换/可迁移”。拦截器是 axios 官方推荐扩展点。(axios-http.com)
  • api/__generated__ 用 OpenAPI/Swagger 生成强类型客户端,避免手写 drift;常见工具:orvalopenapi-typescript-codegen、OpenAPI Generator。(orval.dev, GitHub, openapi-generator.tech)
  • api/modules 是“轻薄门面层”:把生成代码或 http.call 包一层,落地统一的错误/返回范式,方便埋点与切面。
  • features/*/queries.ts 将请求函数交给 TanStack Query/Vue Query 管理缓存、并发、重试、失效、SSR 等。(tanstack.com)
  • tests/mswMSW 在浏览器/Node 统一拦截 HTTP,保证本地/CI 可重复。(mswjs.io)

类型与声明管理:收口与增量

1) .d.ts 只放声明,不放实现

  • 将你现有的 api.d.ts / axios.d.ts 合并/去重后放到 src/types/http/ 下,职责清晰:

    • axios.d.tsmodule augmentation(扩展 AxiosInstance,如新增 call<T>() 签名,不改原生 get/post)。TypeScript 官方建议通过声明合并做到“对第三方库的扩展”。(typescriptlang.org, Stack Overflow, futurestud.io)
    • api.d.ts:与后端约定的响应外层、错误码、工具类型(如 CodeResponse<T>PageResp<T>)。
  • common.ts 只保留与请求无关的通用类型/常量,避免耦合。

示例(module augmentation):将 .call 加入 AxiosInstance(放 src/types/http/axios.d.ts

ts
// 在同名模块作用域内“声明合并”,不会改动运行时代码
declare module "axios" {
  export interface AxiosInstance {
    // 简化:对象式入口
    call<T = any, P = Record<string, any>>(options: {
      url: string;
      method?: import("axios").Method;
      params?: P;
      data?: unknown;
      requestConfig?: import("axios").AxiosRequestConfig;
      contentType?: "json" | "urlencoded" | "formdata";
    }): Promise<T>;
  }
}

2) 运行时校验(可选但推荐)

  • 对关键接口采用 Zod/生成器产出的 schema,做“入站/出站”校验,尽早暴露协议漂移。(Zod)
  • 如果你用 Zodios,可直接以 Zod 定义端点 → 生成强类型 axios 客户端。(zodios.org, npm)

代码生成:把“类型维护成本”降到最低

  • orval:从 OpenAPI 生成 TS 客户端,内置 React/Vue Query 集成、缓存与 MSW mock 模板,工程化最佳。(orval.dev, GitHub, npm)
  • openapi-typescript-codegen:轻量、纯客户端生成,适合你已有 Query 层。(GitHub)

建议:把生成产物放进 src/api/__generated__,并加入 lint/tsc,但排除 format(防止误改)。CI 里加“变更即失败”的 diff 检查,确保版本与后端规范同步。


Core HTTP 的“硬规范”

  1. 不覆写 axios.request,新增 http.call(options),保持 http.get/post/... 零破坏兼容(你已走在正确路线上)。

  2. 拦截器:鉴权(注入 Authorization)、401 统一处理、取消池、日志/链路 ID、错误归一化。(axios-http.com)

  3. FormData 正确处理

    • 不要手动设置 Content-Type: multipart/form-data,让浏览器带 boundary;否则容易 4xx/网络错误。(axios-http.com, GitHub)
  4. 错误范式:将 AxiosError → 统一 HttpError(含 statuscodemessagetraceIdisNetworkError)。

  5. 请求命名与埋点:在 requestConfig 中允许 opNameskipAuthretry 等自定义键,统一日志与灰度。


查询层(React/Vue):“把副作用交给专业工具”

  • TanStack Query / Vue Query 管理缓存、并发去重、后台刷新、窗口聚焦重刷、错误边界、SSR/Hydration、离线等硬问题;你的 http.call / 生成客户端只需提供 纯函数 即可。(tanstack.com)
  • 典型封装(以 React 为例):
ts
// features/price-trend/queries.ts
import { useQuery } from "@tanstack/react-query";
import { priceApi } from "@/api/modules/price";

export const useGamePriceTrend = (gameId: string, stores: string[]) =>
  useQuery({
    queryKey: ["priceTrend", gameId, stores],
    // 复杂逻辑上一行注释:调用领域 API,返回 Promise,交由 Query 管理缓存与失败重试
    queryFn: () => priceApi.getTrend({ gameId, stores }),
    staleTime: 5 * 60 * 1000,
    gcTime: 60 * 60 * 1000,
    retry: 2,
  });

TanStack 官方定位就是“声明式异步/服务端状态管理”,在大厂广泛使用。(tanstack.com)


Mock 与测试:MSW 打通本地与 CI

  • MSW 在浏览器/Node 拦截真实网络层,mock 定义可复用到 Storybook/E2E/单元测试,减少“环境不可用”的波动。(mswjs.io)
  • 放到 tests/msw/handlers,与 api/modules 一一对应;生成器也能产出 mock 模板(如 orval)。(GitHub)

迁移策略(零停机)

  1. 先落地 core/http 与 http.call(不动 get/post)。
  2. 挑 1 ~ 2 个模块迁移到 api/modules(使用 http.call__generated__ 客户端),沉淀错误与重试策略。
  3. 接入 TanStack/Vue Query 的“易变接口”(价格趋势、榜单、列表)以验证缓存收益。
  4. 稳定后再逐步 把老的页面直连 http 的代码迁到 modules;期间保留 api.d.ts 的兼容类型,避免一次性爆炸式改动。
  5. 接入 MSW 保障端到端可测。

你现有文件的落位建议

  • /mnt/data/api.d.tssrc/types/http/api.d.ts(保留响应范式与工具类型,去掉与 axios 的交叉声明)
  • /mnt/data/axios.d.tssrc/types/http/axios.d.ts只做 module augmentation:为 AxiosInstance 增加 .call 的签名,不定义实现)
  • /mnt/data/common.tssrc/types/common.ts(与请求无关的类型保留在此;若有请求相关的工具类型,迁到 lib/http/types.ts

最小代码约定(只列关键片段)

ts
// lib/http/call.ts
import type { AxiosRequestConfig, Method } from "axios";
import http from "./client";
import { toFormData, toUrlencoded } from "./utils";

export type RequestContentType = "json" | "urlencoded" | "formdata";
export interface RequestOptions<P = Record<string, any>> {
  url: string;
  method?: Method;
  params?: P;
  data?: unknown;
  requestConfig?: AxiosRequestConfig;
  contentType?: RequestContentType;
}

export async function call<T, P = Record<string, any>>(
  opt: RequestOptions<P>
): Promise<T> {
  const {
    url,
    method = "GET",
    params,
    data,
    requestConfig,
    contentType = "json",
  } = opt;
  if (!url) throw new Error("Missing url");

  const cfg: AxiosRequestConfig = { ...(requestConfig || {}), url, method };
  if (method === "GET" || method === "HEAD") {
    cfg.params = params ?? requestConfig?.params;
  } else {
    // 复杂逻辑上一行注释:统一 body 选择并按 contentType 组织
    const body = data ?? params ?? requestConfig?.data;
    if (contentType === "formdata")
      cfg.data = body instanceof FormData ? body : toFormData(body as any);
    else if (contentType === "urlencoded")
      cfg.data =
        body instanceof URLSearchParams
          ? body
          : toUrlencoded((body ?? {}) as any);
    else cfg.data = body;
  }
  return http.request(cfg) as Promise<T>;
}

client.ts 里注册拦截器与取消池;index.tsexport { http }export { call };FormData 时别手动设置 Content-Type,让浏览器带 boundary(官方文档有说明,也有 issue 讨论)。(axios-http.com, GitHub)


校验清单(落地前检查)

  • [ ] 不覆写 axios.request;保留 http.get/post/...
  • [ ] .d.ts 只放声明axios.d.ts 仅做 module augmentation
  • [ ] http.call 对象式入口可用;FormData 不手设 Content-Type
  • [ ] 统一 HttpError;401/取消池/日志在拦截器层
  • [ ] 关键接口用 Zod 校验或生成器 schema
  • [ ] 接入 TanStack/Vue Query 管理缓存与重试
  • [ ] MSW 拦截器与 handlers 就位;本地/CI 可跑通

参考与延伸

  • Axios 拦截器官方文档(拦截请求/响应的标准扩展点)。(axios-http.com)
  • Axios Multipart/FormData 说明(自动序列化;不要手动 Content-Type)。(axios-http.com)
  • 不要手动设置 FormData 的 Content-Type(社区/issue 共识)。(GitHub, Stack Overflow)
  • TypeScript 声明合并 / 模块增强(官方手册)。(typescriptlang.org)
  • Orval(OpenAPI → TS 客户端、Query、MSW mock 一体化)。(orval.dev, GitHub)
  • openapi-typescript-codegen(轻量生成 TS 客户端)。(GitHub)
  • TanStack Query(React/Vue 的服务端状态/数据获取)。(tanstack.com)
  • MSW(浏览器/Node 的网络层 API Mock)。(mswjs.io)

如果你愿意,我可以把你现有的 api.d.ts / axios.d.ts / common.ts 逐条清点并给出迁移 diff(仅贴需要修改的片段,复杂处在上一行加注释),直接落到上面的目录结构里。

下面给你一套可直接落地的企业级请求层目录与所有涉及文件的完整代码(不使用 Hexo)。 特点:不覆盖 axios.request、保留 http.get/post/... 零破坏;新增对象式入口 http.call(options);类型集中在 .d.tstypes.ts;复杂逻辑行已在上一行加中文注释。


目录结构

src/
  lib/
    http/
      client.ts
      call.ts
      types.ts
      utils.ts
      index.ts
  api/
    modules/
      user.ts
      price.ts
  types/
    http/
      axios.d.ts
      api.d.ts
    common.ts
  tests/
    msw/
      server.ts
      handlers/
        price.ts

说明:api/__generated__(OpenAPI 代码生成)在此略去,你可以后续接入 orval/openapi-typescript-codegen;当前示例定位为手写模块层。


src/lib/http/types.ts

ts
import type {
  AxiosInstance,
  AxiosRequestConfig,
  Method,
  AxiosResponse,
} from "axios";

/** 请求体类型枚举 */
export type RequestContentType = "json" | "urlencoded" | "formdata";

/** 对象式入参:与 axios 原生保持解耦 */
export interface RequestOptions<P = Record<string, any>> {
  url: string;
  method?: Method;
  /** GET/HEAD 下作为查询串;非 GET/HEAD 下作为 body 的回退来源 */
  params?: P;
  /** 非 GET/HEAD 的首选请求体 */
  data?: unknown;
  /** 附加的 axios 配置(headers/timeout/signal 等) */
  requestConfig?: AxiosRequestConfig;
  contentType?: RequestContentType;
}

/** 统一的错误对象,可在拦截器里规约 */
export interface HttpError extends Error {
  status?: number;
  code?: number | string;
  data?: unknown;
  traceId?: string;
  isNetworkError?: boolean;
}

/** 统一的响应解析器签名:返回 undefined 表示“跳过,让后续解析器或原始响应继续处理” */
export type ResponseParser<T = any, R = AxiosResponse<any>> = (
  resp: R
) => T | undefined;

/** 为 http 实例扩展的方法(实现见 client.ts / call.ts) */
export interface HttpInstance extends AxiosInstance {
  call<T = any, P = Record<string, any>>(
    options: RequestOptions<P>
  ): Promise<T>;
  cancelAll(): void;
  setAuthToken(token?: string | null, type?: string): void;
  setGlobalParser(parsers: ResponseParser<any, any>[]): void;
  setAuthorization(token: string, expires: number | Date, name?: string): void;
  removeAuthorization(name?: string): void;
  checkAuthorization(name?: string): boolean;
}

src/lib/http/utils.ts

ts
/** 将对象转为 FormData;数组按 key[] 追加;对象值 JSON.stringify */
export function toFormData(params?: Record<string | number, unknown>) {
  const formData = new FormData();
  if (!params) return formData;
  Object.entries(params).forEach(([k, v]) => {
    if (Array.isArray(v)) {
      v.forEach((val) =>
        formData.append(
          `${k}[]`,
          typeof val === "object" ? JSON.stringify(val) : String(val)
        )
      );
    } else if (typeof v === "object" && v !== null) {
      formData.append(k, JSON.stringify(v));
    } else if (v !== null && v !== undefined) {
      formData.append(k, String(v));
    }
  });
  return formData;
}

/** 将对象转为 application/x-www-form-urlencoded(支持嵌套/数组) */
export function toUrlencoded(params: Record<string | number, unknown>) {
  const urlencoded = new URLSearchParams();
  function walk(obj: Record<string | number, unknown>, parent = "") {
    for (const [k, v] of Object.entries(obj)) {
      const key = parent ? `${parent}[${k}]` : k;
      if (v === null || v === undefined) continue;
      if (Array.isArray(v)) {
        v.forEach((item) =>
          urlencoded.append(
            `${key}[]`,
            typeof item === "object" ? JSON.stringify(item) : String(item)
          )
        );
      } else if (typeof v === "object") {
        walk(v as Record<string | number, unknown>, key);
      } else {
        urlencoded.append(key, String(v));
      }
    }
  }
  walk(params);
  return urlencoded;
}

src/lib/http/call.ts

ts
import type { AxiosRequestConfig } from "axios";
import qs from "qs";
import type { RequestOptions } from "./types";
import { toFormData, toUrlencoded } from "./utils";

/** 复杂逻辑上一行注释:根据 contentType 组织 body 并设置 headers/paramsSerializer */
export function buildAxiosConfigFromOptions(
  options: RequestOptions
): AxiosRequestConfig {
  const {
    url,
    method = "GET",
    params,
    data,
    requestConfig,
    contentType = "json",
  } = options;

  if (!url) throw new Error("Missing url");

  const cfg: AxiosRequestConfig = {
    ...(requestConfig || {}),
    url,
    method,
    headers: {
      // 默认 Content-Type,稍后允许外部覆盖;formdata 会主动删除以让浏览器自动带 boundary
      "Content-Type":
        contentType === "urlencoded"
          ? "application/x-www-form-urlencoded"
          : "application/json",
      ...(requestConfig?.headers || {}),
    },
  };

  if (method === "GET" || method === "HEAD") {
    cfg.params = params ?? requestConfig?.params;
    // 复杂逻辑上一行注释:数组无索引、跳过 null
    cfg.paramsSerializer = (d) =>
      qs.stringify(d, { indices: false, skipNulls: true });
  } else {
    // 复杂逻辑上一行注释:优先 data,回退 params(兼容旧思路)
    const body = data ?? params ?? requestConfig?.data;
    if (contentType === "formdata") {
      // 复杂逻辑上一行注释:FormData 让浏览器自动带 boundary,需移除手动设置的 Content-Type
      if (cfg.headers) delete (cfg.headers as any)["Content-Type"];
      cfg.data = body instanceof FormData ? body : toFormData(body as any);
    } else if (contentType === "urlencoded") {
      cfg.data =
        body instanceof URLSearchParams
          ? body
          : toUrlencoded((body ?? {}) as Record<string, any>);
      cfg.headers = {
        ...cfg.headers,
        "Content-Type": "application/x-www-form-urlencoded",
      };
    } else {
      cfg.data = body;
      cfg.headers = { ...cfg.headers, "Content-Type": "application/json" };
    }
  }
  return cfg;
}

src/lib/http/client.ts

ts
import axios from "axios";
import Cookie from "js-cookie";
import storage from "store";
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import type { HttpInstance, ResponseParser } from "./types";
import { buildAxiosConfigFromOptions } from "./call";
import { STORAGE_KEYS } from "@/constants";
import { LOGIN_ROUTE } from "@/router/constants";

const { ACCESS_TOKEN, TOKEN_TYPE } = STORAGE_KEYS;

/** —— 取消池:统一管理所有请求的 AbortController —— */
const abortPool = new Set<AbortController>();

/** 对外暴露:集中取消所有未决请求(如退出登录时) */
function cancelAll() {
  abortPool.forEach((c) => c.abort());
  abortPool.clear();
}

/** 创建 axios 实例并安装拦截器 */
function createAxiosHttp(
  config: AxiosRequestConfig,
  opts: {
    authDefault?: boolean;
    publicPrefixes?: string[];
  } = {}
): HttpInstance {
  const authDefault = opts.authDefault ?? true;
  const publicPrefixes = opts.publicPrefixes ?? ["/api/pub"];

  const _axios = axios.create(config) as AxiosInstance;

  // —— 业务增强:全局解析器(按顺序尝试,返回 undefined 表示跳过) ——
  (_axios as any).setGlobalParser = (parsers: ResponseParser<any, any>[]) => {
    _axios.interceptors.response.use(
      (resp: AxiosResponse) => {
        for (const p of parsers) {
          const r = p(resp);
          if (r !== undefined) return r;
        }
        return resp;
      },
      (err) => {
        if (err?.response) {
          const e: any = new Error(err.response.statusText);
          e.status = err.response.status;
          e.code = err.response.data?.code;
          e.data = err.response.data;
          throw e;
        }
        // 网络层错误(超时/断网/跨域)
        const e: any = new Error(err?.message || "Network Error");
        e.isNetworkError = true;
        throw e;
      }
    );
  };

  // —— cookie 鉴权工具方法(部分场景需要,同你原实现保持一致) ——
  (_axios as any).setAuthorization = (
    token: string,
    expires: number | Date,
    name?: string
  ) => {
    Cookie.set(name ?? _axios.defaults.xsrfCookieName!, token, { expires });
  };
  (_axios as any).removeAuthorization = (name?: string) => {
    Cookie.remove(name ?? _axios.defaults.xsrfCookieName!);
  };
  (_axios as any).checkAuthorization = (name?: string) => {
    return Boolean(Cookie.get(name ?? _axios.defaults.xsrfCookieName!));
  };

  // —— 请求拦截器:统一接入取消池/授权头/公开前缀 ——
  _axios.interceptors.request.use((req) => {
    // 复杂逻辑上一行注释:桥接外部 signal 与内部 AbortController,并加入取消池
    const abortController = new AbortController();
    if ((req as any).signal) {
      const ext = (req as any).signal as AbortSignal;
      if (ext.aborted) abortController.abort();
      else ext.addEventListener?.("abort", () => abortController.abort());
    }
    (req as any).signal = abortController.signal;
    abortPool.add(abortController);
    // 复杂逻辑上一行注释:把 controller 暂存在 config 上,响应阶段释放
    (req as any).__abortController = abortController;

    // 复杂逻辑上一行注释:根据前缀与开关决定是否附加 Authorization
    const url = req.url || "";
    const isPublicByPrefix = publicPrefixes.some((p) => url.startsWith(p));
    // 优先使用 req.auth(boolean);否则用实例默认值 + 前缀约定
    const explicitAuth =
      typeof (req as any).auth === "boolean" ? (req as any).auth : undefined;
    const needAuth = explicitAuth ?? (authDefault && !isPublicByPrefix);
    const accToken = storage.get(ACCESS_TOKEN);
    const tokenType = storage.get(TOKEN_TYPE) || "Bearer";
    if (needAuth && !accToken) {
      ((req as any).__abortController as AbortController | undefined)?.abort();
      return Promise.reject(new Error("UNAUTHENTICATED"));
    }
    if (needAuth && accToken) {
      req.headers = {
        ...req.headers,
        Authorization: `${tokenType} ${accToken}`,
      };
    }
    return req;
  });

  // —— 响应拦截器:释放取消控制器;401 统一处理 ——
  _axios.interceptors.response.use(
    (res) => {
      const abortController = (res.config as any).__abortController as
        | AbortController
        | undefined;
      if (abortController) abortPool.delete(abortController);
      return res;
    },
    (err) => {
      if (err?.response?.status === 401) {
        storage.remove(ACCESS_TOKEN);
        storage.remove(TOKEN_TYPE);
        if (window.location.pathname !== LOGIN_ROUTE.path) {
          window.location.replace(LOGIN_ROUTE.path);
        }
      }
      const abortController = (err?.config as any)?.__abortController as
        | AbortController
        | undefined;
      if (abortController) abortPool.delete(abortController);
      return Promise.reject(err);
    }
  );

  // —— 实例级取消 ——
  (_axios as any).cancelAll = cancelAll;

  // —— 实例级鉴权头设置(与本地存储联动) ——
  (_axios as any).setAuthToken = (token?: string | null, type = "Bearer") => {
    if (token) {
      _axios.defaults.headers.common.Authorization = `${type} ${token}`;
      storage.set(ACCESS_TOKEN, token);
      storage.set(TOKEN_TYPE, type);
    } else {
      delete _axios.defaults.headers.common.Authorization;
      storage.remove(ACCESS_TOKEN);
      storage.remove(TOKEN_TYPE);
    }
  };

  // —— 新增对象式入口:http.call(options) ——
  (_axios as any).call = async function <T = any>(
    options: Parameters<typeof buildAxiosConfigFromOptions>[0]
  ): Promise<T> {
    const cfg = buildAxiosConfigFromOptions(options);
    return _axios.request(cfg) as Promise<T>;
  };

  return _axios as unknown as HttpInstance;
}

const http = createAxiosHttp(
  {
    timeout: 10000,
    baseURL: import.meta.env.VITE_API_URL as string,
    withCredentials: true,
    xsrfCookieName: "Authorization",
    xsrfHeaderName: "Authorization",
  },
  { authDefault: true, publicPrefixes: ["/api/pub"] }
);

export default http;
export { cancelAll };

src/lib/http/index.ts

ts
export { default as http, cancelAll } from "./client";
export * from "./types";
export * from "./utils";
export * from "./call";

src/api/modules/user.ts

ts
import { http } from "@/lib/http";
import type { RequestOptions } from "@/lib/http/types";

/** 复杂逻辑上一行注释:领域 API 薄封装,避免页面直接感知 URL/方法/解析细节 */
export const userApi = {
  getProfile() {
    return http.get<{ id: string; name: string; roles: string[] }>(
      "/api/user/profile"
    );
  },

  updateProfile(input: { name?: string }) {
    return http.call<void>({
      url: "/api/user/profile",
      method: "PUT",
      data: input,
      contentType: "json",
    });
  },

  /** 演示:URLSearchParams */
  search(params: { keyword?: string; page?: number }) {
    const options: RequestOptions<typeof params> = {
      url: "/api/user/search",
      method: "GET",
      params,
      requestConfig: { timeout: 8000 },
    };
    return http.call<{ list: any[]; total: number }>(options);
  },
};

src/api/modules/price.ts

ts
import { http } from "@/lib/http";

/** 复杂逻辑上一行注释:后端采用 CodeResponse 外包裹,交由全局解析器转换 */
export const priceApi = {
  getTrend(input: { gameId: string; stores: string[] }) {
    return http.call<Array<{ store: string; date: string; price: number }>>({
      url: "/api/price/trend",
      method: "POST",
      data: input,
      contentType: "json",
      requestConfig: { timeout: 15000 },
    });
  },
};

src/types/http/axios.d.ts

ts
// 声明合并:扩展 AxiosInstance,而不改动运行时代码
import type { AxiosRequestConfig } from "axios";

declare module "axios" {
  export interface AxiosInstance {
    call<T = any, P = Record<string, any>>(options: {
      url: string;
      method?: Method;
      params?: P;
      data?: unknown;
      requestConfig?: AxiosRequestConfig;
      contentType?: "json" | "urlencoded" | "formdata";
    }): Promise<T>;

    cancelAll(): void;
    setAuthToken(token?: string | null, type?: string): void;
    setGlobalParser(parsers: Array<(resp: any) => any | undefined>): void;
    setAuthorization(
      token: string,
      expires: number | Date,
      name?: string
    ): void;
    removeAuthorization(name?: string): void;
    checkAuthorization(name?: string): boolean;
  }
}

src/types/http/api.d.ts

ts
// 与后端约定的通用响应外壳
export interface CodeResponse<T = any> {
  code: number;
  data: T;
  message?: string;
}

// 常见分页返回
export interface PageResp<T> {
  list: T[];
  total: number;
  page: number;
  pageSize: number;
}

// 解析器(如果你希望有全局类型)
export type ResponseParser<T = any, R = any> = (resp: R) => T | undefined;

src/types/common.ts

ts
// 放与请求无关的通用类型
export type ID = string | number;

export interface Dict<T = any> {
  [key: string]: T;
}

(可选)全局解析器:将 CodeResponse<T> 解开

你可以把它放到任意初始化位置(如 src/app.tssrc/main.ts)并在启动时挂载。

ts
// src/app.setup-http.ts
import type { AxiosResponse } from "axios";
import { http } from "@/lib/http";
import { LOGIN_ROUTE } from "@/router/constants";
import storage from "store";
import { STORAGE_KEYS } from "@/constants";
import type { CodeResponse } from "@/types/http/api";

const { ACCESS_TOKEN, TOKEN_TYPE } = STORAGE_KEYS;

/** 复杂逻辑上一行注释:仅处理符合 CodeResponse 形状的响应,其他透传 */
function isCodeResp<T>(d: unknown): d is CodeResponse<T> {
  return (
    !!d && typeof d === "object" && "code" in (d as any) && "data" in (d as any)
  );
}

export const codeResponseParser = <T, R = CodeResponse<T>>(
  res: AxiosResponse<R>
): T | undefined => {
  const payload = res.data as any;
  if (!isCodeResp<T>(payload)) return undefined;
  const { code, data, message = "" } = payload;

  if (code === 401) {
    storage.remove(ACCESS_TOKEN);
    storage.remove(TOKEN_TYPE);
    if (window.location.pathname !== LOGIN_ROUTE.path) {
      window.location.replace(LOGIN_ROUTE.path);
    }
    throw new Error("未授权,请重新登录");
  }
  if (code === 200) return (data ?? null) as T;
  throw new Error(message || "后端错误");
};

// 启动时挂载
http.setGlobalParser([codeResponseParser]);

tests/msw/server.ts

ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers/price";

export const server = setupServer(...handlers);

// 在测试框架的 setup 中调用:
// beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
// afterEach(() => server.resetHandlers())
// afterAll(() => server.close())

tests/msw/handlers/price.ts

ts
import { http, HttpResponse } from "msw";

// 示例:与你的 priceApi.getTrend 匹配
export const handlers = [
  http.post("/api/price/trend", async ({ request }) => {
    const body = (await request.json()) as any;
    // 复杂逻辑上一行注释:模拟后端的 CodeResponse 外壳
    return HttpResponse.json({
      code: 200,
      data: [
        { store: "Steam", date: "2025-09-01", price: 199 },
        { store: "Epic", date: "2025-09-01", price: 189 },
      ],
      message: "ok",
    });
  }),
];

使用要点

  • 老代码:http.get/post/... 完全不受影响
  • 新写法:http.call({ url, method, data/params, contentType, requestConfig }) 更可读/可扩展。
  • FormData 场景不要手设 Content-Type,已在实现里处理。
  • 推荐在应用初始化处挂载 codeResponseParser,让模块层拿到的就是“已解壳”的数据。

如果你要把你现有的 api.d.ts / axios.d.ts / common.ts 内容原封不动迁入以上文件名,也可以把原定义贴出来,我帮你对齐/去重并完善类型冲突的 diff。

下面把你要的 cancel.ts 完整代码给出,并说明如何在 client.ts 接入;最后把 “FormData 自动带 boundary,需移除手动设置的 Content-Type” 的含义讲清楚。


1) src/lib/http/cancel.ts(完整代码)

ts
// src/lib/http/cancel.ts

/**
 * 维护一个全局取消池,用于集中取消所有尚未完成的请求。
 * 注意:只存放“活跃中的” AbortController;请求结束后要及时释放。
 */
const abortPool = new Set<AbortController>();

/** 复杂逻辑上一行注释:把外部 signal(若有)与内部 controller 绑定,外部取消时联动内部取消 */
function linkExternalSignal(
  external?: AbortSignal,
  controller?: AbortController
) {
  if (!external || !controller) return;
  if (external.aborted) {
    // 外部已取消则立即同步内部取消
    controller.abort();
    return;
  }
  // 外部后续取消时,触发内部取消
  external.addEventListener("abort", () => controller.abort());
}

/**
 * 创建一个“已纳入取消池管理”的 AbortController。
 * - 会自动与外部 signal(如果传入)联动;
 * - 会自动加入全局 abortPool,方便统一取消;
 * - 调用方需在请求完成/失败时调用 releaseController 释放。
 */
export function makeTrackedController(
  externalSignal?: AbortSignal
): AbortController {
  const controller = new AbortController();
  linkExternalSignal(externalSignal, controller);
  abortPool.add(controller);
  return controller;
}

/** 将 controller 从取消池释放(请求完成/失败时务必调用,避免内存泄漏) */
export function releaseController(controller?: AbortController) {
  if (controller) abortPool.delete(controller);
}

/** 退出登录或全局重置时:集中取消所有未决请求 */
export function cancelAll() {
  abortPool.forEach((c) => c.abort());
  abortPool.clear();
}

2) 在 client.ts 中的最小改动

按你的偏好,这里只给需要改的片段,并在复杂逻辑上一行加注释。

2.1 顶部引入:

ts
// + 新增
import { makeTrackedController, releaseController, cancelAll } from "./cancel";

2.2 删除原本在 client.ts 里的这两段:

ts
// - 删除
const abortPool = new Set<AbortController>();
function cancelAll() {
  /* ...原实现... */
}

2.3 请求拦截器里替换创建/管理 controller 的逻辑:

ts
// —— 将外部 signal 与内部 AbortController 关联,并纳入统一取消池 ——
const abortController = makeTrackedController(
  (req as any).signal as AbortSignal | undefined
);
(req as any).signal = abortController.signal;
(req as any).__abortController = abortController;

2.4 响应与异常分支里释放 controller:

ts
// —— 请求完成:释放与该请求关联的 AbortController ——
releaseController(
  (res.config as any).__abortController as AbortController | undefined
);

// —— 异常完成:同样需要释放 AbortController ——
releaseController(
  (err?.config as any)?.__abortController as AbortController | undefined
);

2.5 保持对外暴露实例方法:

ts
// 实例级的取消功能依然可用
(_axios as any).cancelAll = cancelAll;

这样,取消逻辑就从 client.ts 拆分到了独立的 cancel.ts,便于复用与测试;client.ts 里只保留“如何在拦截器中使用”的胶水代码。


3) “FormData 自动带 boundary,需移除手动设置的 Content-Type” 是啥意思?

  • 当你在浏览器环境FormData 作为请求体时,真正发请求的是浏览器(或者 fetch/XHR),它会自动把请求头 Content-Type 设置成:

    Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXyz...

    这里的 boundary 是一段分隔符,用来把每个字段/文件的二进制块分割开。这个 boundary 是浏览器动态生成的

  • 如果你手动把 Content-Type 固定写成 multipart/form-data(没有 boundary=...),服务端收到的内容就无法正确分段解析,经常出现 400/422 或 “Unexpected end of form / Missing boundary” 之类错误。

  • 因此,在我们封装里(contentType === 'formdata' 分支)会:

    ts
    // 复杂逻辑上一行注释:FormData 让浏览器自动带 boundary,需移除手动设置的 Content-Type
    if (cfg.headers) delete (cfg.headers as any)["Content-Type"];
    cfg.data = body instanceof FormData ? body : toFormData(body as any);

    这么做是故意Content-Type 删除,让浏览器自己填上带 boundary 的正确值。

小结:

  • 浏览器 + FormData:不要手写 Content-Type,让它自动带 boundary(我们代码里已处理)。
  • Node.js 端(无浏览器):若用 form-data/formdata-node 等库,也应让库自己设置或通过库提供的方法拿到带 boundary 的 header,而不是手写。

需要我把 client.ts 根据上面的片段整合成完整文件再贴一次,也可以说一声;但如果你已经在用我之前给的版本,只要按上面的 4 处小改即可完成 cancel.ts 的接入。

简短结论先给你:

  • Axios 封装没有“唯一正确解”,但大厂普遍遵循“分层 + 零破坏 + 渐进演进”三原则: Core(http 客户端能力)→ Domain API(领域方法)→ View/State(查询层)。
  • TanStack Query(React Query / Vue Query)不是必须。当你的页面需要缓存、并发去重、重试、后台刷新、预取、分页/无限滚动、乐观更新中的多项时,它的价值非常高;否则可以先不用,等复杂度上来了再加。

下面给你一套“可落地的选择题”,帮你快速拍板。


一、Axios 封装到底怎么选?

方案 A:极简封装(Core + 少量 Domain API)

适用:后端接口简单、页面少、交互不复杂,或以 Server Actions/Route Handlers(Next.js 14) 为主、客户端请求很少。 要点:

  • Core 层:一个 http 实例(拦截器、取消池、统一错误、鉴权)、http.call(options) 对象式入口(你现在这版 ✅)。
  • Domain 层:在 src/api/modules/* 里写薄封装函数(避免在页面拼 URL/方法)。 优点:学习/维护成本最低;对现有代码零破坏。 缺点:项目一复杂,缓存/并发/重试容易重复造轮子。

方案 B:标准化封装(Core + Domain API + 代码生成)

适用:接口较多、多人协作、需要强类型对齐后端(避免“文档/实现漂移”)。 要点:

  • 在 A 的基础上加 OpenAPI 代码生成(orval/openapi-typescript-codegen),产物放 api/__generated__只读、不手改
  • Domain 层再包一层“门面”,统一错误处理/埋点/灰度。 优点:类型和接口更新自动跟进;减少手抄 DTO。 缺点:引入生成链路,需要后端提供/维护 OpenAPI。

方案 C:企业级(Core + Domain + 代码生成 + 查询层 TanStack)

适用:列表/详情很多、需要缓存/失效/后台刷新/并发去重/预取/SSR等能力;或你已经遇到“重复请求、抖动、复杂 loading 状态”问题。 要点:

  • 在 B 的基础上引入 TanStack Query(React)/Vue Query(Vue3) 做服务端状态管理。
  • 页面里不直接 http.call,而是用 useQuery/useMutation 调你封装好的 Domain API。 优点:把“缓存/并发/重试/错误边界/重整化”交给成熟库,页面只管渲染。 缺点:学习成本 ↑,需要 Provider 初始化与查询键设计。

二、到底要不要用 TanStack Query?(自测 8 题)

请数一数你满足了多少条(≥3 条就建议上):

  1. 同一数据被多处页面/组件复用,且你想要共享缓存
  2. 同一接口在短时间内可能被多次触发(需要请求去重)。
  3. 希望失败自动重试退避,或在网络恢复/窗口聚焦时自动后台刷新
  4. 分页/无限滚动,并且要处理下一页预取乐观更新
  5. 需要预取数据(用户悬停/路由即将跳转时先打请求)。
  6. 你在做 Next.js 14 前端页面,既有 Server Components 也有 Client Components,客户端数据要SSR Hydration延迟刷新
  7. 显式管理缓存过期(staleTime)垃圾回收(gcTime),防止反复打接口。
  8. 现在已经在手写“请求缓存/合流/状态机”,感觉心智负担上来了。

结合你的项目:有价格趋势多商店选择首页与详情复用价格数据、后续还要图表/列表,我建议:

  • 这些“读多写少”的模块上用 TanStack Query;
  • 其他简单页面(一次性拉取、无复杂刷新),继续用现在的 http.call 即可。 这样“按需引入”,增量演进,不会一刀切。

三、你现在的最佳落地路线(最少折腾)

  1. 保留现有 http 层(你已完成)

    • client.ts + cancel.ts + call.ts + types.ts + utils.ts,不覆盖 axios.request零破坏保留 get/post
  2. Domain API 薄封装

    • src/api/modules/* 暴露语义化函数(priceApi.getTrend等),页面不拼 URL。
  3. 对“需要缓存/并发去重”的模块上 TanStack Query

    • 只在“价格趋势、榜单、列表”这些位置用 Query,其它模块不强求。
  4. 后续再接 OpenAPI 代码生成(可选)

    • 先让 Query 跑顺,再把类型维护成本转移给生成器,逐步替换手写 DTO。
  5. MSW 做联调与测试(可选)

    • 本地/CI 统一拦截,减少后端不可用带来的卡点。

四、常见误区(避免踩坑)

  • 覆写 axios.request → 破坏 get/post 的语义(你已经改成 .call,很对)。
  • 手写 Content-Type: multipart/form-data → FormData 的 boundary 会丢(我们已在 formdata 分支删除该头,让浏览器自动带)。
  • 在组件里定义 DTO/类型 → 类型四散;建议集中到 types/http/*api/__generated__
  • 重复写缓存/重试/去重 → 已有成熟轮子(TanStack Query);自己写容易遗漏边角。
  • 取消控制器不释放 → 造成内存泄漏;我们用 releaseController 在响应/异常两侧都释放。

五、如果暂时不用 TanStack Query,也能稳住吗?

可以。你的 http.call + modules 架构,配合**轻量的自定义缓存(Map + 过期时间)**也能顶一阵子。但一旦出现“相同查询在多处使用”、“滚动列表/分页”、“需要重试与后台刷新”的需求,切到 TanStack Query 成本更低(只需把 Domain API 包进 useQuery),没必要造大轮子。


你现在可以怎么做(两步):

  • 短期:继续用现有封装;把价格趋势这块接入 Query(仅此一处),体会收益。
  • 中期:如果感觉不错,再把“新闻列表/攻略列表”等也迁到 Query;并考虑引入 OpenAPI 代码生成,降低类型维护成本。

需要的话,我可以基于你现有的模块,直接给出一个最小的 TanStack Query 集成示例(包含 Provider、useQuery 的示例调用、SSR Hydration 的注意点),只贴必要的代码片段,不改你现有文件结构。

结论:你的“核心响应拦截器”(释放 AbortController + 401 跳转)先执行,setGlobalParser 里注册的“全局解析器”后执行

原因(Axios 规则):

  • response 拦截器是 FIFO:谁先注册,谁先执行
  • request 拦截器是 LIFO:谁后注册,谁先执行。(顺带一提)

在你这份代码里:

  1. createAxiosHttp 里先注册了“核心响应拦截器”。
  2. 应用启动时再调用 http.setGlobalParser([...]),此时追加一个响应拦截器。 → 因为 response 是 FIFO,所以核心拦截器先跑全局解析器后跑

执行顺序具体是:

  • 成功响应路径 (fulfilled) ① 核心响应拦截器:releaseController(...)return res ② 全局解析器:遍历 parsers,可能把 AxiosResponse 解包成你的业务数据(或继续返回原响应)

  • 失败响应路径 (rejected) ① 核心响应拦截器:遇到 401 清 token + 跳登录;不管什么错误都先 releaseController(...)return Promise.reject(err) ② 全局解析器:拿到上一步抛出的 err,再做标准化/改写错误对象(如果你这么实现)

小提示:如果你想让全局解析器先执行,就必须更早注册它(例如把 setGlobalParser 的安装逻辑挪到 createAxiosHttp 里、并放在核心拦截器之前),或者在核心拦截器内部先调用解析器再做释放/跳转。但你当前这种“先释放/401,再解析”的顺序其实更稳:能确保无论成功还是失败,先释放资源再做业务级处理,避免泄漏。

本站总访问