外观
状态管理演进:从 Vuex/Redux 到 Pinia/Zustand
前端状态管理经历了一个明显的钟摆过程:从"所有状态都放进全局 store",到"先判断状态属于谁"。
早期的 Vuex 和 Redux 提供了严格的单向数据流,解决了组件间共享状态的问题,但也带来了大量样板代码。随着组合式 API、Hooks 和服务器状态库的成熟,状态管理正在变得更轻、更聚焦。
先给状态分类
在选型之前,先问清楚状态属于哪一类:
| 状态类型 | 例子 | 推荐方案 |
|---|---|---|
| 局部 UI 状态 | 弹窗开关、表单输入、Tab 选中项 | 组件内部 ref / useState |
| 跨组件客户端状态 | 用户偏好、购物车、全局主题 | Pinia / Zustand / Redux Toolkit |
| 服务端状态 | 列表数据、详情数据、分页结果 | TanStack Query / SWR / Vue Query |
| URL 状态 | 筛选条件、分页、搜索关键词 | 路由 query |
| 表单状态 | 校验、脏值、提交状态 | 表单库或局部状态 |
最常见的问题是把服务端状态塞进全局 store。接口数据有缓存、失效、重新验证、并发请求等复杂需求,用 Redux 手写 loading/error/refetch 很快就会失控。
Pinia:Vue 的状态管理回归组合式
Pinia 在 Vue 3 生态中取代了 Vuex,核心变化是:
- 去掉
mutations,同步和异步都通过 action 修改状态。 - 基于组合式 API,TypeScript 类型推导自然。
- 每个 store 独立,不再有嵌套模块的命名空间复杂度。
- 支持 setup 写法,和组件内的逻辑组织方式一致。
ts
// stores/cart.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
interface CartItem {
sku: string
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function add(item: CartItem) {
const existed = items.value.find((current) => current.sku === item.sku)
if (existed) {
existed.quantity += item.quantity
} else {
items.value.push({ ...item })
}
}
return { items, total, add }
})在组件中使用:
vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { items, total } = storeToRefs(cart)
</script>storeToRefs 保留响应式。直接解构 store 会得到普通值,这是 Pinia 使用中最常见的错误之一。
Redux Toolkit:降低 Redux 的样板成本
Redux 的严格约束在大型团队中仍有价值,但原生 Redux 的 action types、action creators、reducer 三件套过于繁琐。Redux Toolkit(RTK)通过 createSlice 把三者合并:
ts
// store/cartSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
interface CartItem {
sku: string
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
const initialState: CartState = { items: [] }
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action: PayloadAction<CartItem>) {
const item = state.items.find((current) => current.sku === action.payload.sku)
if (item) {
item.quantity += action.payload.quantity
} else {
state.items.push(action.payload)
}
}
}
})
export const { addItem } = cartSlice.actions
export default cartSlice.reducerRTK 内部使用 Immer,允许直接"修改" state,实际会生成不可变更新。异步逻辑用 createAsyncThunk 或 RTK Query。
Redux 的优势是可预测性和工具链:时间旅行调试、action 日志、中间件生态。代价是心智负担和样板代码,中小项目往往不值得。
Zustand:最小化的 React 状态方案
Zustand 用闭包保存状态,通过 selector 订阅需要的部分,避免全局重渲染:
ts
// stores/cart.ts
import { create } from 'zustand'
interface CartItem {
sku: string
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
add: (item: CartItem) => void
}
export const useCartStore = create<CartState>((set) => ({
items: [],
add: (item) =>
set((state) => {
const existed = state.items.find((current) => current.sku === item.sku)
if (existed) {
return {
items: state.items.map((current) =>
current.sku === item.sku
? { ...current, quantity: current.quantity + item.quantity }
: current
)
}
}
return { items: [...state.items, item] }
})
}))组件中按需订阅:
tsx
const total = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
)注意 selector 的返回值。如果每次都返回新对象,Zustand 会认为状态变化而触发重渲染,需要用 useShallow 或拆成多个 selector。
服务端状态应该单独管理
接口数据的核心问题是缓存与失效,而不是"全局共享"。TanStack Query 这类库提供了开箱即用的能力:
tsx
import { useQuery } from '@tanstack/react-query'
function ArticleList({ page }: { page: number }) {
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['articles', page],
queryFn: () => fetchArticles(page),
staleTime: 30_000
})
if (isPending) return <Skeleton />
if (isError) return <button onClick={() => refetch()}>重试</button>
return <List items={data.items} />
}queryKey 是缓存键,staleTime 控制数据多久后视为过期。组件卸载后缓存仍在,重新进入页面会先展示旧数据再后台更新,体验明显优于每次重新请求。
Vue 项目对应 Vue Query 或 SWR 的 Vue 版本。它们和 Pinia 不是替代关系:服务端状态交给查询库,客户端状态交给 store。
常见坑
- 把所有状态都全局化。弹窗开关、输入框内容不需要进入 store,局部状态更简单。
- 存储派生状态。
total应该由items计算,而不是单独维护,否则容易不一致。 - 在 store 里写请求逻辑。请求缓存、重试、失效策略交给查询库,store 只保存必要的客户端状态。
- selector 返回新对象。会造成不必要重渲染,用浅比较或拆分 selector。
- SSR 状态泄漏。服务端渲染时全局 store 必须在每次请求创建新实例,否则用户数据会串。
小结
状态管理的演进方向不是"更强大的全局 store",而是按状态来源分工:组件状态留在组件,服务端状态交给查询库,只有真正跨组件共享的客户端状态才进入全局 store。选型时优先考虑团队心智成本和调试能力,而不是库的流行度。