前端状态管理的本质:从 Vuex 到 Pinia,我们到底在管理什么?

43 阅读1分钟

前端状态管理的本质:从 Vuex 到 Pinia,我们到底在管理什么?

一个真实的崩溃现场

事情是这样的。一个中后台项目,二十多个页面,四个人协作开发。某天我打开 store/index.js,发现这个文件已经膨胀到了 1400 行。里面有用户信息、权限列表、全局 loading、主题配置、表单草稿、甚至还有一个叫 tempFlag 的变量——没有注释,没人知道它是干嘛的,但谁也不敢删。

更刺激的是,改一个筛选条件的 mutation,三个页面同时炸了。

那一刻我意识到:我们不是在做状态管理,我们是在做状态腌制——什么东西都往 store 里塞,最后谁也分不清哪些是全局状态、哪些只是组件的局部变量。

这才是状态管理真正需要回答的问题:什么该管,什么不该管,怎么管。


状态管理的本质问题

把所有花哨的 API 扒掉,状态管理解决的核心问题只有三个:

1. 共享——多个组件需要读写同一份数据 2. 同步——数据变了,所有依赖方必须自动更新 3. 可预测——状态怎么变的,能追踪、能复现

就这三件事。听起来简单,但组合在一起,复杂度是指数级的。

打个比方:状态管理就像一个公司的共享文档系统。没有它的时候,大家用微信互传 Excel,版本混乱,信息不同步。有了它,所有人编辑同一份文档,改动实时可见,历史记录可追溯。

但如果你把所有东西——会议纪要、个人笔记、午餐菜单——全扔进共享文档,那也是一场灾难。


先搞清楚:你的状态到底分几类?

在动手选工具之前,先把状态分个类:

// 1️⃣ 服务端状态:从 API 拿回来的数据
//    特点:有缓存需求、有过期时间、有加载/错误状态
const userList = await fetch('/api/users')

// 2️⃣ 客户端全局状态:多组件共享的 UI 状态
//    特点:用户信息、权限、主题、语言偏好
const currentUser = { name: '张三', role: 'admin' }

// 3️⃣ 组件局部状态:只有这个组件自己关心
//    特点:表单输入、弹窗开关、tab 选中
const isModalOpen = ref(false)

// 4️⃣ URL 状态:应该反映在路由里的状态
//    特点:分页、筛选条件、搜索关键词
// /users?page=2&role=admin

80% 的状态管理滥用,都是把第 1 类和第 3 类塞进了全局 store。

服务端状态有专门的工具(TanStack Query / SWR),组件局部状态用 ref 就够了。真正需要状态管理库的,往往只有第 2 类。


Vuex 的设计哲学:从 Flux 说起

Vuex 不是凭空冒出来的。它的老祖宗是 Facebook 提出的 Flux 架构,核心思想就一句话:数据单向流动,状态修改必须走固定路径。

// Flux 的核心模型:
// View → Action → Mutation → State → View
//  ↑                                    |
//  └────────────────────────────────────┘

// 翻译成人话:
// 用户点了个按钮(View)
// → 触发一个动作(Action)
// → 动作提交一个修改(Mutation)
// → 修改更新状态(State)
// → 视图自动刷新(View)

为什么要这么绕?因为没有这套约束的时候,代码长这样:

// ❌ 没有状态管理时的"自由"写法
// ComponentA.vue
this.$parent.$parent.$refs.sidebar.userInfo.name = '李四'

// ComponentB.vue
eventBus.$on('user-changed', (data) => {
  this.user = data // 谁发的?什么时候发的?鬼知道
})

// ComponentC.vue
window.__globalUser = { name: '王五' } // 破罐子破摔

三种写法,三种混乱。状态散落在各处,改了一个地方,不知道哪里会炸。这就是 Flux 要解决的问题——不是让写代码更方便,而是让状态变化可追踪。


Vuex 做对了什么?

Vuex 把 Flux 思想落地到 Vue 生态,核心就四个概念:

// Vuex 的四件套
const store = new Vuex.Store({
  // state:唯一数据源
  state: {
    count: 0
  },
  
  // getters:派生状态(类似 computed)
  getters: {
    doubleCount: state => state.count * 2
  },
  
  // mutations:唯一能改 state 的地方(必须同步)
  mutations: {
    INCREMENT(state) {
      state.count++
    }
  },
  
  // actions:处理异步逻辑,最终调 mutation
  actions: {
    async fetchAndIncrement({ commit }) {
      await api.doSomething()
      commit('INCREMENT') // 异步完成后,还是得走 mutation
    }
  }
})

这套设计的好处很明确:

  • 所有状态改动都经过 mutation,DevTools 能记录每一次变化
  • mutation 必须同步,保证状态变化的时序可预测
  • 单一 store,不存在"这个数据在哪个组件的 data 里"的困惑

对于 2018 年的 Vue 2 生态来说,这是一个合理的设计。


Vuex 做错了什么?

但是,随着项目变大,Vuex 的问题开始暴露——不是"不能用",而是"用得累"。

问题一:mutation 和 action 的分裂

// 想改一个状态,要写三个地方:

// 1. 定义 mutation(store/mutations.js)
SET_USER_INFO(state, payload) {
  state.userInfo = payload
}

// 2. 定义 action(store/actions.js)
async fetchUserInfo({ commit }) {
  const res = await getUserInfo()
  commit('SET_USER_INFO', res.data) // 字符串调用,拼错了不报错
}

// 3. 组件里 dispatch(SomeComponent.vue)
this.$store.dispatch('fetchUserInfo')

改一个字段,三个文件跳来跳去。mutation 名字是字符串,拼错了运行时才发现。你以为有 TypeScript 就能救?Vuex 的类型推导基本靠吼。

问题二:模块化的噩梦

// Vuex 的 modules 方案
const store = new Vuex.Store({
  modules: {
    user: {
      namespaced: true,
      state: () => ({ info: null }),
      mutations: { SET_INFO(state, val) { state.info = val } }
    },
    order: {
      namespaced: true,
      // ... 又是一套 state/getters/mutations/actions
    }
  }
})

// 使用时:
store.commit('user/SET_INFO', data) // 命名空间 + 字符串,酸爽
store.dispatch('order/fetchList')

// mapState 写起来更酸爽
...mapState('user', ['info'])
...mapState('order', ['list'])

namespaced modules 解决了命名冲突,但代价是到处写模块路径字符串。跨模块访问更是灾难——rootStaterootGetters,像在迷宫里找路。

问题三:TypeScript 支持几乎为零

这才是致命伤。在 TypeScript 成为前端标配的今天,Vuex 的类型推导停留在"手动声明"的原始阶段。commit('SET_USER')——这个字符串里的 typo,TypeScript 帮不了你。


Pinia 做对了什么?

Pinia 不是 Vuex 5 的"重命名版",它是一次设计哲学的重新思考。

核心变化:干掉 mutation

// Pinia 的写法
export const useUserStore = defineStore('user', () => {
  // state:直接用 ref
  const userInfo = ref(null)
  const token = ref('')

  // getter:直接用 computed
  const isLoggedIn = computed(() => !!token.value)

  // action:直接写函数,同步异步都行
  async function login(credentials) {
    const res = await loginApi(credentials)
    token.value = res.token    // 直接改,不用 commit
    userInfo.value = res.user  // 就是普通的赋值
  }

  function logout() {
    token.value = ''
    userInfo.value = null
  }

  return { userInfo, token, isLoggedIn, login, logout }
})

看出区别了吗?没有 mutation,没有 commit,没有字符串魔法。

改状态就是赋值,写 action 就是写函数。整个 store 就是一个 Composition API 的 setup 函数,你已经会了。

组件里使用

// 组件中
const userStore = useUserStore()

// 读状态:直接访问
console.log(userStore.userInfo)

// 调 action:直接调函数
await userStore.login({ username: 'admin', password: '123' })

// 完整的类型推导,IDE 自动补全,拼错直接红线
// userStore.logot() → TS Error: Property 'logot' does not exist

TypeScript 支持开箱即用。因为 store 就是个返回对象的函数,类型推导是天然的。


从 Vuex 迁移到 Pinia:一个对照表

// ============= Vuex =============
const store = new Vuex.Store({
  state: { count: 0 },
  getters: {
    double: state => state.count * 2
  },
  mutations: {
    SET_COUNT(state, val) { state.count = val }
  },
  actions: {
    async fetchCount({ commit }) {
      const res = await api.getCount()
      commit('SET_COUNT', res)   // 字符串调用,无类型检查
    }
  }
})

// 组件中
this.$store.dispatch('fetchCount')   // 又是字符串
this.$store.getters.double           // 无自动补全

// ============= Pinia =============
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const double = computed(() => count.value * 2)

  async function fetchCount() {
    count.value = await api.getCount() // 直接赋值,类型安全
  }

  return { count, double, fetchCount }
})

// 组件中
const counter = useCounterStore()
await counter.fetchCount()    // 函数调用,有类型,有补全
console.log(counter.double)   // 完整推导

少了一层 mutation 的间接性,代码量减少约 40%,类型安全从 0 到 100。


设计权衡:Pinia 真的全是优点?

不是。任何设计都有取舍。

放弃 mutation 的代价

Vuex 强制走 mutation 修改状态,DevTools 能精确记录每次变化的来源。Pinia 允许直接赋值修改状态,这意味着:

const store = useUserStore()

// 这两种写法都合法
store.token = 'abc'          // 直接改
store.$patch({ token: 'abc' }) // 批量改

// 在大型项目中,如果团队没有约定——
// "所有状态修改必须走 action"
// 那 store 的修改来源就会变得分散

Vuex 的 mutation 是强制约束,Pinia 的 action 是团队约定。约束靠框架,约定靠自觉。你觉得哪个更靠谱?这取决于你的团队。

单一 store vs 多 store

// Vuex:一个 store 统治一切
// 所有模块挂在一棵树上,跨模块访问虽然麻烦但至少有路径

// Pinia:每个 store 是独立的
// 好处:按需加载,互不干扰
// 问题:store 之间的依赖关系需要自己管理

// 当 store A 依赖 store B 时
export const useOrderStore = defineStore('order', () => {
  const userStore = useUserStore() // 直接在 store 里调另一个 store

  async function createOrder(item) {
    if (!userStore.isLoggedIn) {  // 跨 store 读状态
      throw new Error('请先登录')
    }
    // ...
  }

  return { createOrder }
})

// 这样写可以,但如果 A 依赖 B,B 又依赖 A → 循环依赖
// Pinia 不会帮你检测这个问题

性能对比

说实话,对于 99% 的项目,Vuex 和 Pinia 的性能差异可以忽略。都是基于 Vue 的响应式系统,底层都是 Proxy。

真正的性能差异来自使用方式

// ❌ 性能坑:在大列表中订阅整个 store
// 每次 store 任意属性变化,组件都会重新渲染
const store = useProductStore()

// ✅ 用 storeToRefs 只提取需要的属性
// 只有 list 变化时才触发渲染
const { list } = storeToRefs(useProductStore())

什么时候该用状态管理?什么时候不该?

这才是最重要的问题。我见过太多项目,用 Pinia 管理一个弹窗的开关状态。

适合放进 store 的

  • 用户登录态、权限信息(多个页面和组件都要读取)
  • 全局配置(主题、语言、布局模式)
  • 购物车(跨页面共享,且需要持久化)
  • WebSocket 推送的实时数据(一处接收,多处消费)

不适合放进 store 的

// ❌ 不该放 store 的状态
const isDropdownOpen = ref(false)  // 组件局部 UI 状态,用 ref 就行
const formData = ref({})           // 表单数据,属于当前页面
const searchResults = ref([])      // 服务端数据,用 TanStack Query 更合适

// ✅ 判断标准:问自己一个问题
// "这个页面销毁后,这个数据还有用吗?"
// 有用 → 可能需要 store
// 没用 → 大概率不需要

可扩展性思考:大型项目怎么组织 store?

当项目有 50+ 个页面,store 怎么组织才不会失控?

src/
  stores/
    modules/
      useAuthStore.ts      // 认证相关
      usePermissionStore.ts // 权限相关
      useAppConfigStore.ts  // 全局配置
      useNotificationStore.ts // 通知系统
    composables/
      useStoreReset.ts     // 统一重置逻辑
      useStorePersist.ts   // 统一持久化逻辑
    index.ts               // 统一导出

关键原则:

// 1. 一个 store 只管一个领域
// ❌ 不要搞一个 useGlobalStore 塞所有东西
// ✅ 按职责拆分:auth / permission / config / notification

// 2. store 之间的依赖要单向
// auth → permission ✅(权限依赖认证)
// permission → auth ❌(不要反向依赖)

// 3. 统一重置逻辑(登出时)
function resetAllStores() {
  useAuthStore().$reset()
  usePermissionStore().$reset()
  useNotificationStore().$reset()
  // 如果用 setup 语法,$reset 需要自己实现
}

持久化插件

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// 在 store 中声明需要持久化
export const useAuthStore = defineStore('auth', () => {
  const token = ref('')
  return { token }
}, {
  persist: {
    pick: ['token'], // 只持久化 token,不要把所有状态都塞进 localStorage
  }
})

边界与风险

踩坑一:SSR 环境下的单例问题

// ❌ 在 SSR 中,如果 store 是模块级单例
// 不同用户的请求会共享同一个 store 实例 → 数据串了

// ✅ Pinia 的解法:每个请求创建新的 pinia 实例
// Nuxt 3 已经自动处理了这个问题
// 但如果你自己搭 SSR 框架,一定要注意

踩坑二:store 在组件外使用

// ❌ 在路由守卫中直接调 useStore()
// 此时 pinia 可能还没挂载
router.beforeEach((to) => {
  const auth = useAuthStore() // 可能报错:getActivePinia was called with no active Pinia
})

// ✅ 把 pinia 实例传进去,或者在 app.use(pinia) 之后再使用
router.beforeEach((to) => {
  const auth = useAuthStore(pinia) // 显式传入 pinia 实例
})

踩坑三:响应式丢失

const store = useUserStore()

// ❌ 解构赋值 → 丢失响应式
const { token, userInfo } = store // token 变成普通字符串了

// ✅ 用 storeToRefs 保持响应式
const { token, userInfo } = storeToRefs(store)

// 注意:action(方法)不需要 storeToRefs,直接解构就行
const { login, logout } = store // 函数不需要响应式

技术升华:我们到底在管理什么?

回到标题的问题。从 Vuex 到 Pinia,API 在变,但底层模型从没变过:

状态管理 = 共享数据 + 变更控制 + 派生计算

这不是前端独有的问题。数据库有事务和视图,后端有 Event Sourcing 和 CQRS,分布式系统有一致性协议。本质上,任何多个消费者共享可变数据的场景,都需要某种形式的"状态管理"。

Vuex 选择了强约束路线:mutation 必须同步,修改必须显式 commit。这是 2016 年的正确选择——Vue 2 的 Options API 缺乏组合能力,严格的规则能防止混乱。

Pinia 选择了轻约束路线:利用 Composition API 的表达力,让 store 回归"就是一个函数"的本质。这是 2022 年的正确选择——TypeScript 普及了,开发者的工程素养提高了,框架可以少管一点。

工具在进化,但思维模型是通用的。 下次遇到状态管理的问题,别急着选库,先问自己三个问题:

  1. 这个状态需要共享吗?还是组件自己管就行?
  2. 状态的变更路径清晰吗?出了 bug 能追溯吗?
  3. 当前的方案,团队里最弱的那个人能理解吗?

第三个问题最重要。写到这里我开始怀疑人生——最好的状态管理方案,可能不是技术最优的那个,而是团队最容易达成共识的那个。