陌上人如玉
公子世无双

7.1 fetch 与 axios 封装

7.1 fetch 与 axios 封装

一句话:fetch 是浏览器原生 API(体积为零、但不会因 4xx/5xx 报错、无超时、需手动解析),axios 是功能完备的库(拦截器、超时、自动 JSON、进度);实际项目里两者都需要一层统一封装。

一、fetch vs axios

维度 fetch axios
来源 浏览器原生(Node 18+ 也有) 第三方库(约 30KB)
是否 reject 4xx/5xx ❌ 只有网络错误才 reject ✅ 默认 reject,可用 validateStatus 改
自动解析 JSON ❌ 需要 res.json() ✅ 自动
超时 ❌ 需要 AbortController 自己实现 ✅ timeout 选项
拦截器 ❌ ✅ 请求/响应拦截器
请求/响应转换 手动 transformRequest / transformResponse
进度(上传/下载) ❌(需 ReadableStream 手动算) ✅ onUploadProgress / onDownloadProgress
取消请求 ✅ AbortController(标准) ✅ AbortController(新版)/ CancelToken(旧)
浏览器兼容 现代浏览器 更广(含旧版 polyfill 能力)
体积 0 约 13KB gzip
生态 原生 成熟(msw、a11y、XHR 适配器等)
// fetch 的三个「坑」
// ① 4xx/5xx 不会 reject → 必须自己判 res.ok
const res = await fetch('/api/users/999');
if (!res.ok) {
  // ⚠️ 不写这段,就会把错误响应当成成功数据处理
  throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();

// ② 没有超时 → 请求可能永远挂着
// ③ 没有自动 JSON → 忘记 res.json() 会拿到 ReadableStream

二、统一封装的目标

一个「好的请求层」应该统一处理:

① baseURL(避免每个请求写完整域名)
② 超时(默认 10~30 秒,避免请求挂死)
③ 凭证(`credentials: 'include'` 或 token 头)
④ 请求头(Content-Type、Accept-Language、traceId)
⑤ 响应格式(统一「解包」业务数据,或统一抛错)
⑥ 业务错误码(HTTP 200 但 code !== 0 的情况)
⑦ 鉴权失效(401 → 清状态 + 跳登录)
⑧ 全局提示(网络错误、服务端错误 → toast)
⑨ 取消请求(用 AbortController)
⑩ 埋点/日志(记录耗时、成功率)

三、基于 fetch 的封装

// utils/request.ts
export interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

export class HttpError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly payload?: unknown
  ) {
    super(message);
    this.name = 'HttpError';
  }
}

interface RequestOptions extends Omit<RequestInit, 'body'> {
  baseURL?: string;
  timeout?: number;
  params?: Record<string, string | number | boolean | undefined | null>;
  body?: unknown;
  /** 是否跳过业务码检查(如 code !== 0 也返回 data) */
  raw?: boolean;
}

const DEFAULT_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
const DEFAULT_TIMEOUT = 15_000;

export async function request<T>(url: string, options: RequestOptions = {}): Promise<T> {
  const { baseURL = DEFAULT_BASE_URL, timeout = DEFAULT_TIMEOUT, params, body, raw, ...init } = options;

  // ① 拼接查询参数(过滤空值)
  const query = params
    ? '?' + new URLSearchParams(
        Object.entries(params)
          .filter(([, v]) => v !== undefined && v !== null && v !== '')
          .map(([k, v]) => [k, String(v)])
      ).toString()
    : '';

  // ② 超时:用 AbortController + 定时器
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new DOMException('Timeout', 'TimeoutError')), timeout);

  // ③ 合并「外部传入的 signal」(支持调用方取消)
  if (init.signal) {
    init.signal.addEventListener('abort', () => controller.abort(init.signal!.reason), { once: true });
  }

  try {
    const res = await fetch(`${baseURL}${url}${query}`, {
      ...init,
      signal: controller.signal,
      credentials: 'include',                       // ⭐ 携带 cookie
      headers: {
        Accept: 'application/json',
        ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
        ...getAuthHeaders(),                        // ⭐ token
        ...init.headers,
      },
      body: body !== undefined ? JSON.stringify(body) : undefined,
    });

    // ④ 空响应(204)直接返回
    if (res.status === 204) return undefined as T;

    // ⑤ 解析(按 content-type 决定)
    const contentType = res.headers.get('content-type') ?? '';
    const payload = contentType.includes('application/json')
      ? await res.json()
      : await res.text();

    // ⑥ HTTP 层错误
    if (!res.ok) {
      if (res.status === 401) handleUnauthorized();          // ⭐ 统一处理鉴权失效
      throw new HttpError(pickMessage(payload) ?? res.statusText, res.status, payload);
    }

    // ⑦ 业务层错误(HTTP 200 但 code !== 0)
    const data = payload as ApiResponse<T> | T;
    if (!raw && isWrapped(data) && data.code !== 0) {
      throw new HttpError(data.message || '请求失败', res.status, data);
    }

    return raw ? (data as T) : ((data as ApiResponse<T>).data ?? (data as T));
  } catch (err) {
    // ⑧ 统一转换错误类型
    if (err instanceof DOMException && err.name === 'TimeoutError') {
      throw new HttpError('请求超时,请稍后重试', 408);
    }
    if (err instanceof DOMException && err.name === 'AbortError') {
      throw err;                                            // 主动取消 → 交给调用方忽略
    }
    if (err instanceof TypeError) {
      throw new HttpError('网络异常,请检查网络连接', 0);      // fetch 的网络错误是 TypeError
    }
    throw err;
  } finally {
    clearTimeout(timer);                                    // ⭐ 一定要清
  }
}

// 便于按方法调用
export const http = {
  get: <T>(url: string, options?: RequestOptions) => request<T>(url, { ...options, method: 'GET' }),
  post: <T>(url: string, body?: unknown, options?: RequestOptions) => request<T>(url, { ...options, method: 'POST', body }),
  put: <T>(url: string, body?: unknown, options?: RequestOptions) => request<T>(url, { ...options, method: 'PUT', body }),
  patch: <T>(url: string, body?: unknown, options?: RequestOptions) => request<T>(url, { ...options, method: 'PATCH', body }),
  delete: <T>(url: string, options?: RequestOptions) => request<T>(url, { ...options, method: 'DELETE' }),
};
// 辅助函数
function getAuthHeaders(): Record<string, string> {
  const token = authStore.getState().token;
  return token ? { Authorization: `Bearer ${token}` } : {};
}

function pickMessage(payload: unknown): string | undefined {
  if (payload && typeof payload === 'object' && 'message' in payload) {
    const m = (payload as { message?: unknown }).message;
    if (typeof m === 'string') return m;
  }
  // 处理「字段校验错误」的常见结构
  if (payload && typeof payload === 'object' && 'errors' in payload) {
    const errs = (payload as { errors?: Record<string, string[]> }).errors;
    if (errs) return Object.values(errs).flat().join(';');
  }
  return undefined;
}

function isWrapped<T>(v: unknown): v is ApiResponse<T> {
  return !!v && typeof v === 'object' && 'code' in v;
}
// 使用
const users = await http.get<User[]>('/users', { params: { page: 1, keyword: 'react' } });
await http.post('/users', { name: '张三' });

四、axios 封装

// utils/http.ts
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';

const instance: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
  timeout: 15_000,
  withCredentials: true,                    // ⭐ 携带 cookie
  headers: { Accept: 'application/json' },
});

// ① 请求拦截器
instance.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = authStore.getState().token;
    if (token) config.headers.Authorization = `Bearer ${token}`;

    // traceId(便于前后端联调排错)
    config.headers['X-Trace-Id'] = crypto.randomUUID();

    // 「同一请求 cancel 上一个」(如实时搜索)
    if (config.cancelPrevious) {
      cancelSourceMap.get(buildKey(config))?.cancel('duplicated request');
      cancelSourceMap.set(buildKey(config), new AbortController());
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// ② 响应拦截器
instance.interceptors.response.use(
  (res) => {
    const body = res.data;

    // 业务码检查
    if (body && typeof body === 'object' && 'code' in body && body.code !== 0) {
      const error = new HttpError(body.message ?? '请求失败', res.status, body);
      toast.error(error.message);                     // ⭐ 统一提示(可配置跳过)
      return Promise.reject(error);
    }

    return body?.data !== undefined ? body.data : body;   // ⭐ 解包
  },
  async (error: AxiosError) => {
    const status = error.response?.status;

    if (status === 401) {
      authStore.getState().clear();
      queryClient.clear();
      globalNavigate?.('/login', { replace: true });       // ⭐ 跳登录(防重复跳转)
    } else if (status === 403) {
      toast.error('没有权限执行此操作');                     // ⚠️ 不跳登录
    } else if (status && status >= 500) {
      toast.error('服务异常,请稍后重试');
    } else if (error.code === 'ECONNABORTED') {
      toast.error('请求超时,请稍后重试');
    } else if (!error.response) {
      toast.error('网络异常,请检查网络连接');
    }

    return Promise.reject(error);
  }
);
// ③ 类型化的封装(让调用方拿到「解包后」的类型)
export const http = {
  get: <T>(url: string, config?: AxiosRequestConfig) => instance.get<T, T>(url, config),
  post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) => instance.post<T, T>(url, data, config),
  put: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) => instance.put<T, T>(url, data, config),
  delete: <T>(url: string, config?: AxiosRequestConfig) => instance.delete<T, T>(url, config),
};
// ⭐ axios 的第二个泛型参数是「返回值类型」(因为拦截器已解包)

五、错误处理约定

三种「错误」要分清:

① 网络层错误
   · 断网、DNS 失败、CORS 拦截 → fetch 抛 TypeError / axios 无 response
   · 处理:提示「网络异常」

② HTTP 层错误(4xx / 5xx)
   · 400 参数错误、401 未认证、403 无权限、404 不存在、429 限流、5xx 服务异常
   · 处理:401 跳登录、403 提示无权限、5xx 提示稍后重试、429 提示「操作太频繁」

③ 业务层错误(HTTP 200 但 code !== 0)
   · 如「余额不足」「用户名已存在」
   · 处理:把 message 展示给用户(或交给调用方定制处理)
// ✅ 让调用方能「选择性处理错误」(比如表单错误就地展示,而不是全局 toast)
interface RequestOptions {
  /** 是否跳过全局错误提示(由调用方自己处理) */
  skipErrorToast?: boolean;
}

// 在拦截器里
if (!error.config?.skipErrorToast) {
  toast.error(message);
}
return Promise.reject(error);

// 调用方(表单提交:想让错误显示在表单里)
try {
  await http.post('/users', data, { skipErrorToast: true });
} catch (err) {
  setFieldError('username', (err as HttpError).message);
}
⚠️ 「全局 toast」与「就地处理」的取舍:
· 优点:统一、不漏提示
· 缺点:所有错误都 toast 会显得「噪音大」(尤其是表单校验)
· 解法:默认全局提示 + 提供 `skipErrorToast` 让调用方接管

六、取消请求

// ✅ 标准做法:AbortController(fetch 与新版 axios 都支持)
const controller = new AbortController();

const promise = http.get('/search', {
  params: { q: keyword },
  signal: controller.signal,
});

// 取消
controller.abort();

// 在拦截器里要「区分」取消与真实错误(取消不应该 toast)
if (axios.isCancel(error) || error.name === 'CanceledError') {
  return Promise.reject(error);      // ⭐ 直接透传,不提示
}
// ✅ 组件里的用法(配合 useEffect 清理)
function Search({ keyword }: { keyword: string }) {
  const [results, setResults] = useState<Item[]>([]);

  useEffect(() => {
    const controller = new AbortController();

    http
      .get<Item[]>('/search', { params: { q: keyword }, signal: controller.signal })
      .then(setResults)
      .catch((err) => {
        if (err.name === 'AbortError' || err.name === 'CanceledError') return;   // ⭐ 忽略取消
        setError(err.message);
      });

    return () => controller.abort();          // ⭐ 依赖变化时取消上一个请求
  }, [keyword]);

  return <List items={results} />;
}

七、常见坑

// ① 用 fetch 但忘了判 res.ok
const data = await (await fetch(url)).json();     // ❌ 4xx/5xx 也当成功

// ② fetch 没有超时 → 请求挂死(用户一直等)
// ③ 忘记 res.json() 或重复调用(body 只能读一次)
const res = await fetch(url);
const a = await res.json();
const b = await res.json();                       // ❌ 报错:body already read

// ④ 用 JSON.stringify 处理 FormData(会变成 {})
fetch(url, { body: JSON.stringify(formData) });    // ❌
fetch(url, { body: formData });                    // ✅(不要设 Content-Type)

// ⑤ 手动设置 multipart 的 Content-Type(缺少 boundary)
headers: { 'Content-Type': 'multipart/form-data' }  // ❌ 浏览器需要自己加 boundary

// ⑥ 在拦截器里 toast「取消请求」的错误(噪音)
// ⑦ 401 时「并发请求各自跳转登录页」(加标志位防抖)
// ⑧ 把所有错误都 toast(表单校验错误应该就地展示)
// ⑨ 忘记 withCredentials / credentials: 'include'(跨域时 cookie 不发送)
// ⑩ 在请求拦截器里读 store 但 store 还没初始化(循环依赖)
// ⑪ 用 axios 的 CancelToken(已废弃)而不是 AbortController
// ⑫ 响应拦截器「解包」了 data,但类型没跟着改(返回类型对不上)
// ⑬ 超时计时器没 clearTimeout(内存泄漏 + 误触发)
// ⑭ 把「业务码检查」放在每个调用处(应该统一在拦截器)
// ⑮ 忽略「HTTP 200 + 业务失败」的情况(把失败当成功)
// ⑯ 用 fetch 处理「大文件上传进度」却没实现(fetch 无原生进度)
// ⑰ 在 SSR 里用 axios(Node 端可以,但要注意 baseURL 与 cookie 传递)
// ⑱ 请求层的错误类型不统一(有时 Error 有时 string)→ 调用方难以处理
// ⑤ 的说明
// ❌ 手动设置 Content-Type 会丢掉 boundary,服务器解析失败
fetch('/upload', {
  method: 'POST',
  headers: { 'Content-Type': 'multipart/form-data' },
  body: formData,
});

// ✅ 让浏览器自动设置(含 boundary)
fetch('/upload', { method: 'POST', body: formData });

// ✅ 用 axios 也一样
axios.post('/upload', formData);           // 不要手动设 Content-Type

八、面试延伸

  1. 「fetch 和 axios 有什么区别?」
    关键差异:① fetch 不会因 4xx/5xx reject(只有网络错误才 reject)——必须自己判 res.ok;axios 默认 reject,并允许用 validateStatus 定制。② fetch 没有超时——要用 AbortController + 定时器自己实现;axios 有 timeout。③ fetch 不自动解析 JSON——要手动 res.json();axios 自动。④ fetch 没有拦截器——需要自己封装;axios 有请求/响应拦截器。⑤ 上传/下载进度——fetch 没有原生支持(要手动处理 ReadableStream),axios 有 onUploadProgress。⑥ 体积——fetch 为零(原生),axios 约 13KB gzip。

  2. 「为什么实际项目还要封装一层?」
    因为无论用 fetch 还是 axios,都需要统一处理一批横切关注点:baseURL、超时、凭证(credentials/withCredentials)、认证头(token)、响应解包(业务数据在 data 里)、业务错误码(HTTP 200 但 code !== 0)、鉴权失效(401 → 清状态 + 跳登录)、全局提示、取消请求、埋点。如果每个调用点各自处理,会导致「代码重复 + 处理不一致」(有的地方判了 res.ok,有的忘了)。封装的目标是「让业务代码只关心业务」。

  3. 「fetch 有哪些容易踩的坑?」
    五个:① 4xx/5xx 不 reject(最常见,会把错误响应当成功数据);② 没有超时(请求可能挂死);③ 需要手动解析 JSON,且 body 只能读一次(res.json() 调两次会报错);④ 上传进度需要手动实现(response.body 是 ReadableStream);⑤ Cookie 默认不发送(跨域时要 credentials: 'include')。此外 fetch 的网络错误是 TypeError(不要跟业务错误混淆)。

  4. 「拦截器有什么用?」
    拦截器是「在请求发出前 / 响应返回后统一插入逻辑」的机制(axios 内置)。请求拦截器常做:注入 token、加 traceId、加语言头、取消重复请求、序列化参数;响应拦截器常做:解包业务数据、检查业务错误码、统一错误提示、处理 401 跳登录、错误类型转换。它的价值是「把横切逻辑集中到一处」,避免每个调用点重复写。用 fetch 时要自己在封装函数里实现等价的逻辑。

  5. 「401 和 403 该怎么处理?」
    401 Unauthorized 表示「没有有效身份凭证」(未登录、token 过期、token 无效)→ 应该「清除本地状态(user/token/缓存)+ 跳转登录页」,并记录来源以便登录后回跳;403 Forbidden 表示「身份有效但无权限」→ 应该「就地提示无权限」(toast / 403 页面),不能跳登录页(因为用户登录了也没权限,只会造成困惑)。两者都应该在响应拦截器里统一处理,并且 401 要加「防重复跳转」的标志位(避免并发请求各自跳一次)。

  6. 「怎么取消请求?」
    用标准的 AbortController:创建 controller,把 controller.signal 传给请求(fetch 的 signal 选项 / axios 的 config.signal),需要取消时调 controller.abort()。在 React 里典型用法是「在 useEffect 里创建 controller,在清理函数里 abort」——这样「依赖变化导致 effect 重跑」时,上一个请求会被取消,从而避免「旧请求后返回覆盖新数据」的竞态问题。注意:取消产生的错误(AbortError / CanceledError)不应该触发全局提示(否则用户会看到无意义的 toast)。

  7. 「业务错误码和 HTTP 状态码怎么配合?」
    两种常见约定:① 只用 HTTP 状态码——RESTful 风格,400 参数错误、401 未认证、403 无权限、404 不存在、5xx 服务端错误(推荐);② HTTP 200 + 业务码——所有响应都返回 200,用 { code, message, data } 表达结果(国内后端常见)。实践中要注意「两者都要判」:先判 HTTP 状态(res.ok),再判业务码(code !== 0),否则会出现「HTTP 200 但业务失败被当成功」的 bug。封装时把这两层检查都放进拦截器/封装函数里,是「统一错误处理」的关键。

  8. 「网络错误、HTTP 错误、业务错误分别怎么处理?」
    三类分开:① 网络层错误(断网、DNS、CORS)——fetch 抛 TypeError、axios 无 response → 提示「网络异常,请检查网络连接」;② HTTP 层错误——按状态码分支处理(401 跳登录、403 提示无权限、429 提示「操作过于频繁」、5xx 提示「服务异常,请稍后重试」);③ 业务层错误(code !== 0)——通常把 message 直接展示给用户,但要允许调用方接管(如「用户名已存在」应该显示在表单字段旁,而不是全局 toast)。所以封装要提供类似 skipErrorToast 的开关。

一句话速记

fetch 原生零体积但不因 4xx/5xx reject(要判 res.ok)、无超时(要 AbortController)、不自动解析 JSON、无拦截器与进度;axios 这些都内置(体积约 13KB);两者都要封装一层,统一处理「baseURL、超时、凭证、token、响应解包、业务码 code !== 0、401 清状态跳登录、403 就地提示、全局提示开关、取消请求」;取消用 AbortController(useEffect 清理里 abort,且不要给取消错误弹提示);网络错误 / HTTP 错误 / 业务错误要分类处理;fetch 传 FormData 时不要手动设 Content-Type(会丢 boundary);401 要防重复跳转。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.1 fetch 与 axios 封装

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫