本文档系统讲解 React 项目中的 API 封装方法论,覆盖从基础 fetch 封装到生产级拦截器、重试、缓存、Mock 的完整体系。
所有代码示例均基于当前项目实现,配合深度原理解析。
目录
- 架构总览
- 目录结构
- [第 0 层:共享类型(shared.ts)](#第 0 层:共享类型(shared.ts))
- [第 5 层:环境配置](#第 5 层:环境配置)
- [第 1 层:基础请求层(含拦截器)](#第 1 层:基础请求层(含拦截器))
- [第 2 层:接口定义层](#第 2 层:接口定义层)
- [第 3 层:数据转换层](#第 3 层:数据转换层)
- 实战:新增业务模块
- 进阶:请求拦截器机制
- 进阶:智能重试策略
- 进阶:内存缓存层
- [进阶:Mock 数据接入](#进阶:Mock 数据接入)
- 进阶:错误码规范与统一处理
- 进阶:请求取消与竞态条件
- 生产级完整架构
- 常见问题排查清单
- 升级路线
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 层 → 业务层"的反向依赖,导致循环依赖和模块解析失败。
解法 :把 TodoLike、TodoDto 等"类型契约"独立到 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可直接 extendsTodoLike
扩展示例:加分页类型
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"
排查步骤:
- 检查
vite.config.ts的alias配置 - 检查
tsconfig.json的paths配置 - 确认文件存在、文件名大小写正确
- 确认没有同名文件和目录(如同时存在
api.ts和api/)
Q2: 报 "Circular dependency"
排查步骤:
- 检查模块是否反向 import 了业务组件
- 把共享类型提到
shared.ts - 用
import type替代import(避免运行时循环)
Q3: 请求超时但网络正常
排查步骤:
- 检查
config.timeout设置 - 检查
AbortController是否正确清理 - 检查是否有请求未取消导致堆积
Q4: 状态不更新(Stale State)
排查步骤:
- 是否使用了函数式更新
setState(prev => ...) - 是否有竞态条件(旧请求覆盖新请求)
- 是否正确使用
useCallback/useMemo
Q5: 重复请求
排查步骤:
- 是否缺少依赖项(
useEffect依赖数组) - 是否 StrictMode 下开发(会导致 effects 执行两次)
- 是否需要使用
apiCache做请求去重
Q6: Token 过期未刷新
排查步骤:
- 检查响应拦截器是否处理了 401
- 检查 Token 刷新接口是否正确
- 检查刷新后是否重放了原请求
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 类型只在编译时检查,后端返回的数据可能与声明不符:
- 后端字段拼写错误
- 字段类型改变(
string→number) - 新增字段未同步
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 可以
extend、merge、pick、omit
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 的三大原则
- 单一职责:一个 Hook 管一个业务流程
- 组合现有能力:不要重复造轮子,复用 Context 和 API 层
- 可测试:Hook 本身返回函数,可独立测试
完整的 Hook 分层
UI 层组件
调用 useTodoManager() / useOrderFlow()
业务 Hook 层(useXxx)
组合业务流程、状态机、异常处理
Context 层
提供基础状态和方法
API 层
纯数据读写
25. 附录:常见面试题与答案
Q1:fetch vs axios 怎么选?
A:
fetch原生 API、零依赖,适合简单场景axios拦截器体系成熟、自动 JSON 转换,适合中大型项目- 核心差异在"拦截器机制":axios 是类 Express 的洋葱模型
- 当前项目选择 fetch 自研封装,因为要最小依赖、全链路可控
Q2:乐观更新如何处理网络失败?
A:
- 先更新本地 state(用户即时反馈)
- 发送请求
- 成功:用服务器返回数据覆盖
- 失败:回滚到旧 state + 展示错误提示 + 可重试
Q3:请求重试如何保证幂等性?
A:
- 对 GET/HEAD/DELETE 可以安全重试
- 对 POST/PUT 必须先判断是否幂等:后端支持幂等键(
Idempotency-Keyheader) - 对业务敏感操作(支付、下单),必须带幂等键
Q4:如何避免重复点击导致多次提交?
A:
- 按钮禁用:提交中禁用按钮
- 状态锁 :用
isSubmittingflag 做互斥 - 请求去重:同一请求在 inflight 时直接复用
- 防抖/节流: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:
- 请求拦截器自动附加 Token
- 响应拦截器检测 401
- 自动调用 refreshToken 接口(带并发锁,避免多个请求同时刷新)
- 刷新成功:重放原请求
- 刷新失败:跳转登录页
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:三层类型安全:
- 编译时:TypeScript 接口定义
- 运行时:Zod / Yup Schema 校验
- 自动生成:从后端 Swagger 生成 TS 类型
Q9:如何优雅处理 loading 状态?
A:
- 全局 loading:NProgress(顶部进度条)
- 局部 loading:组件级骨架屏
- 按请求粒度:每个 API 独立 loading 状态
- 竞态保护:新请求自动清除旧 loading
- 乐观更新:交互场景可跳过 loading,直接反馈
Q10:API 封装的核心心法?
A:
- 分层:请求层 → API 定义层 → Mapper 层 → 业务 Hook 层
- 契约:类型先行,DTO 与 Domain 分离
- 防御:运行时 Zod 校验、错误码规范、重试与回滚
- 体验:乐观更新、缓存 SWR、离线队列
- 演进:fetch 封装 → axios → React Query/SWR → RSC
- 测试:测试金字塔,单元 → 集成 → E2E
- 实时:WebSocket / SSE 按需选择
- 安全:Token 自动刷新、幂等键、请求签名
源码见上一篇文章[React 组件封装方法论 ------ 以 Todo App 为例](https://editor.csdn.net/md/?articleId=162174819)