状态管理方案选型与实践
一、前端状态分类
1.1 状态的本质
前端应用中的"状态"本质上是客户端内存中的数据快照。根据数据的来源、生命周期和变更频率,可以将状态分为四个核心类别:
| 状态类别 |
数据来源 |
生命周期 |
变更频率 |
典型场景 |
| 服务端状态 |
后端 API |
随页面/组件 |
低频(依赖用户操作) |
用户列表、文章详情、订单数据 |
| 客户端状态 |
本地产生 |
随应用/会话 |
高频 |
主题、侧边栏折叠、表单输入值 |
| 表单状态 |
用户输入 |
随表单组件 |
极高 |
输入值、校验错误、提交状态 |
| URL 状态 |
路由系统 |
随路由导航 |
中频 |
当前页面、筛选参数、搜索关键词 |
1.2 各类状态的技术选型
服务端状态(Server State)
服务端状态是从后端 API 获取的数据,其核心挑战不是"存储",而是"缓存、同步与失效"。
| 方案 |
推荐指数 |
核心特性 |
适用场景 |
| TanStack Query (React Query / Vue Query) |
⭐⭐⭐⭐⭐ |
自动缓存、后台刷新、乐观更新、请求去重 |
几乎任何需要调用 API 的项目 |
| SWR |
⭐⭐⭐⭐ |
轻量、stale-while-revalidate 策略、轮询 |
React 轻量项目 |
| RTK Query |
⭐⭐⭐⭐ |
集成 Redux、强类型、代码生成 |
已使用 Redux Toolkit 的项目 |
| Apollo Client |
⭐⭐⭐ |
GraphQL 专用、缓存丰富 |
GraphQL 项目 |
| 自行封装 fetch/axios |
⭐⭐ |
灵活但需手动处理缓存/去重/竞态 |
极简项目或学习用途 |
客户端状态(Client State)
客户端状态是本地产生的 UI 状态,核心挑战是"跨组件共享、响应式更新与模块化"。
| 方案 |
推荐指数 |
核心特性 |
适用场景 |
| Pinia |
⭐⭐⭐⭐⭐ |
Vue 官方推荐、TypeScript 友好、模块化 |
Vue 3 项目首选 |
| Zustand |
⭐⭐⭐⭐⭐ |
极致轻量(~1KB)、TS 友好、无 Provider |
React 项目首选 |
| Jotai |
⭐⭐⭐⭐ |
原子化状态、细粒度更新、类 Recoil |
复杂原子依赖场景 |
| Redux Toolkit |
⭐⭐⭐⭐ |
生态成熟、中间件丰富、DevTools |
企业级大型 React 应用 |
| Provide/Inject |
⭐⭐⭐ |
无需安装、适用范围有限 |
简单组件树共享 |
| Context + useReducer |
⭐⭐⭐ |
React 内置、无外部依赖 |
中小型应用、原型开发 |
表单状态管理的核心挑战是"输入跟踪、校验、提交状态和性能优化"。
| 方案 |
推荐指数 |
核心特性 |
适用场景 |
| VeeValidate + FormKit |
⭐⭐⭐⭐⭐ |
Vue 生态、校验声明式、zod 集成 |
Vue 3 项目 |
| React Hook Form |
⭐⭐⭐⭐⭐ |
性能优异、不受控组件、zod/yup 集成 |
React 项目 |
| Formik |
⭐⭐⭐⭐ |
成熟稳定、社区资源丰富 |
React 中大型表单 |
| Element Plus / Ant Design 表单 |
⭐⭐⭐⭐ |
UI 库内置、开箱即用 |
基础表单场景 |
URL 状态(URL State)
URL 状态是通过路由系统管理的状态,核心优势是可分享、可收藏、可回溯。
| 方案 |
推荐指数 |
核心特性 |
适用场景 |
| Vue Router |
⭐⭐⭐⭐⭐ |
query/params、导航守卫、类型化路由 |
Vue 项目 |
| React Router v6 |
⭐⭐⭐⭐⭐ |
useSearchParams、loaders、URL params |
React 项目 |
| Next.js/Nuxt 路由 |
⭐⭐⭐⭐⭐ |
文件路由、SSR 友好、自动类型 |
全栈框架项目 |
1.3 状态生命周期总览
┌──────────────────────────────────────────────────────────┐
│ 前端状态全景图 │
├────────────┬──────────────┬──────────────┬───────────────┤
│ 服务端状态 │ 客户端状态 │ 表单状态 │ URL状态 │
│ │ │ │ │
│ ┌──────┐ │ ┌──────┐ │ ┌──────┐ │ ┌────────┐ │
│ │ API │───│→│ Store│ │ │ Input│ │ │ Route │ │
│ └──────┘ │ └──────┘ │ └──────┘ │ └────────┘ │
│ ↓ 缓存 │ ↓ 响应式 │ ↓ 校验 │ ↓ 导航 │
│ 自动刷新 │ 模块化共享 │ 提交状态 │ 可分享/可回溯 │
├────────────┴──────────────┴──────────────┴───────────────┤
│ 推荐工具链 │
│ TanStack Query │ Pinia/Zustand │ RHF/vee-validate │
└──────────────────────────────────────────────────────────┘
二、Vue 状态管理方案对比
2.1 方案全景
| 维度 |
Pinia |
Pinia + Vue Query |
Provide/Inject |
Vuex 4 |
| 定位 |
客户端状态 |
服务端+客户端 |
简单共享 |
历史遗产 |
| TS 支持 |
✅ 原生 |
✅ 原生 |
⚠️ 一般 |
❌ 较差 |
| 体积 |
~1KB |
~15KB(含 Vue Query) |
0KB(内置) |
~10KB |
| DevTools |
✅ 专用插件 |
✅ 双插件 |
❌ 不支持 |
✅ |
| 模块化 |
✅ defineStore |
✅ 按 Query 拆分 |
❌ 无结构 |
⚠️ 模板代码多 |
| 学习成本 |
低 |
中 |
极低 |
中 |
| 推荐场景 |
Vue 3 项目首选 |
中大型数据密集型项目 |
简单父子通信 |
兼容 Vue 2 历史项目 |
2.2 Vue 生态选型决策
┌─────────────────────┐
│ Vue 3 项目 │
└──────────┬──────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ 小型 │ │ 中型 │ │ 大型 │
└───┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────────┐
│Provide/ │ │ Pinia │ │ Pinia │
│Inject │ │ + Vue Query│ │ + Vue Query │
│(简单共享) │ │ │ │ + 模块化Store │
└────────────┘ └────────────┘ └────────────────┘
三、React 状态管理方案对比
3.1 方案全景
| 维度 |
Zustand |
Jotai |
Redux Toolkit |
Context + useReducer |
| 定位 |
轻量全局 Store |
原子化状态 |
企业级状态管理 |
内置方案 |
| 包体积 |
~1KB |
~3KB |
~12KB (含 Redux) |
0KB |
| TS 支持 |
✅ 原生 |
✅ 原生 |
✅ 原生 |
⚠️ 需手动类型 |
| Boilerplate |
极少 |
较少 |
中等 |
较少 |
| 性能 |
按 selector 更新 |
原子级精确更新 |
按 slice 更新 |
全子树重渲染 |
| 中间件 |
persist/immer/devtools |
无官方中间件 |
丰富生态 |
N/A |
| 外部使用 |
✅ 可直接读写 |
❌ 需要 Provider |
✅ 可直接读写 |
❌ 需要 Provider |
| 推荐场景 |
中小型项目首选 |
复杂原子依赖 |
大型企业项目 |
原型/简单场景 |
3.2 React 生态选型决策
┌─────────────────────┐
│ React 项目 │
└──────────┬──────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ 小型 │ │ 中型 │ │ 大型 │
└───┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────────┐
│Context + │ │ Zustand │ │ Redux Toolkit │
│useReducer │ │ + React │ │ + RTK Query │
│(简单场景) │ │ Query │ │ + 模块化Slice │
└────────────┘ └────────────┘ └────────────────┘
四、Pinia 实践(Vue 3)
4.1 defineStore 的两种定义方式
Options Store(选项式,类 Vuex 写法)
// stores/counter.ts
import { defineStore } from 'pinia'
interface CounterState {
count: number
lastUpdated: number | null
}
export const useCounterStore = defineStore('counter', {
// state 必须是返回对象的函数,避免跨请求/实例的状态污染
state: (): CounterState => ({
count: 0,
lastUpdated: null,
}),
// getters 定义派生状态,带类型推断
getters: {
doubleCount: (state) => state.count * 2,
formattedDate: (state): string => {
if (!state.lastUpdated) return '尚未更新'
return new Date(state.lastUpdated).toLocaleString('zh-CN')
},
},
// actions 定义业务逻辑,支持同步和异步
actions: {
increment(step: number = 1) {
this.count += step
this.lastUpdated = Date.now()
},
async fetchCount() {
try {
const response = await api.get('/api/count')
this.count = response.data
this.lastUpdated = Date.now()
} catch (error) {
console.error('获取计数失败:', error)
// 防御式编程:API 失败时使用兜底值
this.count = 0
}
},
},
})
Setup Store(组合式,推荐方式)
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
avatar: string
role: 'admin' | 'editor' | 'viewer'
}
export const useUserStore = defineStore('user', () => {
// state —— 使用 ref / reactive
const currentUser = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const loginLoading = ref(false)
const loginError = ref<string | null>(null)
// getters —— 使用 computed
const isLoggedIn = computed(() => !!token.value && !!currentUser.value)
const isAdmin = computed(() => currentUser.value?.role === 'admin')
const displayName = computed(() => currentUser.value?.name ?? '未登录用户')
const avatarUrl = computed(() => {
// 防御式编程:处理用户信息缺失的场景
return currentUser.value?.avatar ?? '/default-avatar.png'
})
// actions —— 使用普通函数
async function login(username: string, password: string) {
loginLoading.value = true
loginError.value = null
try {
// 防御式编程:永远不要信任后端返回的数据
const response = await api.post('/api/auth/login', {
username,
password,
})
const data = response.data
// 防御式编程:校验后端数据完整性
if (!data.user || !data.token) {
throw new Error('登录响应格式异常:缺少用户信息或令牌')
}
currentUser.value = {
id: data.user.id,
name: data.user.name ?? '',
email: data.user.email ?? '',
avatar: data.user.avatar ?? '',
role: data.user.role ?? 'viewer',
}
token.value = data.token
localStorage.setItem('token', data.token)
} catch (error: any) {
const message = error?.response?.data?.message ?? error.message ?? '登录失败,请稍后重试'
loginError.value = message
throw new Error(message)
} finally {
loginLoading.value = false
}
}
function logout() {
currentUser.value = null
token.value = null
localStorage.removeItem('token')
}
return {
currentUser,
token,
loginLoading,
loginError,
isLoggedIn,
isAdmin,
displayName,
avatarUrl,
login,
logout,
}
})
4.2 Options Store vs Setup Store 对比
| 对比维度 |
Options Store |
Setup Store |
| 写法 |
对象属性声明 |
函数式定义(Composition API) |
| TypeScript 推断 |
state/getters 自动推断,actions 需手动 |
全自动类型推断 |
| 灵活性 |
结构固定,无法自定义逻辑组合 |
可组合任意 Composition API |
| 可测试性 |
依赖 Pinia 实例 |
纯函数,更易测试 |
| 复杂逻辑组织 |
依赖 mixin 或外部函数 |
可直接复用 composables |
| 推荐度 |
简单场景可选 |
项目主力推荐 |
4.3 组件中使用
<!-- views/DashboardView.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const counterStore = useCounterStore()
// 使用 storeToRefs 解构保持响应性
const { displayName, isAdmin, avatarUrl, loginLoading } = storeToRefs(userStore)
// 直接解构 actions 不会丢失 this 上下文
const { login, logout } = userStore
const { increment, fetchCount } = counterStore
// 错误示范 ❌:直接解构 state/getters 会导致响应性丢失
// const { count } = counterStore // 这样 count 是普通值,不响应
onMounted(() => {
fetchCount()
})
</script>
<template>
<div class="dashboard">
<header>
<el-avatar :src="avatarUrl" />
<span>{{ displayName }}</span>
<el-tag v-if="isAdmin" type="danger">管理员</el-tag>
</header>
<main>
<el-card>
<template #header>
<span>计数器</span>
</template>
<p>当前值:{{ counterStore.count }}</p>
<p>翻倍值:{{ counterStore.doubleCount }}</p>
<p>更新时间:{{ counterStore.formattedDate }}</p>
<el-button @click="increment(1)" :disabled="loginLoading">
增加
</el-button>
<el-button @click="fetchCount" :loading="loginLoading">
从服务端刷新
</el-button>
</el-card>
</main>
</div>
</template>
4.4 模块化拆分策略
src/stores/
├── index.ts # 统一导出
├── user.ts # 用户模块
├── permission.ts # 权限模块
├── app.ts # 应用配置(主题、侧边栏等)
├── settings.ts # 用户设置
└── modules/ # 业务模块
├── order.ts
├── product.ts
└── notification.ts
// stores/index.ts —— 统一导出入口
export { useUserStore } from './user'
export { usePermissionStore } from './permission'
export { useAppStore } from './app'
export { useOrderStore } from './modules/order'
export { useProductStore } from './modules/product'
4.5 Pinia 持久化
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// stores/settings.ts
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', () => {
const theme = ref<'light' | 'dark'>('light')
const sidebarCollapsed = ref(false)
const language = ref('zh-CN')
// 开启持久化
return { theme, sidebarCollapsed, language }
}, {
persist: {
key: 'app-settings', // 存储键名
storage: localStorage, // 存储引擎
pick: ['theme', 'language'], // 只持久化部分字段
},
})
4.6 Pinia 注意事项与踩坑
✅ 最佳实践
- 优先使用 Setup Store,类型推断更好,逻辑组织更灵活
- 使用
storeToRefs 解构 state/getters,保持响应性
- 将 API 调用层与 Store 解耦,在 action 中调用独立的 API 模块
- 合理拆分模块,每个 Store 不超过 8~10 个 action
- 使用
$reset() 方法重置状态(Options Store 原生支持,Setup Store 需手动实现)
❌ 常见错误
- 直接解构 state导致响应性丢失
- 在 Store 外部直接修改 state(虽然 Pinia 允许,但不推荐)
- Store 之间循环依赖(A Store 引用 B Store,B Store 引用 A Store)
- Store 中存放非序列化数据(如 DOM 元素、组件实例)
- 未处理异步 action 的错误边界,导致组件中无法感知失败
// ✅ 正确:使用 storeToRefs 解构
const { count } = storeToRefs(counterStore)
// ❌ 错误:直接解构失去响应性
const { count } = counterStore
// ✅ 正确:Setup Store 手动实现 $reset
export const useFormStore = defineStore('form', () => {
const initialState = () => ({
name: '',
email: '',
age: 0,
})
const name = ref('')
const email = ref('')
const age = ref(0)
function $reset() {
const state = initialState()
name.value = state.name
email.value = state.email
age.value = state.age
}
return { name, email, age, $reset }
})
五、Zustand 实践(React)
5.1 create 函数定义 Store
// stores/counterStore.ts
import { create } from 'zustand'
interface CounterState {
count: number
lastUpdated: number | null
increment: (step?: number) => void
decrement: (step?: number) => void
reset: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
lastUpdated: null,
increment: (step = 1) =>
set((state) => ({
count: state.count + step,
lastUpdated: Date.now(),
})),
decrement: (step = 1) =>
set((state) => ({
count: state.count - step,
lastUpdated: Date.now(),
})),
reset: () => set({ count: 0, lastUpdated: null }),
}))
5.2 使用方式对比
Hook 方式(推荐在组件内使用)
// components/CounterPanel.tsx
import { useCounterStore } from '@/stores/counterStore'
export function CounterPanel() {
// ✅ 正确:使用 selector 订阅特定字段,避免不必要的重渲染
const count = useCounterStore((state) => state.count)
const doubleCount = useCounterStore((state) => state.count * 2)
const { increment, decrement, reset } = useCounterStore(
(state) => ({
increment: state.increment,
decrement: state.decrement,
reset: state.reset,
}),
)
return (
<div className="counter-panel">
<p>当前值:{count}</p>
<p>翻倍值:{doubleCount}</p>
<button onClick={() => increment(1)}>增加</button>
<button onClick={() => decrement(1)}>减少</button>
<button onClick={reset}>重置</button>
</div>
)
}
外部使用方式(在组件外、工具函数中读写)
// api/tokenInterceptor.ts
import { useAuthStore } from '@/stores/authStore'
// ✅ 在 axios 拦截器中直接获取/设置 Store 值
apiClient.interceptors.request.use((config) => {
// 注意:useAuthStore 是函数,在非组件环境下可直接调用 .getState()
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 在外部直接修改状态
useAuthStore.getState().logout()
}
return Promise.reject(error)
},
)
5.3 Hook 方式 vs 外部使用
| 对比维度 |
Hook 方式 |
外部使用 |
| 使用范围 |
仅限 React 组件/Hooks |
组件、工具函数、拦截器 |
| 订阅机制 |
自动订阅 + 按 selector 重渲染 |
手动调用 .getState() |
| 性能 |
按 selector 精确控制重渲染 |
无自动订阅,无重渲染 |
| 状态更新 |
set 更新自动通知组件 |
.setState() 或 .getState() 后手动处理 |
| 典型场景 |
组件内使用状态 |
路由守卫、请求拦截器、Service 层 |
5.4 中间件
// stores/authStore.ts
import { create } from 'zustand'
import { persist, devtools, immer } from 'zustand/middleware'
interface AuthState {
user: { id: number; name: string; roles: string[] } | null
token: string | null
login: (username: string, password: string) => Promise<void>
logout: () => void
addRole: (role: string) => void
}
export const useAuthStore = create<AuthState>()(
devtools(
persist(
immer((set) => ({
user: null,
token: null,
login: async (username, password) => {
try {
const response = await api.post('/api/auth/login', { username, password })
// immer 允许"可变"写法,自动生成不可变状态
set((state) => {
state.user = response.data.user
state.token = response.data.token
})
} catch (error) {
console.error('登录失败:', error)
throw error
}
},
logout: () => {
set({ user: null, token: null })
localStorage.removeItem('auth-storage')
},
addRole: (role) => {
set((state) => {
if (state.user) {
state.user.roles.push(role) // immer 允许直接 push
}
})
},
})),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token, user: state.user }),
},
),
{ name: 'AuthStore' },
),
)
5.5 slice 模式拆分 Store
当业务逻辑复杂时,使用 slice 模式将一个 Store 拆分为多个逻辑片段:
// stores/orderStore.ts
import { create } from 'zustand'
// ── 类型定义 ──
interface OrderItem {
id: string
productId: number
productName: string
quantity: number
price: number
}
interface OrderState {
// 订单列表 slice
orders: OrderItem[]
loading: boolean
error: string | null
// 筛选 slice
filter: {
status: string
dateRange: [string, string] | null
}
// Actions:订单列表 slice
fetchOrders: () => Promise<void>
addOrder: (order: OrderItem) => void
removeOrder: (id: string) => void
// Actions:筛选 slice
setFilter: (filter: Partial<OrderState['filter']>) => void
resetFilter: () => void
}
// ── Slice 工厂函数模式 ──
function createOrderSlice(
set: Parameters<typeof create<OrderState>>[0],
) {
return {
orders: [] as OrderItem[],
loading: false,
error: null,
fetchOrders: async () => {
set({ loading: true, error: null })
try {
const response = await api.get('/api/orders')
// 防御式编程:校验后端数据
const data = Array.isArray(response.data) ? response.data : []
set({ orders: data, loading: false })
} catch (error: any) {
set({
loading: false,
error: error?.message ?? '获取订单列表失败',
})
}
},
addOrder: (order) =>
set((state) => ({ orders: [...state.orders, order] })),
removeOrder: (id) =>
set((state) => ({
orders: state.orders.filter((o) => o.id !== id),
})),
}
}
function createFilterSlice(
set: Parameters<typeof create<OrderState>>[0],
) {
return {
filter: {
status: 'all',
dateRange: null as [string, string] | null,
},
setFilter: (partial) =>
set((state) => ({
filter: { ...state.filter, ...partial },
})),
resetFilter: () =>
set({
filter: { status: 'all', dateRange: null },
}),
}
}
// ── 合并 Slice ──
export const useOrderStore = create<OrderState>()((set) => ({
...createOrderSlice(set),
...createFilterSlice(set),
}))
5.6 Zustand 注意事项与踩坑
✅ 最佳实践
- 使用 selector 精确订阅,避免父组件更新导致整个子树重渲染
- 使用 shallow 比较:
import { shallow } from 'zustand/shallow'
- immer 中间件简化不可变更新,特别是嵌套对象场景
- 使用
persist 中间件持久化关键状态(token、偏好设置)
- 在拦截器/工具函数中使用
.getState() 而非 useXxxStore()
❌ 常见错误
- 在组件中订阅整个 Store,导致任何字段变更都触发重渲染
- 在 Zustand action 中直接修改 state(非 immer 中间件时)
- selector 返回新对象/数组(每次重渲染时引用不同,导致死循环)
- Store 中存放异步操作状态(loading/error 应独立管理或使用 TanStack Query)
// ✅ 正确:selector 返回原始值
const count = useCounterStore((s) => s.count)
// ❌ 错误:selector 每次都返回新对象,导致无限重渲染
const { count, increment } = useCounterStore(
(s) => ({ count: s.count, increment: s.increment }),
)
// ✅ 修复:使用 shallow 比较
import { shallow } from 'zustand/shallow'
const { count, increment } = useCounterStore(
(s) => ({ count: s.count, increment: s.increment }),
shallow,
)
六、服务端状态管理
6.1 为什么需要专门的服务端状态管理工具?
传统模式下,开发者通常自行封装 API 调用,在 Store 或组件中管理 loading/error/data 三个状态。这种模式存在以下问题:
| 问题 |
传统模式 |
TanStack Query 方案 |
| 缓存管理 |
手动维护缓存,缺少失效策略 |
自动缓存 + 基于时间的失效策略 |
| 请求去重 |
同一时间多个组件请求相同 API |
自动去重,只发一个请求 |
| 后台刷新 |
需手动 setInterval |
支持 focus refetch、stale time |
| 乐观更新 |
实现繁琐,易出错 |
内置 API,回滚机制 |
| 分页/无限加载 |
手动维护 page/cursor |
内置 useInfiniteQuery |
| 竞态条件 |
需手动处理请求过期 |
自动取消过期请求 |
6.2 Vue:TanStack Vue Query 实践
// composables/useArticle.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { api } from '@/api'
interface Article {
id: number
title: string
content: string
author: { id: number; name: string }
createdAt: string
tags: string[]
}
// ── 获取文章列表 ──
export function useArticles() {
return useQuery({
queryKey: ['articles'],
queryFn: async (): Promise<Article[]> => {
const response = await api.get('/api/articles')
// 防御式编程:校验后端数据结构
if (!Array.isArray(response.data)) {
console.warn('API 返回格式异常,期望数组,收到:', typeof response.data)
return []
}
return response.data.map((item: any) => ({
id: item.id,
title: item.title ?? '',
content: item.content ?? '',
author: {
id: item.author?.id ?? 0,
name: item.author?.name ?? '未知作者',
},
createdAt: item.createdAt ?? new Date().toISOString(),
tags: Array.isArray(item.tags) ? item.tags : [],
}))
},
staleTime: 30_000, // 30 秒内认为数据是新鲜的,不触发重新请求
gcTime: 5 * 60_000, // 5 分钟后清除缓存(原名 cacheTime)
refetchOnWindowFocus: true,
retry: 2, // 失败重试次数
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10_000),
})
}
// ── 获取单个文章 ──
export function useArticle(id: number) {
return useQuery({
queryKey: ['articles', id],
queryFn: async (): Promise<Article | null> => {
try {
const response = await api.get(`/api/articles/${id}`)
const item = response.data
if (!item || !item.id) {
throw new Error('文章不存在')
}
return {
id: item.id,
title: item.title ?? '',
content: item.content ?? '',
author: {
id: item.author?.id ?? 0,
name: item.author?.name ?? '未知作者',
},
createdAt: item.createdAt ?? new Date().toISOString(),
tags: Array.isArray(item.tags) ? item.tags : [],
}
} catch (error) {
// 防御:捕获所有异常,返回 null 而非崩溃
console.error(`获取文章 ${id} 失败:`, error)
return null
}
},
enabled: !!id, // id 为 falsy 时不请求
})
}
// ── 创建文章(Mutation + 乐观更新 + 缓存失效) ──
export function useCreateArticle() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; content: string; tags: string[] }) =>
api.post('/api/articles', data),
// 乐观更新
onMutate: async (newArticle) => {
// 取消可能正在进行的相关查询
await queryClient.cancelQueries({ queryKey: ['articles'] })
// 保存之前的数据用于回滚
const previousArticles = queryClient.getQueryData<Article[]>(['articles'])
// 乐观插入
queryClient.setQueryData<Article[]>(['articles'], (old) => {
const optimisticArticle: Article = {
id: -Date.now(), // 临时 ID
title: newArticle.title,
content: newArticle.content,
author: { id: 0, name: '保存中...' },
createdAt: new Date().toISOString(),
tags: newArticle.tags,
}
return old ? [optimisticArticle, ...old] : [optimisticArticle]
})
// 返回 context,供 onError 回滚使用
return { previousArticles }
},
// 请求成功:使缓存失效,触发重新获取
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] })
},
// 请求失败:回滚乐观更新
onError: (error, _, context) => {
if (context?.previousArticles) {
queryClient.setQueryData(['articles'], context.previousArticles)
}
console.error('创建文章失败:', error)
},
})
}
<!-- views/ArticleListView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useArticles, useCreateArticle } from '@/composables/useArticle'
import { ElMessage } from 'element-plus'
const { data: articles, isLoading, isError, error, refetch } = useArticles()
const { mutate: createArticle, isPending: isCreating } = useCreateArticle()
const showDialog = ref(false)
const form = ref({ title: '', content: '', tags: '' })
function handleCreate() {
createArticle(
{
title: form.value.title,
content: form.value.content,
tags: form.value.tags.split(',').map((t) => t.trim()).filter(Boolean),
},
{
onSuccess: () => {
ElMessage.success('文章创建成功')
showDialog.value = false
form.value = { title: '', content: '', tags: '' }
},
onError: (err) => {
ElMessage.error(err?.message ?? '创建失败,请稍后重试')
},
},
)
}
</script>
<template>
<div>
<!-- 加载状态 -->
<el-skeleton v-if="isLoading" :rows="5" animated />
<!-- 错误状态 -->
<el-result v-else-if="isError" status="error" title="加载失败">
<template #extra>
<el-button @click="refetch">重新加载</el-button>
</template>
</el-result>
<!-- 空状态 -->
<el-empty v-else-if="!articles?.length" description="暂无文章" />
<!-- 数据列表 -->
<el-table v-else :data="articles">
<el-table-column prop="title" label="标题" />
<el-table-column prop="author.name" label="作者" />
<el-table-column prop="createdAt" label="创建时间" />
</el-table>
<el-button @click="showDialog = true" :loading="isCreating">
新建文章
</el-button>
</div>
</template>
6.3 React:TanStack React Query 实践
// hooks/useArticles.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api'
interface Article {
id: number
title: string
content: string
author: { id: number; name: string }
createdAt: string
tags: string[]
}
export function useArticles(filters?: { status?: string; page?: number }) {
return useQuery({
queryKey: ['articles', filters],
queryFn: async (): Promise<Article[]> => {
const response = await api.get('/api/articles', { params: filters })
// 防御式编程:永远不要信任后端返回的数据
if (!Array.isArray(response.data)) {
console.warn('API 返回格式异常,期望数组:', response.data)
return []
}
return response.data.map((item: any) => ({
id: item.id,
title: item.title ?? '',
content: item.content ?? '',
author: {
id: item.author?.id ?? 0,
name: item.author?.name ?? '未知作者',
},
createdAt: item.createdAt ?? new Date().toISOString(),
tags: Array.isArray(item.tags) ? item.tags : [],
}))
},
staleTime: 30_000,
placeholderData: keepPreviousData, // 分页时保留前页数据
refetchOnWindowFocus: true,
})
}
export function useUpdateArticle() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, ...data }: Partial<Article> & { id: number }) =>
api.put(`/api/articles/${id}`, data),
onSuccess: (_, variables) => {
// 使具体文章和高层列表缓存同时失效
queryClient.invalidateQueries({ queryKey: ['articles', variables.id] })
queryClient.invalidateQueries({ queryKey: ['articles'] })
},
})
}
// components/ArticleList.tsx
import { useState } from 'react'
import { useArticles, useCreateArticle } from '@/hooks/useArticles'
import { Button, Table, Modal, message, Skeleton, Empty, Result } from 'antd'
export function ArticleList() {
const [page, setPage] = useState(1)
const { data: articles, isLoading, isError, refetch } = useArticles({ page })
const { mutateAsync: createArticle, isPending: isCreating } = useCreateArticle()
const [modalOpen, setModalOpen] = useState(false)
// 加载态
if (isLoading) {
return <Skeleton active paragraph={{ rows: 8 }} />
}
// 错误态
if (isError) {
return (
<Result
status="error"
title="数据加载失败"
extra={<Button onClick={() => refetch()}>重新加载</Button>}
/>
)
}
return (
<div>
{/* 空态 */}
{!articles?.length ? (
<Empty description="暂无文章" />
) : (
<Table
dataSource={articles}
columns={[
{ title: '标题', dataIndex: 'title', key: 'title' },
{
title: '作者',
dataIndex: ['author', 'name'],
key: 'author',
},
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt' },
]}
pagination={{
current: page,
onChange: setPage,
}}
/>
)}
<Button
type="primary"
onClick={() => setModalOpen(true)}
loading={isCreating}
>
新建文章
</Button>
<Modal
title="新建文章"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={async () => {
await createArticle({ title: '测试', content: '内容', tags: [] })
setModalOpen(false)
}}
/>
</div>
)
}
6.4 服务端状态管理注意事项
✅ 最佳实践
- 将 queryKey 作为缓存依赖的第一公民,合理设计层级(
['资源', ...参数])
- 合理设置
staleTime,避免不必要地重复请求
- 静态数据(配置、字典):5~30 分钟
- 用户信息:30 秒~1 分钟
- 实时数据(通知、消息):0 或很短
- 使用
placeholderData: keepPreviousData 提升分页/筛选体验
- 使用乐观更新提升用户体验,同时做好回滚准备
- 在 queryFn 中做数据校验和转换,而非在组件中处理
❌ 常见错误
- 将 API 调用和状态管理混在 Store 中,与 TanStack Query 职责重叠
- queryKey 粒度太粗,导致不必要的缓存失效
- 乐观更新未处理回滚逻辑,导致 UI 显示脏数据
- 未处理
queryFn 中的异常,导致错误边界崩溃
- 在
onSuccess 中又手动 setState,导致数据源不统一
七、状态管理选型决策树
7.1 完整决策树
┌──────────────────────────────────┐
│ 开始项目状态管理选型 │
└────────────────┬─────────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌─────────────┐ ┌──────────────┐
│ Vue 3 项目 │ │ React 项目 │
└──────┬──────┘ └──────┬───────┘
│ │
┌───────┼───────┐ ┌───────┼───────┐
▼ ▼ ▼ ▼ ▼ ▼
┌─────┐ ┌─────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ 小 │ │ 中 │ │ 大 │ │ 小 │ │ 中 │ │ 大 │
└──┬──┘ └──┬──┘ └──┬─┘ └──┬─┘ └──┬──┘ └──┬─┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌────┐ ┌────┐ ┌──────┐ ┌──────┐
│简单 │ │Pinia │ │标准│ │ Ctx│ │Zust- │ │Redux │
│共享 │ │+Vue │ │+ │ │+use│ │and │ │Tool- │
│: │ │Query │ │Vue │ │Red-│ │+ │ │kit │
│Prov/ │ │ │ │Qry │ │ucr │ │ReactQ│ │+RTK │
│Inject│ │ │ │ │ │ │ │ │ │Query │
└──────┘ └──────┘ └────┘ └────┘ └──────┘ └──────┘
7.2 场景建议表
| 项目类型 |
推荐方案 |
备选方案 |
核心考量 |
| Vue 3 后台管理 |
Pinia + Vue Query |
Pinia + axios |
官方推荐、生态成熟 |
| Vue 3 简单 SPA |
Pinia |
Provide/Inject |
保持简洁、不过度设计 |
| Vue 3 SSR (Nuxt) |
Pinia + useFetch |
Vue Query |
Nuxt 内置 useFetch 更简单 |
| React 中后台 |
Zustand + React Query |
RTK + RTK Query |
轻量、TS 友好 |
| React 大型企业应用 |
Redux Toolkit + RTK Query |
Zustand + React Query |
团队规范、DevTools、生态 |
| React 简单页面 |
Context + useReducer |
Zustand |
零依赖、快速 |
| React SSR (Next.js) |
Zustand + React Query |
RTK Query |
Server Components 兼容性 |
| 微前端主应用 |
Zustand(外部可读写) |
Pinia |
无需 Provider 包裹 |
| 复杂表单 |
React Hook Form + zod |
VeeValidate + zod |
性能优先、TS 集成 |
| 实时数据仪表盘 |
React Query + WebSocket |
Zustand + EventSource |
自动缓存 + 实时更新 |
7.3 技术选型检查清单
Vue 3 项目检查清单
React 项目检查清单
通用检查清单
7.4 总结
┌────────────────────────────────────────────────────────────┐
│ 核心原则总结 │
├────────────────────────────────────────────────────────────┤
│ │
│ 1. 分类管理:服务端状态用 Query 工具,客户端状态用 Store │
│ 2. 职责分离:API 调用层、Store 层、组件层职责清晰 │
│ 3. 最小必要:不滥用全局状态,能用局部就用局部 │
│ 4. 防御编程:永远不信任后端数据,校验 + 兜底值 │
│ 5. 性能意识:精确订阅、合理缓存、避免不必要重渲染 │
│ 6. 团队规范:统一命名、结构化模块、代码审查 │
│ │
└────────────────────────────────────────────────────────────┘
状态管理的本质不是"存数据",而是"管理数据的生命周期"。选择方案时,理解你的数据从哪里来、到哪里去、多久变一次,比选哪个库更重要。