API 封装方法论(进阶版)

本文档系统讲解 React 项目中的 API 封装方法论,覆盖从基础 fetch 封装到生产级拦截器、重试、缓存、Mock 的完整体系。

所有代码示例均基于当前项目实现,配合深度原理解析。


目录

  1. 架构总览
  2. 目录结构
  3. [第 0 层:共享类型(shared.ts)](#第 0 层:共享类型(shared.ts))
  4. [第 5 层:环境配置](#第 5 层:环境配置)
  5. [第 1 层:基础请求层(含拦截器)](#第 1 层:基础请求层(含拦截器))
  6. [第 2 层:接口定义层](#第 2 层:接口定义层)
  7. [第 3 层:数据转换层](#第 3 层:数据转换层)
  8. 实战:新增业务模块
  9. 进阶:请求拦截器机制
  10. 进阶:智能重试策略
  11. 进阶:内存缓存层
  12. [进阶:Mock 数据接入](#进阶:Mock 数据接入)
  13. 进阶:错误码规范与统一处理
  14. 进阶:请求取消与竞态条件
  15. 生产级完整架构
  16. 常见问题排查清单
  17. 升级路线

1. 架构总览

复制代码
┌─────────────────────────────────────────────┐
│  0. 共享类型层(shared.ts)             ← 安全枢纽
│     TodoDto / TodoLike / ApiResponse 等     │
├─────────────────────────────────────────────┤
│  5. 环境配置层(env.ts)                     │
│     多环境 baseURL、超时、鉴权配置          │
├─────────────────────────────────────────────┤
│  1. 基础请求层(request.ts)                 │
│     fetch 封装 + 拦截器 + 错误处理 + 重试   │
├─────────────────────────────────────────────┤
│  业务模块层(modules/*/)                   │
│     ├── todo/todoApi.ts      接口定义       │
│     ├── todo/todoMapper.ts   数据转换       │
│     └── todo/index.ts        子 Barrel     │
├─────────────────────────────────────────────┤
│  业务接入层(由 TodoContext 等消费)         │
│     组件 → useTodos() → todoApi.create()    │
└─────────────────────────────────────────────┘

调用顺序:组件 → Context/Hook → 模块 API → Mapper → Request → 后端

为什么加"第 0 层"?

问题 :如果 todoMapper.ts 直接 import { Todo } from '../../components/todo/types',会形成"API 层 → 业务层"的反向依赖,导致循环依赖和模块解析失败。

解法 :把 TodoLikeTodoDto 等"类型契约"独立到 shared.ts,它:

  • ❌ 不依赖任何业务组件
  • ✅ 所有模块(env/request/api/mapper)都可以安全 import
  • ✅ 业务层的 Todo 类型可以直接复用 TodoLike

2. 目录结构

复制代码
src/api/
├── shared.ts                ← 共享类型(所有模块可安全依赖)
├── env.ts                   ← 环境配置
├── request.ts               ← 基础请求层(fetch 封装 + 拦截器)
├── index.ts                 ← 根 Barrel(统一出口 @/api)
└── modules/                 ← 业务模块目录
    └── todo/                ← Todo 模块
        ├── todoApi.ts       ←   接口定义
        ├── todoMapper.ts    ←   数据转换
        └── index.ts         ←   子 Barrel

依赖规则

  • 每个业务模块一个文件夹,内部自行管理 API 与 Mapper
  • 模块内部通过 ../../shared 引用类型,通过 ../../request 引用 httpClient
  • 模块之间禁止互相引用(避免耦合)
  • 所有对外导出统一走根目录 index.ts

3. 第 0 层:共享类型(shared.ts)

文件:shared.ts(file:///d:/代码/uniapp/react-app/src/api/shared.ts)

作用

  • 定义 API 层与业务层的"类型契约"
  • 纯类型文件,无任何实现逻辑
  • 作为架构的"安全枢纽",彻底消除循环依赖

核心类型

ts 复制代码
// 后端 DTO:与后端接口字段严格对齐
export interface TodoDto {
  id: number
  text: string
  done: boolean
  createdAt: string
  updatedAt?: string
}

// 前端 Domain:与业务层 Todo 结构兼容
export interface TodoLike {
  id: number
  text: string
  done: boolean
  createdAt: number
}

// 通用响应结构
export interface ApiResponse<T = unknown> {
  code: number
  message: string
  data: T
}

设计要点

  • 后端 DTO vs 前端 Domain 分离 :后端用 snake_case、字符串日期;前端用 camelCase、时间戳
  • 共享类型必须纯净shared.ts 只能 import type 其它类型文件,不能 import 业务组件
  • 业务层复用src/components/todo/types.ts 中的 Todo 可直接 extends TodoLike

扩展示例:加分页类型

ts 复制代码
// shared.ts
export interface PageParams {
  page: number
  pageSize: number
}

export interface PageResult<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}

4. 第 5 层:环境配置

文件:env.ts(file:///d:/代码/uniapp/react-app/src/api/env.ts)

作用

  • 多环境自动切换 baseURL
  • 集中管理所有配置项
  • 通过 .env.* 文件区分环境

核心代码

ts 复制代码
import type { Env, AppConfig } from './shared'

const getEnv = (): Env => {
  const mode = import.meta.env.MODE as Env
  return mode || 'development'
}

export const config: AppConfig = {
  env: getEnv(),
  apiBaseUrl:
    import.meta.env.VITE_API_BASE_URL ||
    (getEnv() === 'production'
      ? 'https://api.example.com'
      : getEnv() === 'test'
        ? 'https://test-api.example.com'
        : 'https://dev-api.example.com'),
  timeout: Number(import.meta.env.VITE_API_TIMEOUT) || 10000,
  tokenKey: 'app_token',
}

.env 文件示例

bash 复制代码
# .env.development
VITE_API_BASE_URL=https://dev-api.example.com
VITE_API_TIMEOUT=10000

# .env.test
VITE_API_BASE_URL=https://test-api.example.com
VITE_API_TIMEOUT=15000

# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_API_TIMEOUT=10000

环境变量在代码中的使用

ts 复制代码
// 任何文件都可以这样获取
const mode = import.meta.env.MODE          // 'development' | 'test' | 'production'
const baseUrl = import.meta.env.VITE_API_BASE_URL
const appVersion = import.meta.env.VITE_APP_VERSION

Vite 的环境变量注入规则

前缀 可见性 用途
VITE_ 客户端代码可见 前端配置
无前缀 仅服务端可见 私密密钥(不要放前端)

5. 第 1 层:基础请求层(含拦截器)

文件:request.ts(file:///d:/代码/uniapp/react-app/src/api/request.ts)

5.1 拦截器机制设计

复制代码
请求拦截器                     响应拦截器
┌──────────────┐             ┌──────────────┐
│ 1. Token 注入 │             │ 1. 错误码检查 │
│ 2. 请求头处理 │──→ fetch ──→ │ 2. 数据解包   │
│ 3. 日志打印   │             │ 3. Token 刷新 │
│ 4. 取消请求   │             │ 4. 日志打印   │
└──────────────┘             └──────────────┘

5.2 当前实现详解

ts 复制代码
import { config } from './env'
import type { ApiResponse } from './shared'

export class ApiError extends Error {
  code: number
  status?: number
  constructor(code: number, message: string, status?: number) {
    super(message)
    this.name = 'ApiError'
    this.code = code
    this.status = status
  }
}

// 请求拦截器:自动加 token
const getAuthHeaders = () => {
  const token = localStorage.getItem(config.tokenKey)
  return token ? { Authorization: `Bearer ${token}` } : {}
}

// 核心请求函数
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const { method = 'GET', params, body, headers: customHeaders, signal } = options

  // ① 请求拦截:拼接 URL + query
  const url = new URL(path, config.apiBaseUrl)
  if (params) {
    Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, String(v)))
  }

  // ② 请求拦截:构建 headers
  const init: RequestInit = {
    method,
    headers: { 'Content-Type': 'application/json', ...getAuthHeaders(), ...customHeaders },
    signal,
    credentials: 'include',
  }
  if (body !== undefined) init.body = JSON.stringify(body)

  // ③ 超时控制
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), config.timeout)
  const finalSignal = signal
    ? mergeSignal(signal, controller.signal)
    : controller.signal

  try {
    // ④ 发送请求
    const response = await fetch(url.toString(), { ...init, signal: finalSignal })

    // ⑤ 响应拦截:HTTP 状态码检查
    if (!response.ok) throw new ApiError(response.status, `HTTP ${response.status}`, response.status)

    // ⑥ 响应拦截:业务状态码检查
    const json = (await response.json()) as ApiResponse<T>
    if (json.code !== 0) {
      if (json.code === 401) localStorage.removeItem(config.tokenKey)
      throw new ApiError(json.code, json.message, response.status)
    }
    return json.data
  } catch (err) {
    // ⑦ 错误分类
    if (err instanceof ApiError) throw err
    if (err instanceof DOMException && err.name === 'AbortError') {
      throw new ApiError(-1, '请求超时,请稍后重试')
    }
    throw new ApiError(-1, (err as Error).message || '网络异常,请稍后重试')
  } finally {
    clearTimeout(timeoutId)
  }
}

5.3 进阶:自定义拦截器栈

ts 复制代码
// 拦截器接口
interface RequestInterceptor {
  onRequest?(config: RequestConfig): Promise<RequestConfig> | RequestConfig
  onResponse?(response: Response): Promise<any> | any
  onError?(error: Error): Promise<Error> | Error
}

// 拦截器管理器
class InterceptorManager {
  private interceptors: RequestInterceptor[] = []

  use(interceptor: RequestInterceptor) {
    this.interceptors.push(interceptor)
  }

  async runRequest(config: RequestConfig) {
    for (const interceptor of this.interceptors) {
      if (interceptor.onRequest) {
        config = await interceptor.onRequest(config)
      }
    }
    return config
  }

  async runResponse(response: Response) {
    let data: any = response
    for (const interceptor of this.interceptors) {
      if (interceptor.onResponse) {
        data = await interceptor.onResponse(data)
      }
    }
    return data
  }

  async runError(error: Error) {
    for (const interceptor of this.interceptors) {
      if (interceptor.onError) {
        error = await interceptor.onError(error)
      }
    }
    throw error
  }
}

5.4 便捷方法

ts 复制代码
export const httpClient = {
  get: <T>(path, params?) => request<T>(path, { method: 'GET', params }),
  post: <T>(path, body?) => request<T>(path, { method: 'POST', body }),
  put: <T>(path, body?) => request<T>(path, { method: 'PUT', body }),
  delete: <T>(path) => request<T>(path, { method: 'DELETE' }),
  patch: <T>(path, body?) => request<T>(path, { method: 'PATCH', body }),
}

6. 第 2 层:接口定义层

文件:todoApi.ts(file:///d:/代码/uniapp/react-app/src/api/modules/todo/todoApi.ts)

核心代码

ts 复制代码
import { httpClient } from '../../request'
import type { TodoDto, CreateTodoRequest, UpdateTodoRequest, ListTodoParams } from '../../shared'

const BASE = '/api/todos'

export const todoApi = {
  list: (params?: ListTodoParams) =>
    httpClient.get<TodoDto[]>(
      BASE,
      params as Record<string, string | number | boolean> | undefined
    ),
  getById: (id: number) => httpClient.get<TodoDto>(`${BASE}/${id}`),
  create: (data: CreateTodoRequest) => httpClient.post<TodoDto>(BASE, data),
  update: (id: number, data: UpdateTodoRequest) =>
    httpClient.put<TodoDto>(`${BASE}/${id}`, data),
  remove: (id: number) => httpClient.delete<void>(`${BASE}/${id}`),
  batchRemove: (ids: number[]) =>
    httpClient.patch<void>(`${BASE}/batch`, { ids }),
  toggle: (id: number) => httpClient.patch<TodoDto>(`${BASE}/${id}/toggle`),
  clearCompleted: () => httpClient.delete<void>(`${BASE}/completed`),
}

分页接口示例

ts 复制代码
export const todoApi = {
  // 分页列表
  listPage: (params: ListTodoParams & PageParams) =>
    httpClient.get<PageResult<TodoDto>>(BASE, params),

  // 分页加载(支持滚动加载)
  loadMore: async (
    params: ListTodoParams & PageParams,
    signal?: AbortSignal
  ) => {
    return httpClient.get<PageResult<TodoDto>>(BASE, { ...params, signal })
  },
}

文件上传接口示例

ts 复制代码
export const todoApi = {
  uploadAttachment: (todoId: number, file: File) => {
    const formData = new FormData()
    formData.append('file', file)
    formData.append('todoId', String(todoId))

    return fetch(`${config.apiBaseUrl}${BASE}/${todoId}/attachment`, {
      method: 'POST',
      headers: getAuthHeaders(),
      body: formData,  // 注意:FormData 不需要 Content-Type
    }).then(async (res) => {
      if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`)
      const json = await res.json() as ApiResponse<AttachmentDto>
      if (json.code !== 0) throw new ApiError(json.code, json.message)
      return json.data
    })
  },
}

7. 第 3 层:数据转换层

文件:todoMapper.ts(file:///d:/代码/uniapp/react-app/src/api/modules/todo/todoMapper.ts)

核心代码

ts 复制代码
import type { TodoDto, TodoLike } from '../../shared'

export function dtoToTodo(dto: TodoDto): TodoLike {
  return {
    id: dto.id,
    text: dto.text,
    done: dto.done,
    createdAt: new Date(dto.createdAt).getTime(),
  }
}

export function dtoListToTodos(dtos: TodoDto[]): TodoLike[] {
  return dtos.map(dtoToTodo)
}

export function buildCreatePayload(text: string) {
  return { text: text.trim(), done: false }
}

export function buildUpdatePayload(
  patch: Partial<Pick<TodoLike, 'text' | 'done'>>
) {
  return Object.fromEntries(
    Object.entries(patch).filter(([, v]) => v !== undefined)
  )
}

复杂映射示例:字段名不一致

ts 复制代码
// 后端字段:snake_case
// 前端字段:camelCase
interface UserDto {
  user_id: number
  first_name: string
  last_name: string
  is_active: boolean
  created_at: string
}

interface UserLike {
  userId: number
  firstName: string
  lastName: string
  isActive: boolean
  createdAt: number
  displayName: string  // 组合字段
}

export function dtoToUser(dto: UserDto): UserLike {
  return {
    userId: dto.user_id,
    firstName: dto.first_name,
    lastName: dto.last_name,
    isActive: dto.is_active,
    createdAt: new Date(dto.created_at).getTime(),
    displayName: `${dto.first_name} ${dto.last_name}`.trim(),
  }
}

反向映射(前端 → 后端)

ts 复制代码
export function userToDto(user: UserLike): UserDto {
  return {
    user_id: user.userId,
    first_name: user.firstName,
    last_name: user.lastName,
    is_active: user.isActive,
    created_at: new Date(user.createdAt).toISOString(),
  }
}

分页数据映射

ts 复制代码
export function pageDtoToResult<T, U>(
  pageDto: PageResult<T>,
  mapper: (item: T) => U
): PageResult<U> {
  return {
    list: pageDto.list.map(mapper),
    total: pageDto.total,
    page: pageDto.page,
    pageSize: pageDto.pageSize,
  }
}

// 使用
const result = pageDtoToResult(
  await todoApi.listPage(params),
  dtoToTodo
)

8. 实战:新增业务模块

以新增"用户管理"模块为例:

Step 1:在 shared.ts 添加新类型

ts 复制代码
export interface UserDto {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
  createdAt: string
}

export interface UserLike {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
  createdAt: number
}

export interface CreateUserRequest {
  name: string
  email: string
  role?: 'admin' | 'user'
}

Step 2:创建 modules/user/ 目录

复制代码
src/api/modules/user/
├── userApi.ts       ← 接口定义
├── userMapper.ts    ← 数据转换
└── index.ts         ← 子 Barrel

Step 3:写 userApi.ts

ts 复制代码
import { httpClient } from '../../request'
import type { UserDto, CreateUserRequest } from '../../shared'

const BASE = '/api/users'

export const userApi = {
  list: () => httpClient.get<UserDto[]>(BASE),
  getById: (id: number) => httpClient.get<UserDto>(`${BASE}/${id}`),
  create: (data: CreateUserRequest) => httpClient.post<UserDto>(BASE, data),
  update: (id: number, data: Partial<CreateUserRequest>) =>
    httpClient.put<UserDto>(`${BASE}/${id}`, data),
  remove: (id: number) => httpClient.delete<void>(`${BASE}/${id}`),
}

Step 4:写 userMapper.ts

ts 复制代码
import type { UserDto, UserLike } from '../../shared'

export function dtoToUser(dto: UserDto): UserLike {
  return {
    id: dto.id,
    name: dto.name,
    email: dto.email,
    role: dto.role,
    createdAt: new Date(dto.createdAt).getTime(),
  }
}

export function dtoListToUsers(dtos: UserDto[]): UserLike[] {
  return dtos.map(dtoToUser)
}

Step 5:写子 Barrel

ts 复制代码
// src/api/modules/user/index.ts
export { userApi } from './userApi'
export { dtoToUser, dtoListToUsers } from './userMapper'

Step 6:在根 index.ts 注册

ts 复制代码
// src/api/index.ts
export { userApi, dtoToUser, dtoListToUsers } from './modules/user'
export type { UserDto, UserLike, CreateUserRequest } from './shared'

依赖关系图

复制代码
shared.ts (安全枢纽)
    ↑           ↑            ↑
env.ts    request.ts    modules/todo/   modules/user/
                              ↑              ↑
                         todoApi.ts      userApi.ts
                              ↑              ↑
                         todoMapper.ts   userMapper.ts
                              ↑              ↑
                         index.ts (子 Barrel)
                              ↑
                         api/index.ts (根 Barrel)
                              ↑
                          @/api (业务层消费)

9. 进阶:请求拦截器机制

完整拦截器实现

ts 复制代码
type InterceptorFunction<T> = (data: T) => T | Promise<T>

interface Interceptor<T> {
  fulfilled?: InterceptorFunction<T>
  rejected?: (error: Error) => Error | Promise<Error>
}

class InterceptorChain<T> {
  private interceptors: Interceptor<T>[] = []

  use(fulfilled?: InterceptorFunction<T>, rejected?: (error: Error) => Error | Promise<Error>) {
    this.interceptors.push({ fulfilled, rejected })
  }

  async process(data: T): Promise<T> {
    // 正向处理
    for (const interceptor of this.interceptors) {
      if (interceptor.fulfilled) {
        data = await interceptor.fulfilled(data)
      }
    }
    return data
  }

  async processError(error: Error): Promise<Error> {
    // 反向处理(洋葱模型)
    for (let i = this.interceptors.length - 1; i >= 0; i--) {
      const interceptor = this.interceptors[i]
      if (interceptor.rejected) {
        error = await interceptor.rejected(error)
      }
    }
    return error
  }
}

实际应用场景

场景 1:统一 Token 注入
ts 复制代码
requestInterceptors.use((config) => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})
场景 2:401 自动刷新 Token
ts 复制代码
responseInterceptors.use(
  (response) => response,
  async (error) => {
    if (error instanceof ApiError && error.code === 401) {
      const newToken = await refreshToken()
      localStorage.setItem('token', newToken)
      // 重放原请求
      return request(error.config)
    }
    throw error
  }
)
场景 3:请求耗时日志
ts 复制代码
requestInterceptors.use((config) => {
  config.metadata = { startTime: Date.now() }
  return config
})

responseInterceptors.use((response) => {
  const duration = Date.now() - response.config.metadata.startTime
  console.log(`[耗时] ${response.config.url}: ${duration}ms`)
  return response
})

10. 进阶:智能重试策略

什么时候该重试

场景 是否重试 原因
网络超时 ✅ 是 网络波动常见
服务器 502/503/504 ✅ 是 服务端临时故障
服务器 400/401/403 ❌ 否 客户端错误,重试无效
服务器 404 ❌ 否 资源不存在
POST/PUT/DELETE ⚠️ 谨慎 可能重复提交

实现代码

ts 复制代码
interface RetryOptions {
  maxRetries?: number
  retryDelay?: number          // 初始延迟(ms)
  backoffFactor?: number        // 退避因子
  shouldRetry?: (error: Error, attempt: number) => boolean
}

const defaultRetryOptions: Required<RetryOptions> = {
  maxRetries: 3,
  retryDelay: 1000,
  backoffFactor: 2,
  shouldRetry: (error) => {
    if (!(error instanceof ApiError)) return false
    return error.status === 408 || error.status >= 500 || error.code === -1
  },
}

export async function requestWithRetry<T>(
  path: string,
  options: RequestOptions & RetryOptions = {}
): Promise<T> {
  const {
    maxRetries = defaultRetryOptions.maxRetries,
    retryDelay = defaultRetryOptions.retryDelay,
    backoffFactor = defaultRetryOptions.backoffFactor,
    shouldRetry = defaultRetryOptions.shouldRetry,
    ...requestOptions
  } = options

  let lastError: Error | null = null

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await request<T>(path, requestOptions)
    } catch (err) {
      lastError = err as Error

      if (attempt >= maxRetries || !shouldRetry(err as Error, attempt)) {
        break
      }

      // 指数退避
      const delay = retryDelay * Math.pow(backoffFactor, attempt)
      console.warn(`[重试] 第 ${attempt + 1} 次,${delay}ms 后重试...`)
      await new Promise((resolve) => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

使用示例

ts 复制代码
// 对特定接口启用重试
export const todoApi = {
  list: (params?) =>
    requestWithRetry<TodoDto[]>(`${BASE}`, {
      method: 'GET',
      params,
      maxRetries: 2,
      retryDelay: 500,
    }),
}

11. 进阶:内存缓存层

设计思路

对不常变化的数据(如配置、字典)做内存缓存,减少重复请求。

实现代码

ts 复制代码
interface CacheEntry<T> {
  data: T
  expireAt: number
}

class MemoryCache {
  private cache = new Map<string, CacheEntry<any>>()
  private pendingRequests = new Map<string, Promise<any>>()

  async getOrFetch<T>(
    key: string,
    fetcher: () => Promise<T>,
    ttl: number = 60_000  // 默认 60 秒
  ): Promise<T> {
    const entry = this.cache.get(key)
    if (entry && entry.expireAt > Date.now()) {
      return entry.data as T
    }

    // 如果有正在进行的请求,直接复用
    const pending = this.pendingRequests.get(key)
    if (pending) return pending as Promise<T>

    // 发起新请求
    const request = fetcher()
      .then((data) => {
        this.cache.set(key, { data, expireAt: Date.now() + ttl })
        this.pendingRequests.delete(key)
        return data
      })
      .catch((error) => {
        this.pendingRequests.delete(key)
        throw error
      })

    this.pendingRequests.set(key, request)
    return request
  }

  clear(key?: string) {
    if (key) this.cache.delete(key)
    else this.cache.clear()
  }

  has(key: string): boolean {
    const entry = this.cache.get(key)
    return !!entry && entry.expireAt > Date.now()
  }
}

export const apiCache = new MemoryCache()

使用示例

ts 复制代码
// 缓存字典数据(更新不频繁)
export const dictApi = {
  getTodoStatuses: () =>
    apiCache.getOrFetch(
      'dict:todo-statuses',
      () => httpClient.get<DictItem[]>('/api/dict/todo-statuses'),
      5 * 60_000  // 5 分钟缓存
    ),
}

// 缓存当前用户信息(登录后基本不变)
export const userApi = {
  getCurrentUser: () =>
    apiCache.getOrFetch(
      'user:current',
      () => httpClient.get<UserDto>('/api/users/me'),
      30 * 60_000  // 30 分钟缓存
    ),
}

// 退出登录时清空缓存
export function logout() {
  apiCache.clear()
  // ... 其它清理
}

12. 进阶:Mock 数据接入

为什么需要 Mock

  • 后端接口还没开发好,前端需要并行开发
  • 需要稳定的测试数据
  • 离线开发、演示 Demo

方案 A:开发环境 Mock

ts 复制代码
// src/api/mock/index.ts
import type { TodoDto } from '../shared'

const todos: TodoDto[] = [
  { id: 1, text: '学习 React', done: true, createdAt: '2024-01-01T10:00:00Z' },
  { id: 2, text: '学习 TypeScript', done: false, createdAt: '2024-01-02T10:00:00Z' },
  { id: 3, text: '学习 Vite', done: false, createdAt: '2024-01-03T10:00:00Z' },
]

export const mockTodoApi = {
  list: async () => simulateDelay([...todos]),
  create: async (data: { text: string }) =>
    simulateDelay({
      id: Date.now(),
      text: data.text,
      done: false,
      createdAt: new Date().toISOString(),
    }),
  update: async (id: number, data: any) =>
    simulateDelay({
      ...todos.find((t) => t.id === id)!,
      ...data,
    }),
  remove: async (id: number) => {
    const idx = todos.findIndex((t) => t.id === id)
    if (idx >= 0) todos.splice(idx, 1)
    return simulateDelay<void>(undefined as any)
  },
}

function simulateDelay<T>(data: T, delay = 300): Promise<T> {
  return new Promise((resolve) => setTimeout(() => resolve(data), delay))
}

方案 B:Vite 插件 Mock(推荐)

ts 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    react(),
    {
      name: 'mock-api',
      configureServer(server) {
        server.middlewares.use((req, res, next) => {
          if (!req.url?.startsWith('/api/')) return next()

          // Mock todo 接口
          if (req.url === '/api/todos' && req.method === 'GET') {
            res.setHeader('Content-Type', 'application/json')
            res.end(JSON.stringify({
              code: 0,
              message: 'ok',
              data: mockTodos,
            }))
            return
          }
          next()
        })
      },
    },
  ],
})

方案 C:MSW(生产级 Mock)

ts 复制代码
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/todos', () => {
    return HttpResponse.json({
      code: 0,
      data: [
        { id: 1, text: 'Mock Todo', done: false, createdAt: '2024-01-01' },
      ],
    })
  }),

  http.post('/api/todos', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({
      code: 0,
      data: { id: Date.now(), ...body, createdAt: new Date().toISOString() },
    })
  }),
]
ts 复制代码
// src/mocks/browser.ts
import { setupWorker } from 'msw'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
ts 复制代码
// src/main.tsx
async function enableMocking() {
  if (import.meta.env.DEV) {
    const { worker } = await import('./mocks/browser')
    await worker.start()
  }
}

enableMocking().then(() => {
  ReactDOM.createRoot(root).render(<App />)
})

三种方案对比

方案 优点 缺点 适用场景
A. 开发环境 Mock 简单、零依赖 需要手动切换 快速 Demo
B. Vite 插件 Mock 自动切换、生产零影响 只在 dev 生效 日常开发
C. MSW 生产级、支持测试 配置复杂 团队协作、测试

13. 进阶:错误码规范与统一处理

错误码分类

复制代码
┌─────────────────────────────────────────────┐
│  1xx: 信息响应                               │
│  2xx: 成功响应(code = 0 表示业务成功)       │
│  3xx: 重定向                                 │
│  4xx: 客户端错误                             │
│    400 参数错误                              │
│    401 未登录 / Token 过期                   │
│    403 无权限                                │
│    404 资源不存在                            │
│    422 业务校验失败                          │
│  5xx: 服务端错误                             │
│    500 服务器内部错误                         │
│    502/503/504 网关错误                      │
│  -1: 网络错误 / 超时(自定义)               │
└─────────────────────────────────────────────┘

统一错误处理

ts 复制代码
// src/api/errorHandler.ts
interface ErrorHandlerConfig {
  onUnauthorized: () => void
  onForbidden: () => void
  onServerError: (error: ApiError) => void
  onNetworkError: (error: ApiError) => void
}

class ErrorHandler {
  private config: ErrorHandlerConfig

  constructor(config: ErrorHandlerConfig) {
    this.config = config
  }

  handle(error: unknown): string {
    if (!(error instanceof ApiError)) {
      return '未知错误'
    }

    switch (error.code) {
      case 401:
        this.config.onUnauthorized()
        return '登录已过期,请重新登录'
      case 403:
        this.config.onForbidden()
        return '您没有权限执行此操作'
      case 404:
        return '请求的资源不存在'
      case 422:
        return error.message || '数据校验失败'
      case 500:
      case 502:
      case 503:
      case 504:
        this.config.onServerError(error)
        return '服务器开小差了,请稍后重试'
      case -1:
        this.config.onNetworkError(error)
        return '网络连接异常,请检查网络'
      default:
        return error.message || '操作失败'
    }
  }
}

export const errorHandler = new ErrorHandler({
  onUnauthorized: () => {
    localStorage.removeItem('token')
    window.location.href = '/login'
  },
  onForbidden: () => {
    // 弹出无权限提示
  },
  onServerError: (error) => {
    console.error('[服务端错误]', error)
    // 上报 Sentry
  },
  onNetworkError: (error) => {
    console.warn('[网络错误]', error)
  },
})

在 Context 中使用

tsx 复制代码
const handleError = useCallback((err: unknown) => {
  const message = errorHandler.handle(err)
  setError(message)
  // 自动清除错误
  setTimeout(() => setError(null), 3000)
}, [])

14. 进阶:请求取消与竞态条件

问题场景

当用户快速切换筛选条件时,会发出多个请求。如果旧请求比新请求晚返回,就会出现竞态条件(Stale Response)。

复制代码
时间线:
  t0: 用户选择"全部" → 请求 A
  t1: 用户选择"进行中" → 请求 B
  t2: 请求 B 返回 → 显示"进行中" ✅
  t3: 请求 A 返回 → 覆盖为"全部" ❌ (旧数据覆盖新数据)

解法 1:AbortController

tsx 复制代码
const [filter, setFilter] = useState<Filter>('all')
const abortRef = useRef<AbortController | null>(null)

useEffect(() => {
  // 取消上一个请求
  if (abortRef.current) abortRef.current.abort()

  const controller = new AbortController()
  abortRef.current = controller

  todoApi.list({ filter }, controller.signal)
    .then((data) => {
      // 检查是否已被取消
      if (!controller.signal.aborted) {
        setTodos(data)
      }
    })
    .catch((err) => {
      if (err.name !== 'AbortError') {
        handleError(err)
      }
    })

  return () => controller.abort()
}, [filter])

解法 2:请求序号(版本号)

tsx 复制代码
const requestIdRef = useRef(0)

useEffect(() => {
  const currentId = ++requestIdRef.current

  todoApi.list({ filter }).then((data) => {
    // 只有最新请求才能更新状态
    if (currentId === requestIdRef.current) {
      setTodos(data)
    }
  })
}, [filter])

解法 3:React Query(推荐)

tsx 复制代码
const { data } = useQuery({
  queryKey: ['todos', filter],  // 不同 filter 自动取消旧请求
  queryFn: () => todoApi.list({ filter }),
})

React Query 内部自动处理了竞态条件,无需手动取消。


15. 生产级完整架构

复制代码
┌─────────────────────────────────────────────────────────────────┐
│  业务层                                                           │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │  Context / Hook(useTodos, useUser)                        │ │
│  │  - 乐观更新                                                  │ │
│  │  - 错误处理                                                  │ │
│  │  - 加载状态                                                  │ │
│  └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  API 模块层                                                       │
│  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐      │
│  │  todo/        │  │  user/        │  │  order/       │      │
│  │  - todoApi    │  │  - userApi    │  │  - orderApi   │      │
│  │  - todoMapper │  │  - userMapper │  │  - orderMapper│      │
│  └───────────────┘  └───────────────┘  └───────────────┘      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  基础层                                                           │
│  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐      │
│  │  request.ts   │  │  cache.ts     │  │  retry.ts     │      │
│  │  - fetch 封装 │  │  - 内存缓存   │  │  - 智能重试   │      │
│  │  - 拦截器     │  │  - TTL 管理   │  │  - 指数退避   │      │
│  └───────────────┘  └───────────────┘  └───────────────┘      │
│  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐      │
│  │  env.ts       │  │  mock.ts      │  │  errorHandler │      │
│  │  - 环境配置   │  │  - Mock 数据  │  │  - 错误码处理 │      │
│  └───────────────┘  └───────────────┘  └───────────────┘      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  类型层                                                           │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │  shared.ts                                                   │ │
│  │  - DTO 类型(后端结构)                                      │ │
│  │  - Domain 类型(前端结构)                                   │ │
│  │  - 通用接口(ApiResponse, PageResult)                       │ │
│  └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

建议的生产级文件结构

复制代码
src/api/
├── shared.ts          ← 共享类型
├── env.ts             ← 环境配置
├── request.ts         ← fetch + 拦截器
├── cache.ts           ← 内存缓存
├── retry.ts           ← 重试策略
├── errorHandler.ts    ← 错误处理
├── mock.ts            ← Mock 数据(可选)
├── index.ts           ← 根 Barrel
└── modules/
    ├── todo/
    │   ├── todoApi.ts
    │   ├── todoMapper.ts
    │   └── index.ts
    ├── user/
    │   ├── userApi.ts
    │   ├── userMapper.ts
    │   └── index.ts
    └── order/
        ├── orderApi.ts
        ├── orderMapper.ts
        └── index.ts

16. 常见问题排查清单

Q1: 报 "Failed to resolve import"

排查步骤

  1. 检查 vite.config.tsalias 配置
  2. 检查 tsconfig.jsonpaths 配置
  3. 确认文件存在、文件名大小写正确
  4. 确认没有同名文件和目录(如同时存在 api.tsapi/

Q2: 报 "Circular dependency"

排查步骤

  1. 检查模块是否反向 import 了业务组件
  2. 把共享类型提到 shared.ts
  3. import type 替代 import(避免运行时循环)

Q3: 请求超时但网络正常

排查步骤

  1. 检查 config.timeout 设置
  2. 检查 AbortController 是否正确清理
  3. 检查是否有请求未取消导致堆积

Q4: 状态不更新(Stale State)

排查步骤

  1. 是否使用了函数式更新 setState(prev => ...)
  2. 是否有竞态条件(旧请求覆盖新请求)
  3. 是否正确使用 useCallback / useMemo

Q5: 重复请求

排查步骤

  1. 是否缺少依赖项(useEffect 依赖数组)
  2. 是否 StrictMode 下开发(会导致 effects 执行两次)
  3. 是否需要使用 apiCache 做请求去重

Q6: Token 过期未刷新

排查步骤

  1. 检查响应拦截器是否处理了 401
  2. 检查 Token 刷新接口是否正确
  3. 检查刷新后是否重放了原请求

17. 升级路线

阶段 1:当前(fetch + Context)

  • ✅ fetch 封装、错误处理、乐观更新
  • ✅ Context + Hook 业务封装
  • ✅ 模块化目录结构 + 共享类型

阶段 2:增加拦截器、缓存、重试

  • ⬜ 请求拦截器(Token 注入、日志)
  • ⬜ 响应拦截器(错误处理、Token 刷新)
  • ⬜ 内存缓存层(减少重复请求)
  • ⬜ 智能重试(指数退避)

阶段 3:引入 axios(可选)

只需重写 request.ts 中的 request 函数,其他层不动。

ts 复制代码
import axios, { AxiosInstance } from 'axios'

const instance: AxiosInstance = axios.create({
  baseURL: config.apiBaseUrl,
  timeout: config.timeout,
})

// 请求拦截器
instance.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// 响应拦截器
instance.interceptors.response.use(
  (response) => {
    const json = response.data as ApiResponse<any>
    if (json.code !== 0) throw new ApiError(json.code, json.message)
    return json.data
  },
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token')
    }
    throw new ApiError(
      error.response?.status || -1,
      error.message || '网络异常'
    )
  }
)

阶段 4:接入 React Query / SWR

tsx 复制代码
// 服务端状态自动管理
const { data, isLoading } = useQuery({
  queryKey: ['todos', filter],
  queryFn: () => todoApi.list({ filter }),
  staleTime: 60_000,  // 1 分钟内不重复请求
  retry: 2,            // 失败重试 2 次
})

// 乐观更新
const mutation = useMutation({
  mutationFn: todoApi.create,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previous = queryClient.getQueryData(['todos'])
    queryClient.setQueryData(['todos'], (old) => [...old!, newTodo])
    return { previous }
  },
  onError: (err, _, context) => {
    queryClient.setQueryData(['todos'], context!.previous)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

阶段 5:完整技术栈

  • 请求层:axios / fetch + 拦截器
  • 服务端状态:React Query / SWR
  • 客户端状态:Zustand / Redux Toolkit
  • 类型校验:Zod / Valibot
  • Mock:MSW
  • 监控:Sentry / 自研埋点
  • 测试:Vitest / Playwright

附录:文件清单

文件 职责 代码行数
shared.ts(file:///d:/代码/uniapp/react-app/src/api/shared.ts) 共享类型契约 ~50
env.ts(file:///d:/代码/uniapp/react-app/src/api/env.ts) 环境配置 ~30
request.ts(file:///d:/代码/uniapp/react-app/src/api/request.ts) 基础请求层 ~130
modules/todo/todoApi.ts(file:///d:/代码/uniapp/react-app/src/api/modules/todo/todoApi.ts) Todo 接口定义 ~45
modules/todo/todoMapper.ts(file:///d:/代码/uniapp/react-app/src/api/modules/todo/todoMapper.ts) Todo 数据转换 ~35
modules/todo/index.ts(file:///d:/代码/uniapp/react-app/src/api/modules/todo/index.ts) Todo 子 Barrel ~15
index.ts(file:///d:/代码/uniapp/react-app/src/api/index.ts) 根 Barrel 导出 ~40

总计:7 个核心文件,约 345 行代码。


18. 进阶:请求取消与竞态条件深度剖析

竞态条件的 4 种场景

场景 描述 危害
快速切换筛选 用户连点多个 tab 旧请求覆盖新数据
路由快速切换 进入页面立即返回 组件卸载后 setState
并发提交 连点两次"提交" 重复创建数据
轮询与手动刷新冲突 定时请求 + 用户刷新 数据闪烁

AbortController 完整实现

ts 复制代码
// request.ts
interface RequestOptions {
  signal?: AbortSignal
  params?: Record<string, string | number | boolean>
  body?: unknown
  method?: string
}

async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), config.timeout)

  const finalSignal = options.signal
    ? mergeAbortSignals(options.signal, controller.signal)
    : controller.signal

  try {
    const response = await fetch(buildUrl(path, options.params), {
      method: options.method ?? 'GET',
      headers: buildHeaders(),
      body: options.body ? JSON.stringify(options.body) : undefined,
      signal: finalSignal,
    })
    // ...
  } catch (err) {
    if ((err as DOMException).name === 'AbortError') {
      throw new ApiError(-1, '请求已取消')
    }
    throw err
  } finally {
    clearTimeout(timeoutId)
  }
}

function mergeAbortSignals(...signals: AbortSignal[]): AbortSignal {
  const controller = new AbortController()
  const onAbort = () => controller.abort()
  signals.forEach((s) => s.aborted ? controller.abort() : s.addEventListener('abort', onAbort))
  return controller.signal
}

封装一个 useRequest Hook

ts 复制代码
// hooks/useRequest.ts
interface UseRequestOptions<T> {
  fetcher: (signal: AbortSignal) => Promise<T>
  immediate?: boolean
  onSuccess?: (data: T) => void
  onError?: (err: Error) => void
}

export function useRequest<T>({
  fetcher,
  immediate = true,
  onSuccess,
  onError,
}: UseRequestOptions<T>) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  const controllerRef = useRef<AbortController | null>(null)

  const run = useCallback(async () => {
    if (controllerRef.current) controllerRef.current.abort()
    const controller = new AbortController()
    controllerRef.current = controller

    setLoading(true)
    setError(null)
    try {
      const result = await fetcher(controller.signal)
      if (!controller.signal.aborted) {
        setData(result)
        onSuccess?.(result)
      }
    } catch (err) {
      if (!controller.signal.aborted) {
        setError(err as Error)
        onError?.(err as Error)
      }
    } finally {
      if (!controller.signal.aborted) setLoading(false)
    }
  }, [fetcher, onSuccess, onError])

  useEffect(() => {
    if (immediate) run()
    return () => controllerRef.current?.abort()
  }, [run, immediate])

  return { data, loading, error, run, cancel: () => controllerRef.current?.abort() }
}

请求序号方案(无需取消的简单场景)

ts 复制代码
function useLatestRequest<T>(fetcher: () => Promise<T>) {
  const [data, setData] = useState<T | null>(null)
  const requestIdRef = useRef(0)

  const run = useCallback(async () => {
    const currentId = ++requestIdRef.current
    const result = await fetcher()
    if (currentId === requestIdRef.current) {
      setData(result)
    }
  }, [fetcher])

  return { data, run }
}

三种方案对比

方案 优点 缺点
AbortController 真正取消请求,释放网络资源 需要传递 signal
请求序号 实现简单,无需取消 旧请求仍在占用带宽
React Query 全自动处理 需引入新依赖

19. 进阶:缓存策略深入

三层缓存架构

复制代码
L1: 内存缓存(MemoryCache)
  - 进程级,刷新页面丢失
  - 存储访问最频繁、更新最少的数据

L2: 持久化缓存(localStorage / IndexedDB)
  - 跨会话保留
  - 存储用户配置、字典数据

L3: SWR / React Query 缓存
  - 自动后台同步
  - 带 staleTime、自动失效

L2 持久化缓存实现

ts 复制代码
// cache/persistentCache.ts
interface CacheEntry<T> {
  data: T
  expireAt: number
  version: number
}

export class PersistentCache {
  private prefix: string
  private version: number

  constructor(prefix = 'cache:', version = 1) {
    this.prefix = prefix
    this.version = version
  }

  async get<T>(key: string): Promise<T | null> {
    const raw = localStorage.getItem(this.prefix + key)
    if (!raw) return null
    try {
      const entry = JSON.parse(raw) as CacheEntry<T>
      if (entry.expireAt < Date.now()) return null
      if (entry.version !== this.version) return null
      return entry.data
    } catch {
      return null
    }
  }

  set<T>(key: string, data: T, ttlMs: number) {
    const entry: CacheEntry<T> = {
      data,
      expireAt: Date.now() + ttlMs,
      version: this.version,
    }
    localStorage.setItem(this.prefix + key, JSON.stringify(entry))
  }

  clear(key?: string) {
    if (key) localStorage.removeItem(this.prefix + key)
    else Object.keys(localStorage)
      .filter((k) => k.startsWith(this.prefix))
      .forEach((k) => localStorage.removeItem(k))
  }
}

export const persistentCache = new PersistentCache('app:v1:')

SWR 策略(Stale-While-Revalidate)

复制代码
1. 立即返回缓存(即使已过期)→ 用户感觉快
2. 后台发起新请求
3. 请求成功后更新缓存
4. 下次请求使用最新数据
ts 复制代码
// cache/swrCache.ts
export class SWRCache {
  private cache = new Map<string, { data: any; expireAt: number }>()
  private inflight = new Map<string, Promise<any>>()

  async get<T>(key: string): Promise<T | null> {
    const entry = this.cache.get(key)
    if (entry) return entry.data as T
    if (this.inflight.has(key)) return this.inflight.get(key) as Promise<T>
    return null
  }

  async fetch<T>(key: string, fetcher: () => Promise<T>, ttlMs = 60_000): Promise<T> {
    const cached = await this.get<T>(key)
    if (cached) {
      this.revalidate(key, fetcher, ttlMs)
      return cached
    }
    return this.fetchAndCache(key, fetcher, ttlMs)
  }

  private async revalidate<T>(key: string, fetcher: () => Promise<T>, ttlMs: number) {
    try {
      await this.fetchAndCache(key, fetcher, ttlMs)
    } catch {}
  }

  private async fetchAndCache<T>(key: string, fetcher: () => Promise<T>, ttlMs: number): Promise<T> {
    if (this.inflight.has(key)) {
      return this.inflight.get(key) as Promise<T>
    }
    const promise = fetcher()
      .then((data) => {
        this.cache.set(key, { data, expireAt: Date.now() + ttlMs })
        return data
      })
      .finally(() => this.inflight.delete(key))
    this.inflight.set(key, promise)
    return promise
  }
}

缓存失效策略

触发时机 策略
主动更新数据 cache.invalidate(key)
定时过期 TTL 到期自动清除
标签失效 cache.invalidateTag('todo') 清除所有 todo 缓存
版本升级 改版本号,旧缓存全部失效
退出登录 cache.clearAll()

最佳实践

  • 不要缓存:实时性要求高的数据(股票价格、聊天消息)
  • 短期缓存:列表页数据(1-5 分钟)
  • 长期缓存:字典、配置、当前用户信息(30 分钟以上)
  • 永不缓存:支付、订单状态等敏感操作

20. 进阶:离线优先与网络状态感知

网络状态检测

ts 复制代码
// hooks/useNetworkStatus.ts
export function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine)

  useEffect(() => {
    const on = () => setIsOnline(true)
    const off = () => setIsOnline(false)
    window.addEventListener('online', on)
    window.addEventListener('offline', off)
    return () => {
      window.removeEventListener('online', on)
      window.removeEventListener('offline', off)
    }
  }, [])

  return {
    isOnline,
    effectiveType: (navigator as any).connection?.effectiveType,
    downlink: (navigator as any).connection?.downlink,
  }
}

离线请求队列

ts 复制代码
// api/offlineQueue.ts
interface PendingRequest {
  id: string
  path: string
  method: string
  body?: unknown
  retries: number
  maxRetries: number
  nextRetryAt: number
}

export class OfflineQueue {
  private queue: PendingRequest[] = []

  constructor() {
    this.restore()
    window.addEventListener('online', () => this.flush())
  }

  enqueue(req: Omit<PendingRequest, 'id' | 'retries' | 'maxRetries' | 'nextRetryAt'>) {
    const pending: PendingRequest = {
      ...req,
      id: crypto.randomUUID(),
      retries: 0,
      maxRetries: 3,
      nextRetryAt: Date.now(),
    }
    this.queue.push(pending)
    this.persist()
  }

  async flush() {
    if (!navigator.onLine) return
    const now = Date.now()
    const dueRequests = this.queue.filter((r) => r.nextRetryAt <= now)

    for (const req of dueRequests) {
      try {
        await fetch(req.path, {
          method: req.method,
          body: req.body ? JSON.stringify(req.body) : undefined,
        })
        this.queue = this.queue.filter((r) => r.id !== req.id)
      } catch {
        req.retries++
        req.nextRetryAt = Date.now() + Math.pow(2, req.retries) * 1000
        if (req.retries >= req.maxRetries) {
          this.queue = this.queue.filter((r) => r.id !== req.id)
        }
      }
    }
    this.persist()
  }

  private persist() {
    localStorage.setItem('offline_queue', JSON.stringify(this.queue))
  }

  private restore() {
    const raw = localStorage.getItem('offline_queue')
    if (raw) {
      try { this.queue = JSON.parse(raw) } catch {}
    }
  }
}

可安装离线应用(PWA)

Service Worker 实现缓存策略:

js 复制代码
// sw.js
const CACHE = 'app-cache-v1'
const CORE_ASSETS = ['/', '/index.html']

self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(CORE_ASSETS)))
})

self.addEventListener('fetch', (e) => {
  const { request } = e
  if (request.mode === 'navigate') {
    e.respondWith(
      fetch(request).then((res) => {
        caches.open(CACHE).then((c) => c.put(request, res.clone()))
        return res
      }).catch(() => caches.match(request))
    )
  }
})

21. 进阶:WebSocket 与实时通信封装

WebSocket 客户端封装

ts 复制代码
// ws/client.ts
type MessageHandler<T = unknown> = (data: T) => void

interface WSOptions {
  url: string
  protocols?: string[]
  reconnect?: boolean
  maxReconnectDelay?: number
  heartbeatInterval?: number
  heartbeatMessage?: string
}

export class WSClient {
  private ws: WebSocket | null = null
  private url: string
  private reconnect: boolean
  private maxReconnectDelay: number
  private heartbeatInterval: number
  private heartbeatMessage: string
  private reconnectTimer: number | null = null
  private heartbeatTimer: number | null = null
  private reconnectDelay = 1000
  private handlers = new Map<string, Set<MessageHandler>>()
  private pendingQueue: string[] = []

  constructor(options: WSOptions) {
    this.url = options.url
    this.reconnect = options.reconnect ?? true
    this.maxReconnectDelay = options.maxReconnectDelay ?? 30_000
    this.heartbeatInterval = options.heartbeatInterval ?? 30_000
    this.heartbeatMessage = options.heartbeatMessage ?? 'ping'
  }

  connect() {
    this.ws = new WebSocket(this.url)
    this.ws.onopen = () => {
      this.reconnectDelay = 1000
      this.startHeartbeat()
      this.flushPending()
    }
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data)
      this.dispatch(data.type, data.payload)
    }
    this.ws.onclose = () => {
      this.stopHeartbeat()
      if (this.reconnect) this.scheduleReconnect()
    }
    this.ws.onerror = () => this.ws?.close()
  }

  disconnect() {
    this.reconnect = false
    this.ws?.close()
    this.stopHeartbeat()
  }

  send(type: string, payload?: unknown) {
    const message = JSON.stringify({ type, payload })
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(message)
    } else {
      this.pendingQueue.push(message)
    }
  }

  subscribe<T = unknown>(type: string, handler: MessageHandler<T>) {
    if (!this.handlers.has(type)) this.handlers.set(type, new Set())
    this.handlers.get(type)!.add(handler as MessageHandler)
    return () => this.handlers.get(type)?.delete(handler as MessageHandler)
  }

  private dispatch(type: string, payload: unknown) {
    this.handlers.get(type)?.forEach((fn) => fn(payload))
  }

  private scheduleReconnect() {
    this.reconnectTimer = window.setTimeout(() => {
      this.connect()
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
    }, this.reconnectDelay)
  }

  private startHeartbeat() {
    this.heartbeatTimer = window.setInterval(() => {
      this.ws?.send(this.heartbeatMessage)
    }, this.heartbeatInterval)
  }

  private stopHeartbeat() {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
    this.heartbeatTimer = null
  }

  private flushPending() {
    while (this.pendingQueue.length) {
      const msg = this.pendingQueue.shift()!
      this.ws?.send(msg)
    }
  }
}

React Hook 封装

ts 复制代码
// hooks/useWebSocket.ts
export function useWebSocket<T = unknown>(url: string) {
  const clientRef = useRef<WSClient | null>(null)
  const [connected, setConnected] = useState(false)
  const [messages, setMessages] = useState<T[]>([])

  useEffect(() => {
    const client = new WSClient({ url, reconnect: true })
    clientRef.current = client

    const unsubscribe = client.subscribe<T>('*', (data) => {
      setMessages((prev) => [...prev, data])
    })

    client.connect()
    return () => {
      unsubscribe()
      client.disconnect()
    }
  }, [url])

  const send = useCallback((type: string, payload?: unknown) => {
    clientRef.current?.send(type, payload)
  }, [])

  return { connected, messages, send }
}

WebSocket vs SSE vs 轮询

方案 方向 延迟 复杂度
WebSocket 双向 毫秒级
SSE 单向(服务端→客户端) 毫秒级
轮询 双向 秒级

22. 进阶:类型安全升级 Zod 校验

为什么需要运行时校验

TypeScript 类型只在编译时检查,后端返回的数据可能与声明不符:

  • 后端字段拼写错误
  • 字段类型改变(stringnumber
  • 新增字段未同步

Zod 基础

ts 复制代码
import { z } from 'zod'

const TodoDtoSchema = z.object({
  id: z.number().int().positive(),
  text: z.string().min(1).max(200),
  done: z.boolean(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime().optional(),
})

type TodoDto = z.infer<typeof TodoDtoSchema>

与 API 层集成

ts 复制代码
// modules/todo/todoSchema.ts
export const TodoDtoSchema = z.object({
  id: z.number().int().positive(),
  text: z.string().min(1).max(200),
  done: z.boolean(),
  createdAt: z.string().datetime(),
})

export const ListTodoResponseSchema = z.object({
  code: z.literal(0),
  message: z.string(),
  data: z.array(TodoDtoSchema),
})
ts 复制代码
// request.ts 集成
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const response = await fetch(...)
  const json = await response.json()

  if (options.schema) {
    const parsed = options.schema.safeParse(json)
    if (!parsed.success) {
      console.error('[Schema 校验失败]', parsed.error.issues)
      throw new ApiError(-1, '数据格式校验失败')
    }
    return parsed.data as T
  }
  return json.data
}

输入校验(避免脏数据进入系统)

ts 复制代码
export const todoApi = {
  create: async (input: CreateTodoInput) => {
    const parsed = CreateTodoRequestSchema.safeParse(input)
    if (!parsed.success) {
      throw new Error(parsed.error.issues.map((i) => i.message).join('; '))
    }
    return httpClient.post<TodoDto>(BASE, parsed.data)
  },
}

表单校验场景

ts 复制代码
const LoginFormSchema = z.object({
  username: z.string().min(3, '用户名至少 3 位'),
  password: z.string().min(8, '密码至少 8 位')
    .regex(/[A-Z]/, '需包含大写字母')
    .regex(/[0-9]/, '需包含数字'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: '两次密码不一致',
  path: ['confirmPassword'],
})

优势总结

  • 类型推导z.infer 自动生成 TS 类型,避免重复定义
  • 运行时保护:避免非法数据进入业务逻辑
  • 错误信息:Zod 提供结构化错误,便于展示友好提示
  • 可组合 :Schema 可以 extendmergepickomit

23. 进阶:API 层单元测试

测试 request 层

ts 复制代码
// request.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'

beforeEach(() => {
  vi.stubEnv('VITE_API_BASE_URL', 'https://api.test.com')
})

describe('request', () => {
  it('GET 请求正确拼接 URL', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ code: 0, message: 'ok', data: [] }),
    })
    vi.stubGlobal('fetch', fetchMock)

    const { request } = await import('./request')
    await request('/todos', { params: { page: 1 } })

    expect(fetchMock).toHaveBeenCalledWith(
      'https://api.test.com/todos?page=1',
      expect.any(Object)
    )
  })

  it('401 时抛出 ApiError', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      ok: false,
      status: 401,
      json: () => Promise.resolve({ code: 401, message: '未授权', data: null }),
    })
    vi.stubGlobal('fetch', fetchMock)

    const { request, ApiError } = await import('./request')
    await expect(request('/protected')).rejects.toBeInstanceOf(ApiError)
  })
})

测试 mapper 层

ts 复制代码
// todoMapper.test.ts
import { describe, it, expect } from 'vitest'
import { dtoToTodo, dtoListToTodos } from './todoMapper'

describe('todoMapper', () => {
  it('dtoToTodo 正确转换字段', () => {
    const dto = {
      id: 1,
      text: '学习 React',
      done: false,
      createdAt: '2024-01-01T10:00:00Z',
    }
    const result = dtoToTodo(dto)
    expect(result).toEqual({
      id: 1,
      text: '学习 React',
      done: false,
      createdAt: new Date('2024-01-01T10:00:00Z').getTime(),
    })
  })

  it('dtoListToTodos 批量转换', () => {
    const dtos = [
      { id: 1, text: 'A', done: true, createdAt: '2024-01-01T10:00:00Z' },
      { id: 2, text: 'B', done: false, createdAt: '2024-01-02T10:00:00Z' },
    ]
    const result = dtoListToTodos(dtos)
    expect(result).toHaveLength(2)
    expect(result[0].id).toBe(1)
    expect(result[1].id).toBe(2)
  })
})

测试 cache 层

ts 复制代码
// memoryCache.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { MemoryCache } from './memoryCache'

describe('MemoryCache', () => {
  let cache: MemoryCache

  beforeEach(() => {
    cache = new MemoryCache()
  })

  it('getOrFetch 首次调用请求数据', async () => {
    const fetcher = vi.fn().mockResolvedValue({ value: 'hello' })
    const result = await cache.getOrFetch('key1', fetcher, 60_000)
    expect(result).toEqual({ value: 'hello' })
    expect(fetcher).toHaveBeenCalledTimes(1)
  })

  it('getOrFetch 第二次调用走缓存', async () => {
    const fetcher = vi.fn().mockResolvedValue({ value: 'hello' })
    await cache.getOrFetch('key1', fetcher)
    await cache.getOrFetch('key1', fetcher)
    expect(fetcher).toHaveBeenCalledTimes(1)
  })

  it('并发请求合并', async () => {
    let callCount = 0
    const fetcher = async () => {
      callCount++
      return { value: 'hello' }
    }
    const [r1, r2] = await Promise.all([
      cache.getOrFetch('key1', fetcher),
      cache.getOrFetch('key1', fetcher),
    ])
    expect(r1).toEqual(r2)
    expect(callCount).toBe(1)
  })
})

测试金字塔

复制代码
          数量
  单元测试 ████████  最多(mapper、cache、工具函数)
    ↓      █████
  集成测试        API 调用、数据库
    ↓      ██
  E2E 测试          完整用户流程

工具推荐

工具 用途
Vitest 单元测试
React Testing Library 组件测试
MSW API Mock
Playwright E2E 测试
@testing-library/react-hooks Hook 测试

24. 进阶:业务 Hook 层 useXxx

为什么需要业务 Hook

Context 层通常负责"状态+UI",而业务 Hook 专注于"行为+数据流":

  • 将 Context 中的业务方法抽离
  • 组合多个 API 调用形成业务流程
  • 封装 loading/error/refresh 等通用状态

示例:useTodoManager

ts 复制代码
// hooks/useTodoManager.ts
export function useTodoManager() {
  const { todos, loading, error, refetch, addTodo, toggleTodo, deleteTodo } = useTodos()
  const [isSubmitting, setIsSubmitting] = useState(false)

  const toggleAll = useCallback(async () => {
    setIsSubmitting(true)
    try {
      await Promise.all(todos.map((t) => toggleTodo(t.id)))
    } finally {
      setIsSubmitting(false)
    }
  }, [todos, toggleTodo])

  const refreshAndClean = useCallback(async () => {
    await refetch()
    const completedIds = todos.filter((t) => t.done).map((t) => t.id)
    if (completedIds.length > 0) {
      await Promise.all(completedIds.map((id) => deleteTodo(id)))
    }
  }, [refetch, todos, deleteTodo])

  return { todos, loading, error, isSubmitting, addTodo, toggleAll, refreshAndClean, refetch }
}

示例:useOrderFlow(复杂业务流程)

ts 复制代码
// hooks/useOrderFlow.ts
export function useOrderFlow() {
  const [step, setStep] = useState<'idle' | 'creating' | 'paying' | 'shipping' | 'done'>('idle')
  const [orderId, setOrderId] = useState<number | null>(null)

  const createOrder = useCallback(async (items: CartItem[]) => {
    setStep('creating')
    try {
      const { id } = await orderApi.create({ items })
      setOrderId(id)
      setStep('paying')
      return id
    } catch {
      setStep('idle')
      throw new Error('订单创建失败')
    }
  }, [])

  const payOrder = useCallback(async (method: PaymentMethod) => {
    if (!orderId) throw new Error('订单不存在')
    await paymentApi.pay(orderId, method)
    setStep('shipping')
  }, [orderId])

  const shipOrder = useCallback(async (trackingNo: string) => {
    if (!orderId) throw new Error('订单不存在')
    await orderApi.ship(orderId, trackingNo)
    setStep('done')
  }, [orderId])

  return { step, orderId, createOrder, payOrder, shipOrder }
}

业务 Hook 的三大原则

  1. 单一职责:一个 Hook 管一个业务流程
  2. 组合现有能力:不要重复造轮子,复用 Context 和 API 层
  3. 可测试:Hook 本身返回函数,可独立测试

完整的 Hook 分层

复制代码
UI 层组件
  调用 useTodoManager() / useOrderFlow()

业务 Hook 层(useXxx)
  组合业务流程、状态机、异常处理

Context 层
  提供基础状态和方法

API 层
  纯数据读写

25. 附录:常见面试题与答案

Q1:fetch vs axios 怎么选?

A

  • fetch 原生 API、零依赖,适合简单场景
  • axios 拦截器体系成熟、自动 JSON 转换,适合中大型项目
  • 核心差异在"拦截器机制":axios 是类 Express 的洋葱模型
  • 当前项目选择 fetch 自研封装,因为要最小依赖、全链路可控

Q2:乐观更新如何处理网络失败?

A

  1. 先更新本地 state(用户即时反馈)
  2. 发送请求
  3. 成功:用服务器返回数据覆盖
  4. 失败:回滚到旧 state + 展示错误提示 + 可重试

Q3:请求重试如何保证幂等性?

A

  • GET/HEAD/DELETE 可以安全重试
  • POST/PUT 必须先判断是否幂等:后端支持幂等键(Idempotency-Key header)
  • 对业务敏感操作(支付、下单),必须带幂等键

Q4:如何避免重复点击导致多次提交?

A

  1. 按钮禁用:提交中禁用按钮
  2. 状态锁 :用 isSubmitting flag 做互斥
  3. 请求去重:同一请求在 inflight 时直接复用
  4. 防抖/节流:submit 按钮包装 debounce

Q5:后端返回错误码应该怎么组织?

A

  • 遵循 HTTP 语义:2xx 成功、4xx 客户端错误、5xx 服务端错误
  • 业务码独立:code: 0 表示成功,非 0 表示业务错误
  • 错误码分层:系统级(-1 网络/-2 超时)、业务级(1xxx 参数、2xxx 权限)
  • 每个错误码对应唯一含义,便于前端精准处理

Q6:分页接口怎么设计?

A

  • 传统分页:page + pageSize,返回 list + total
  • 游标分页:cursor + limit,适合大数据量/实时更新场景
  • 无限滚动:cursor + hasMore,不需要 total
  • 当前项目建议:普通管理后台用传统分页,Feed 流用游标分页

Q7:如何处理 Token 过期?

A

  1. 请求拦截器自动附加 Token
  2. 响应拦截器检测 401
  3. 自动调用 refreshToken 接口(带并发锁,避免多个请求同时刷新)
  4. 刷新成功:重放原请求
  5. 刷新失败:跳转登录页
ts 复制代码
let refreshPromise: Promise<string> | null = null

async function handle401() {
  if (!refreshPromise) {
    refreshPromise = refreshToken()
      .then((newToken) => {
        localStorage.setItem('token', newToken)
        return newToken
      })
      .finally(() => { refreshPromise = null })
  }
  return refreshPromise
}

Q8:怎么保证 API 层的类型安全?

A:三层类型安全:

  1. 编译时:TypeScript 接口定义
  2. 运行时:Zod / Yup Schema 校验
  3. 自动生成:从后端 Swagger 生成 TS 类型

Q9:如何优雅处理 loading 状态?

A

  • 全局 loading:NProgress(顶部进度条)
  • 局部 loading:组件级骨架屏
  • 按请求粒度:每个 API 独立 loading 状态
  • 竞态保护:新请求自动清除旧 loading
  • 乐观更新:交互场景可跳过 loading,直接反馈

Q10:API 封装的核心心法?

A

  1. 分层:请求层 → API 定义层 → Mapper 层 → 业务 Hook 层
  2. 契约:类型先行,DTO 与 Domain 分离
  3. 防御:运行时 Zod 校验、错误码规范、重试与回滚
  4. 体验:乐观更新、缓存 SWR、离线队列
  5. 演进:fetch 封装 → axios → React Query/SWR → RSC
  6. 测试:测试金字塔,单元 → 集成 → E2E
  7. 实时:WebSocket / SSE 按需选择
  8. 安全:Token 自动刷新、幂等键、请求签名

源码见上一篇文章[React 组件封装方法论 ------ 以 Todo App 为例](https://editor.csdn.net/md/?articleId=162174819)