第 13 章 · HTTP 客户端与接口联调鉴权
本章目标:封装生产级 axios 客户端(baseURL、超时、重试);实现请求/响应拦截器与 双 Token 刷新;完成示例商城登录、订单列表联调;理解 CORS、CSRF、Cookie vs Bearer;统一错误码与 loading;用 MSW Mock 与多环境切换。
学时建议:4~5 小时
前置:本模块 ch16(表单提交)、frontend-web ch08~ch09(Fetch 基础)。
13.1 从 fetch 到 axios:为什么要封装
| 能力 | 原生 fetch | axios 封装层 |
|---|---|---|
| 超时 | 需 AbortController 手写 | timeout 配置 |
| 拦截器 | 无 | 请求/响应统一处理 |
| 重试 | 自行实现 | 拦截器 + 退避策略 |
| 错误归一 | 每处判断 response.ok | ApiError 统一类型 |
| TS 泛型 | 弱 | get<Order[]>(url) |
行业案例 · 贤紫优选商城:商户后台日均 API 调用约 12 万次;统一 axios 实例后,401 自动刷新 Token 使「登录过期」相关工单下降 70%,错误码映射使前端展示文案与客服话术一致。
13.2 目录与环境变量
src/
├── api/
│ ├── http.ts # axios 实例 + 拦截器
│ ├── auth.ts # login / refresh / logout
│ ├── order.ts # 订单列表
│ └── types/
│ └── api.ts # 通用响应、错误码
├── config/
│ └── env.ts
└── mocks/
├── browser.ts # MSW 入口
└── handlers/
├── auth.ts
└── orders.ts
// config/env.ts
export const env = {
apiBase: import.meta.env.VITE_API_BASE_URL ?? '/api',
useMock: import.meta.env.VITE_USE_MOCK === 'true',
appName: import.meta.env.VITE_APP_NAME ?? '贤紫商户后台',
}
# .env.development
VITE_API_BASE_URL=http://localhost:8000/api/v1
VITE_USE_MOCK=true
# .env.production
VITE_API_BASE_URL=https://api.xianzi.shop/v1
VITE_USE_MOCK=false
13.3 axios 实例:baseURL、超时、重试
npm install axios
// api/http.ts
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { env } from '@/config/env'
const MAX_RETRY = 2
const RETRY_DELAY_MS = 500
export const http = axios.create({
baseURL: env.apiBase,
timeout: 15_000,
headers: { 'Content-Type': 'application/json' },
})
/** 仅对幂等请求或网络错误重试 */
function shouldRetry(error: AxiosError, config: InternalAxiosRequestConfig) {
const retryCount = (config as any).__retryCount ?? 0
if (retryCount >= MAX_RETRY) return false
const method = (config.method ?? 'get').toLowerCase()
const idempotent = ['get', 'head', 'options', 'put', 'delete'].includes(method)
const isNetwork = !error.response
const is5xx = error.response?.status != null && error.response.status >= 500
return idempotent && (isNetwork || is5xx)
}
http.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const config = error.config
if (!config || !shouldRetry(error, config)) throw error
;(config as any).__retryCount = ((config as any).__retryCount ?? 0) + 1
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * (config as any).__retryCount))
return http.request(config)
}
)
| 配置 | 建议值 | 说明 |
|---|---|---|
timeout | 15s | 列表查询可单独加大 |
| 重试 | GET/PUT 最多 2 次 | POST 创建订单默认不重试 |
baseURL | 环境变量 | 禁止硬编码生产域名 |
13.4 请求拦截器:附加 Token
// api/http.ts(续)
import { getAccessToken } from '@/stores/auth'
http.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 贤紫 API 约定:部分接口需租户头
config.headers['X-Tenant-Id'] = localStorage.getItem('tenantId') ?? ''
return config
})
13.5 双 Token 刷新(Access + Refresh)
登录 ──► accessToken(短,15min)+ refreshToken(长,7d)
│
请求 API ──► 401 + code=TOKEN_EXPIRED
│
refresh POST /auth/refresh { refreshToken }
├─ 200:换新 access,重放队列中的失败请求
└─ 401:清会话,跳转 /login
// 核心:refreshing 单飞 + queue 挂起 401 请求,刷新成功后 flush 重放
let refreshing = false
let queue: Array<(token: string) => void> = []
http.interceptors.response.use(undefined, async (error) => {
if (error.response?.status !== 401 || error.response?.data?.code !== 'TOKEN_EXPIRED') throw error
if (refreshing) {
return new Promise((resolve) => {
queue.push((token) => resolve(http.request({ ...error.config, headers: { Authorization: `Bearer ${token}` } })))
})
}
refreshing = true
try {
const newToken = await refreshAccessToken()
queue.forEach((cb) => cb(newToken)); queue = []
return http.request(error.config)
} finally { refreshing = false }
})
| 要点 | 说明 |
|---|---|
| 单飞刷新 | refreshing 标志防并发多次 refresh |
| 请求队列 | 刷新期间挂起 401 请求,成功后重放 |
| Refresh 存储 | httpOnly Cookie(推荐)或 secure localStorage |
13.6 响应拦截与统一错误码
// api/types/api.ts
export interface ApiResponse<T = unknown> {
code: string
message: string
data: T
fieldErrors?: { field: string; message: string }[]
}
export class ApiError extends Error {
constructor(
public code: string,
message: string,
public status?: number,
public fieldErrors?: ApiResponse['fieldErrors']
) {
super(message)
}
}
const ERROR_MESSAGES: Record<string, string> = {
AUTH_INVALID: '账号或密码错误',
PERMISSION_DENIED: '无权限访问',
ORDER_NOT_FOUND: '订单不存在',
RATE_LIMITED: '操作过于频繁,请稍后再试',
}
function normalizeError(error: AxiosError<ApiResponse>): ApiError {
const body = error.response?.data