下载工作台
前端框架精通

HTTP 客户端与接口联调鉴权

试读上半部分 · 解锁后可读全文

第 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:为什么要封装

能力原生 fetchaxios 封装层
超时AbortController 手写timeout 配置
拦截器请求/响应统一处理
重试自行实现拦截器 + 退避策略
错误归一每处判断 response.okApiError 统一类型
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)
  }
)
配置建议值说明
timeout15s列表查询可单独加大
重试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

以下内容需解锁后阅读

试读已结束。解锁本章 ¥5.00,或开通年度会员畅读全部教程。
年度会员 ¥199.00/年; 小紫 AI 工作台有效会员 ¥99.00/年

正文仅在服务端鉴权后下发,未付费无法获取下半部分内容。