全文约2300字,预计阅读9分钟。
每个新项目启动时,请求层大概都是从一行 fetch(url) 开始的。随着接口越接越多,token 怎么带、401 怎么跳登录、网络抖动要不要重试、错误提示在哪一层弹,这些逻辑就散落在几十个业务文件里。封装请求层的目的,不是造一个更复杂的轮子,而是把这些横切逻辑收到一处,让业务代码只关心"调哪个接口、传什么参、拿什么数据"。这篇文章给出一个从拦截器、错误处理、超时重试到类型标注的完整方案,可以直接搬去改造现有项目。
01 为什么要单独封装请求层
裸写 fetch 的问题集中在三点:
- 每个调用点都要自己拼 baseURL、带 token、解析 JSON,重复代码多;
- HTTP 错误、业务错误、网络错误混在一起,业务层很难统一处理;
- 接口参数和返回值没有类型约束,改字段时编译期发现不了。
封装之后,业务侧长这样:
ts
const user = await api.user.getDetail({ id: 1 })
所有横切逻辑都在 api 层里。
02 基础结构:一个薄封装
先定义统一的返回结构。后端通常会包一层 { code, data, message },前端把它类型化:
ts
export interface ApiResponse<T = unknown> {
code: number
data: T
message: string
}
然后基于 fetch 写一个最薄的请求函数:
ts
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const baseURL = import.meta.env.VITE_API_BASE ?? '/api'
const res = await fetch(baseURL + path, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
})
if (!res.ok) throw new HttpError(res.status, res.statusText)
const body = (await res.json()) as ApiResponse<T>
if (body.code !== 0) throw new BizError(body.code, body.message)
return body.data
}
这里先把两类错误区分开:HTTP 层抛 HttpError,业务层抛 BizError,后面处理起来不会混。
03 请求与响应拦截器
拦截器不是某个库的专属概念,自己写函数也能实现。请求拦截负责统一注入 token:
ts
type ReqInterceptor = (init: RequestInit) => RequestInit
const reqInterceptors: ReqInterceptor[] = [
(init) => {
const token = localStorage.getItem('token')
return {
...init,
headers: { ...(init.headers ?? {}), Authorization: token ?? '' },
}
},
]
响应拦截负责统一解包和错误归一。把它们串进 request:
ts
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
let cfg = init
for (const fn of reqInterceptors) cfg = fn(cfg)
const res = await fetch(baseURL + path, cfg)
// ... 错误处理与解包
}
这样后续要加埋点、加灰度头、加时间戳,只要往拦截器数组里 push 一项,不用动业务代码。
04 错误处理:三类错误分开管
前端请求错误实际有三类,处理策略各不相同:
| 错误类型 | 触发场景 | 处理方式 |
|---|---|---|
| HttpError | 4xx / 5xx | 401 跳登录,5xx 提示稍后重试 |
| BizError | code 非 0 | 按 message 弹提示 |
| NetworkError | 断网 / CORS | 提示网络异常 |
集中处理示例:
ts
function handleError(err: unknown) {
if (err instanceof HttpError) {
if (err.status === 401) {
location.href = '/login'
return
}
toast.error(`请求失败(${err.status})`)
} else if (err instanceof BizError) {
toast.error(err.message)
} else {
toast.error('网络异常,请稍后再试')
}
}
业务代码里 try/catch 只需要处理"这个接口失败时页面要干什么",弹提示这种事交给上层。
05 超时与重试策略
fetch 本身不带超时,要用 AbortController:
ts
async function withTimeout<T>(p: Promise<T>, ms = 10000): Promise<T> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), ms)
try {
return await p
} finally {
clearTimeout(timer)
}
}
重试不能无脑加。原则是:只对幂等请求(GET)重试,POST/PUT 默认不重试,避免重复提交。重试次数控制在两到三次,指数退避:
ts
async function retry<T>(fn: () => Promise<T>, times = 2, delay = 300): Promise<T> {
try {
return await fn()
} catch (err) {
if (times <= 0) throw err
await new Promise((r) => setTimeout(r, delay))
return retry(fn, times - 1, delay * 2)
}
}
GET 请求包一层 retry,写操作接口不包,是比较稳的默认选择。
06 TypeScript 类型层
请求层封装得好不好,看业务侧调用时有没有类型提示。把每个接口的入参出参都定义成 interface:
ts
interface UserDetailReq { id: number }
interface UserDetailData { id: number; name: string; avatar: string }
const api = {
user: {
getDetail: (q: UserDetailReq) =>
request<UserDetailData>(`/user/detail?id=${q.id}`),
},
}
业务侧 api.user.getDetail({ id: 1 }) 拿回来的 user.name 就有自动补全,改后端字段时 TS 会直接报错,不用等运行时才发现。
07 多环境与方法封装
baseURL 不要写死在代码里。开发、测试、生产三套环境,从环境变量读取:
ts
const baseURL = import.meta.env.VITE_API_BASE ?? '/api'
打包时由构建工具注入对应的值,本地开发走代理,生产走真实域名,代码不用动。
常用方法再包一层,业务侧调用更直观:
ts
export const http = {
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}
业务里 http.post('/order/create', { sku }) 一行搞定,不用每次都写 method 和 JSON.stringify。
08 常见横切需求怎么挂进去
请求层稳定之后,业务里还会冒出几个横切需求,都能在这一层解决。
统一 loading: 在请求拦截里维护一个计数器,进入时加一、响应回来减一,归零时关闭全局 loading。这样业务组件不用每个都写 loading.value = true/false。
取消上一次请求: 搜索框输入时,每敲一个字就发一次请求,旧请求回来会覆盖新结果。做法是用一个 map 存每个接口对应的 AbortController,新请求发出前先 abort 旧的:
ts
const pendings = new Map<string, AbortController>()
function cancelPrevious(key: string) {
pendings.get(key)?.abort()
const ctrl = new AbortController()
pendings.set(key, ctrl)
return ctrl.signal
}
路由切换时清理未完成请求: 在路由守卫里遍历 pendings 全部 abort,避免离开页面后请求回来还去操作已经销毁的组件状态。
错误上报: 错误处理函数里顺便把 HttpError、BizError 上报到监控平台,这样线上出问题不用靠用户截图猜。上报只发元数据,不发敏感参数。
09 踩坑记录
踩坑一:token 过期和 401 死循环。 401 之后直接跳登录,但登录接口本身也走请求层,会再次 401。修复:在 401 处理里判断当前路径,已经在登录页就不再跳。
踩坑二:GET 请求重试导致重复拉取。 GET 是幂等的,但如果接口里带了埋点统计,重试会放大计数。统计类接口建议关闭重试。
踩坑三:错误提示弹 N 次。 并发十个请求同时失败,toast 弹十次。修复:在错误处理层做节流,同一类错误短时间内只弹一次。
踩坑四:AbortController 没传进 fetch。 写了超时函数但忘记把 ctrl.signal 传给 fetch,超时其实不生效。把 signal 透传是这一段最容易漏的地方。
10 总结
一个可落地的请求层,至少包含这几件事:
- 统一 baseURL 与返回结构;
- 请求拦截带 token,响应拦截解包;
- HttpError / BizError / NetworkError 分类处理;
- 超时用 AbortController,重试只加在幂等接口;
- 入参出参全部类型化。
这五层搭好之后,业务代码就真的只关心业务。后面要换埋点、换错误监控、换灰度策略,都只动请求层一处,不用逐个接口翻。
最后提一个反模式:不要为了"统一"而把所有接口塞进一个巨型 class,也不要在请求层里写业务判断。请求层只做横切逻辑,业务分支留在业务文件里,否则过两个月自己都不想维护。薄而稳,比厚而全更耐用。按这个结构搭完,新项目第一天就把骨架立住,后面接口往上堆就行,不用每次回头改请求层。