Vuex 与 Pinia 的区别
Vuex 里改状态要先走 mutation,异步再套一层 action。Pinia 把这两层收成 store 上的方法,TypeScript 也不用再为每个模块手写一堆类型。迁的时候容易漏掉的是插件和严格模式那些隐式行为。
1. mutation 去留带来的差异
最显眼的是 mutation 的消失。Vuex 里「必须走 mutation 才能改 state」这条规则,最初的目的是让 DevTools 能追踪每一次变更——早期的 Vuex 靠 mutation 的同步约束实现时间旅行调试,强制所有「同步修改」集中在一处。代价是每次改 state 都要先定义 mutation、再定义 action 去 commit 它,样板明显增多。
Pinia 允许在 action 里直接改 state,追踪靠响应式系统与插件同样能做到,于是这层样板就成了纯负担。但代价也现实:失去了「所有变更集中在一处」的强约束——同步修改和异步修改现在混在同一个 action 里,团队要靠约定而不是机制来保证可追踪。
// Vuex:state / mutations / actions 四件套,mutation 必须同步
const vuexStore = createStore({
state: () => ({ count: 0, user: null }),
mutations: {
INCREMENT(state) { state.count++ },
SET_USER(state, user) { state.user = user },
},
actions: {
async login({ commit }, cred) {
const user = await api.login(cred)
commit('SET_USER', user) // 必须先定义 mutation 再 commit
},
},
})
// Pinia:state / getters / actions,action 直接改 state
const useAuthStore = defineStore('auth', {
state: () => ({ count: 0, user: null }),
actions: {
increment() { this.count++ },
async login(cred) {
this.user = await api.login(cred) // 直接赋值,无需 commit
},
},
})
两者行为对照:
| 维度 | Vuex | Pinia |
|---|---|---|
| 改 state 的方式 | 只能走 mutation | 在 action 里直接改 |
| 异步操作 | 在 action 中,commit mutation | 在 action 中直接改 |
| TypeScript 支持 | 需大量泛型标注 | 基本开箱推断 |
| 模块组织 | modules + namespace | 每个 store 独立 |
| 组合式 API | 需手动桥接 | 原生契合 |
2. TypeScript 体验的差距
这是 Pinia 真正的优势所在。Vuex 要写出能推导的 store 需要大量泛型标注——store 自身的类型、rootState、rootGetters、以及 dispatch 的 payload 类型都得补,稍不注意就退化成 any,编辑器里点不出字段。Pinia 的 state、getters、actions 从定义里自然推导出来,在组合式写法下几乎不用写类型。
// Pinia 组合式写法:count / user 的类型由 state 推断,this 上直接可用
const useCartStore = defineStore('cart', () => {
const items = ref<Product[]>([])
const total = computed(() => items.value.reduce((s, p) => s + p.price, 0))
function add(p: Product) { items.value.push(p) }
return { items, total, add }
})
// 使用处:const cart = useCartStore(); cart.add(...) 的类型完整
2.1 选型时这一条往往更重
对于 TS 项目,这一条往往比 API 差异更能决定选型——一个每次改状态都要和类型报错搏斗的库,长期维护成本是实打实的。
3. 模块组织方式对比
Vuex 靠 modules + namespaced: true 组织,跨模块访问要带命名空间前缀,或用 root: true 选项去访问根;嵌套越深,调用路径越长。Pinia 每个 store 是一个独立的 defineStore(id),天然扁平,跨 store 直接 import 另一个 store 的函数进来用,不需要 namespace 概念,也没有「要不要开命名空间」这道选择题。
3.1 从 Vuex 迁到 Pinia
迁移不必一步到位,按模块逐个替换最稳:先迁独立的小 store,最后动根 store。Vuex 的 mapState / mapActions 在组合式写法里换成 storeToRefs 取状态 + 直接调用 action。
// Vuex 的 mapActions 写法
// methods: { ...mapActions(['login']) }
// Pinia 对应写法
const auth = useAuthStore()
const { user } = storeToRefs(auth) // 状态用 storeToRefs 保持响应式
auth.login(cred) // action 直接调用
持久化插件也要替换:Vuex 常用的持久化中间件,对应到 Pinia 是一个 plugin(在 store 创建时订阅 $subscribe 写入存储)。过渡期两个可以共存——app.use(store) 和 app.use(pinia) 同时挂上,老模块继续用 Vuex,新模块用 Pinia——但不要双向写同一份状态,否则两边会互相覆盖。
3.2 时间旅行还在
有一处常被误解:Vuex 的时间旅行靠 mutation 的同步约束实现,Pinia 去掉了 mutation,但并没有失去时间旅行——DevTools 依然能按 action 追踪与回放,只是不再强制所有同步修改走同一个入口:
// 需要回溯能力时,得自己订阅变更并把快照存下来
const history = []
store.$subscribe((mutation, state) => {
history.push({ mutation, snapshot: JSON.parse(JSON.stringify(state)) })
}, { detached: true })
4. 迁移时容易漏的
Pinia 的 store 是单例,SSR 场景要额外小心。Node 服务同时处理多个请求时,如果所有请求共享同一个 pinia 实例,一个请求写入的 user 会被下一个请求读到,造成跨请求污染。正确做法是每个请求新建一个 pinia 实例,随请求销毁。
// SSR:每个请求独立创建 pinia,避免跨请求污染
export async function setupApp(ctx: RequestContext) {
const pinia = createPinia()
const app = createApp(Root)
app.use(pinia)
// 后续通过 pinia 注入请求级数据,请求结束随实例回收
return { app, pinia }
}
另外,setup store 写法里每次调用 useStore 在同一组件树内返回同一实例;若脱离组件上下文(例如在路由守卫或纯函数里)调用,要先确保 pinia 已激活——可用 app.runWithContext(() => { /* 在此调用 useStore */ }) 临时提供上下文。最后,不要把非序列化数据塞进 state——DOM 节点、组件实例、函数引用既不利于持久化,也会让 DevTools 在序列化时出错。
Pinia 的定位与迁移建议直接看 Pinia 官方文档,它对「为什么不再需要 mutation」有正面回应。