前端接口请求层怎么封装?一个可落地的请求方案

全文约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 总结

一个可落地的请求层,至少包含这几件事:

  1. 统一 baseURL 与返回结构;
  2. 请求拦截带 token,响应拦截解包;
  3. HttpError / BizError / NetworkError 分类处理;
  4. 超时用 AbortController,重试只加在幂等接口;
  5. 入参出参全部类型化。

这五层搭好之后,业务代码就真的只关心业务。后面要换埋点、换错误监控、换灰度策略,都只动请求层一处,不用逐个接口翻。

最后提一个反模式:不要为了"统一"而把所有接口塞进一个巨型 class,也不要在请求层里写业务判断。请求层只做横切逻辑,业务分支留在业务文件里,否则过两个月自己都不想维护。薄而稳,比厚而全更耐用。按这个结构搭完,新项目第一天就把骨架立住,后面接口往上堆就行,不用每次回头改请求层。

相关推荐
默_笙2 小时前
🍔 中间件不只是打日志:四个钩子、一次短路,和自带的工具
前端·javascript
程序员老赵2 小时前
Docker 部署 Dolibarr:轻松搭建开源 ERP/CRM 平台
运维·前端·后端
星禾元亨3 小时前
同一件事有三份资料,AI 该信哪一份?冲突判定、优先级链与处置流程
前端·人工智能
Dovis(誓平步青云)3 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
穆梓兰煊3 小时前
TS5.7 vs 裸JS:AI应用少3坑
前端
榆瓷3 小时前
闲着没事,我把 WebGPU 的样板代码封成了一个库
前端
136096757233 小时前
页面打不开不是 Nginx 的错
前端·后端
liangshanbo12154 小时前
高级前端面试题:package.json 常见字段
前端·json
CappuccinoRose4 小时前
输入事件进阶
前端·javascript·输入事件
liangshanbo12156 小时前
面试题:前端工程化中 Tree Shaking的原理是什么?
前端·treeshaking