
一句话:
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
八、面试延伸
- 「
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。 -
「为什么实际项目还要封装一层?」
因为无论用fetch还是axios,都需要统一处理一批横切关注点:baseURL、超时、凭证(credentials/withCredentials)、认证头(token)、响应解包(业务数据在data里)、业务错误码(HTTP 200 但code !== 0)、鉴权失效(401 → 清状态 + 跳登录)、全局提示、取消请求、埋点。如果每个调用点各自处理,会导致「代码重复 + 处理不一致」(有的地方判了res.ok,有的忘了)。封装的目标是「让业务代码只关心业务」。 -
「
fetch有哪些容易踩的坑?」
五个:① 4xx/5xx 不 reject(最常见,会把错误响应当成功数据);② 没有超时(请求可能挂死);③ 需要手动解析 JSON,且body只能读一次(res.json()调两次会报错);④ 上传进度需要手动实现(response.body是ReadableStream);⑤ Cookie 默认不发送(跨域时要credentials: 'include')。此外fetch的网络错误是TypeError(不要跟业务错误混淆)。 -
「拦截器有什么用?」
拦截器是「在请求发出前 / 响应返回后统一插入逻辑」的机制(axios内置)。请求拦截器常做:注入 token、加 traceId、加语言头、取消重复请求、序列化参数;响应拦截器常做:解包业务数据、检查业务错误码、统一错误提示、处理 401 跳登录、错误类型转换。它的价值是「把横切逻辑集中到一处」,避免每个调用点重复写。用fetch时要自己在封装函数里实现等价的逻辑。 -
「401 和 403 该怎么处理?」
401 Unauthorized表示「没有有效身份凭证」(未登录、token 过期、token 无效)→ 应该「清除本地状态(user/token/缓存)+ 跳转登录页」,并记录来源以便登录后回跳;403 Forbidden表示「身份有效但无权限」→ 应该「就地提示无权限」(toast / 403 页面),不能跳登录页(因为用户登录了也没权限,只会造成困惑)。两者都应该在响应拦截器里统一处理,并且401要加「防重复跳转」的标志位(避免并发请求各自跳一次)。 -
「怎么取消请求?」
用标准的AbortController:创建controller,把controller.signal传给请求(fetch的signal选项 /axios的config.signal),需要取消时调controller.abort()。在 React 里典型用法是「在useEffect里创建 controller,在清理函数里 abort」——这样「依赖变化导致 effect 重跑」时,上一个请求会被取消,从而避免「旧请求后返回覆盖新数据」的竞态问题。注意:取消产生的错误(AbortError/CanceledError)不应该触发全局提示(否则用户会看到无意义的 toast)。 -
「业务错误码和 HTTP 状态码怎么配合?」
两种常见约定:① 只用 HTTP 状态码——RESTful 风格,400参数错误、401未认证、403无权限、404不存在、5xx服务端错误(推荐);② HTTP 200 + 业务码——所有响应都返回 200,用{ code, message, data }表达结果(国内后端常见)。实践中要注意「两者都要判」:先判 HTTP 状态(res.ok),再判业务码(code !== 0),否则会出现「HTTP 200 但业务失败被当成功」的 bug。封装时把这两层检查都放进拦截器/封装函数里,是「统一错误处理」的关键。 -
「网络错误、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 要防重复跳转。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!