Skip to content

状态管理演进:从 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.reducer

RTK 内部使用 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。

常见坑 ​

  1. 把所有状态都全局化。弹窗开关、输入框内容不需要进入 store,局部状态更简单。
  2. 存储派生状态。total 应该由 items 计算,而不是单独维护,否则容易不一致。
  3. 在 store 里写请求逻辑。请求缓存、重试、失效策略交给查询库,store 只保存必要的客户端状态。
  4. selector 返回新对象。会造成不必要重渲染,用浅比较或拆分 selector。
  5. SSR 状态泄漏。服务端渲染时全局 store 必须在每次请求创建新实例,否则用户数据会串。

小结 ​

状态管理的演进方向不是"更强大的全局 store",而是按状态来源分工:组件状态留在组件,服务端状态交给查询库,只有真正跨组件共享的客户端状态才进入全局 store。选型时优先考虑团队心智成本和调试能力,而不是库的流行度。

基于 VitePress 构建 · 持续更新中