关键词:技术选型、迁移成本、遗留系统、审计合规、Composition API 团队适配
适用场景:知乎回答、团队 Wiki、架构评审材料、技术负责人复盘
被问"Vuex 和 Pinia 选哪个"时,别急着背"Pinia 官方推荐"。选型从来不是选最好的,是选最贴合当前工程约束的。下面直接给判断矩阵,每个观点配真实代码对比。
一、闭眼选 Pinia 的 4 种情况
1. Vue 3 从零起步的项目
官方立场已经锁死:新 Vue 3 应用直接用 Pinia,Vuex 4 不再加新特性。这时候坚持 Vuex 等于主动放弃 TS 推断和 HMR 体验。
举例:一个简单的计数器模块
Vuex 4 写法(新项目还要这么写就太累了):
// store/index.js - Vuex 4 import { createStore } from 'vuex' export default createStore({ state: { count: 0, history: [] }, mutations: { SET_COUNT(state, payload) { state.count = payload }, PUSH_HISTORY(state, payload) { state.history.push(payload) } }, actions: { increment({ commit, state }) { const newVal = state.count + 1 commit('SET_COUNT', newVal) commit('PUSH_HISTORY', `+1 → ${newVal}`) }, async fetchCount({ commit }) { const res = await axios.get('/api/count') commit('SET_COUNT', res.data) } }, getters: { doubleCount: (state) => state.count * 2 } })Pinia 写法(清爽到不需要注释):
// stores/counter.js - Pinia import { defineStore } from 'pinia' import axios from 'axios' export const useCounterStore = defineStore('counter', () => { const count = ref(0) const history = ref([]) function increment() { count.value++ history.value.push(`+1 → ${count.value}`) } async function fetchCount() { const res = await axios.get('/api/count') count.value = res.data } const doubleCount = computed(() => count.value * 2) return { count, history, increment, fetchCount, doubleCount } })关键差异:
Vuex:改一个 count 要经过 mutation → action 两层,异步还得单独处理
Pinia:直接写函数,同步异步一视同仁,代码量少 40%
2. TypeScript 覆盖率高的工程
Pinia 的类型是"定义即生效",Vuex 的 TS 是"配置即劝退"。
举例:用户信息 store
Vuex 4 + TS 的痛苦日常:
// store/user.ts - Vuex 4 interface UserState { name: string age: number role: 'admin' | 'editor' | 'viewer' } // 第一步:定义类型 type UserStore = { state: UserState getters: { userDisplay: (state: UserState) => string } mutations: { SET_USER: (state: UserState, payload: Partial<UserState>) => void } actions: { updateUser: (payload: Partial<UserState>) => Promise<void> } } // 第二步:手动声明 RootState declare module '@vue/runtime-core' { interface ComponentCustomProperties { $store: Store<{ user: UserState }> } } // 第三步:组件里还得断言 const store = useStore() const userName = (store.state as any).user?.name // 类型丢失!Pinia 的天然推断:
// stores/user.ts - Pinia import { defineStore } from 'pinia' interface UserState { name: string age: number role: 'admin' | 'editor' | 'viewer' } export const useUserStore = defineStore('user', { state: (): UserState => ({ name: '', age: 0, role: 'viewer' }), getters: { userDisplay(): string { return `${this.name} (${this.role})` // this 自动推断 } }, actions: { async updateUser(payload: Partial<UserState>) { await api.updateUser(payload) Object.assign(this, payload) // this.$state 也有完整类型 } } }) // 组件里直接用,无需任何额外声明 const store = useUserStore() console.log(store.name) // string ✓ console.log(store.role) // 'admin' | 'editor' | 'viewer' ✓ store.updateUser({ name: 'Tom' }) // 参数类型校验 ✓实际踩坑案例:某团队在 Vuex 项目中有一个user.permissions字段,由于 TS 类型定义不到位,上线后才发现permissions在某些分支下是undefined,导致页面白屏。同样的逻辑用 Pinia,state定义时就会强制你处理好初始值和可选类型。
3. 重度用 Composition API /<script setup>
Pinia 的useXStore()在 setup 里和ref/computed同构。
举例:一个购物车组件
Vuex 在<script setup>里的别扭写法:
<script setup> import { useStore } from 'vuex' import { computed } from 'vue' const store = useStore() // 读取状态 - 每次都要通过 store.state const cartItems = computed(() => store.state.cart.items) const totalPrice = computed(() => store.state.cart.total) // 触发动作 - 字符串魔法 function addItem(item) { store.dispatch('cart/addItem', item) } function removeItem(id) { store.commit('cart/REMOVE_ITEM', id) } </script>Pinia 的自然写法:
<script setup> import { useCartStore } from '@/stores/cart' import { storeToRefs } from 'pinia' const cartStore = useCartStore() // 解构出响应式数据 const { items, totalPrice } = storeToRefs(cartStore) // 方法直接调用 function addItem(item) { cartStore.addItem(item) } function removeItem(id) { cartStore.removeItem(id) } </script> <template> <!-- 直接用,不用加 store.state 前缀 --> <div v-for="item in items" :key="item.id"> {{ item.name }} - ¥{{ item.price }} </div> <p>总计:¥{{ totalPrice }}</p> <button @click="addItem(newItem)">加入购物车</button> </template>关键差异:
Vuex:
store.state.cart.items这种三级路径在模板里反复出现,且dispatch用字符串容易拼错Pinia:就像在用本地
ref,IDE 补全、重构、跳转定义全都支持
4. 中大型项目但要扁平分包
Pinia 的"一个文件一个 store"比 Vuex 的 namespaced modules 清爽太多。
举例:电商后台管理系统,涉及商品、订单、用户三个业务域
Vuex 的模块嵌套噩梦:
// store/index.js - Vuex 4 多模块 import { createStore } from 'vuex' import products from './modules/products' import orders from './modules/orders' import users from './modules/users' export default createStore({ modules: { products, // 内部可能还有子模块:products.list, products.detail orders, // 子模块:orders.list, orders.refund users // 子模块:users.profile, users.permissions } }) // 组件里调用时的路径地狱 store.dispatch('products/list/fetchProducts') store.dispatch('orders/refund/submitRefund') store.commit('users/profile/SET_NAME', 'Tom') // 跨模块引用:需要 rootState // orders/store.js 里要拿用户信息 actions: { submitOrder({ rootState }) { const userId = rootState.users.profile.id // ... } }Pinia 的扁平结构:
stores/ ├── productStore.ts // 商品相关 ├── orderStore.ts // 订单相关 └── userStore.ts // 用户相关// stores/productStore.ts - Pinia export const useProductStore = defineStore('product', () => { const list = ref<Product[]>([]) const detail = ref<ProductDetail | null>(null) async function fetchList() { /* ... */ } async function fetchDetail(id: string) { /* ... */ } return { list, detail, fetchList, fetchDetail } }) // stores/orderStore.ts - 跨 store 引用 import { useUserStore } from './userStore' export const useOrderStore = defineStore('order', () => { const userStore = useUserStore() async function submitOrder(orderData: Order) { // 直接拿用户信息,不用 rootState const userId = userStore.profile.id await api.submit({ ...orderData, userId }) } return { submitOrder } })实际效果:某中型电商项目从 Vuex 迁到 Pinia 后,store 文件数从 12 个(含嵌套)变成 8 个扁平文件,新人理解时间从 2 天缩短到半天。
二、继续用 Vuex 不丢人的 3 种情况
1. Vue 2 存量系统
举例:一个运行两年的 OA 系统
现有 Vuex store:
// store/modules/attendance.js - Vuex 3(Vue 2) export default { namespaced: true, state: { records: [], todayStatus: {} }, mutations: { SET_RECORDS(state, records) { state.records = records }, SET_TODAY_STATUS(state, status) { state.todayStatus = status } }, actions: { async fetchRecords({ commit }, date) { const data = await api.getAttendance(date) commit('SET_RECORDS', data) } } }如果要迁到 Pinia,需要:
安装
@vue/composition-api(Vue 2 的桥接包)安装 pinia,注意版本兼容性
逐个 store 重写,测试回归
组件里所有
mapState、mapActions替换
实际案例:某团队花了 3 天评估迁移,发现 200+ 个组件引用了 store,最终决定不迁。结论是:功能稳定、无性能瓶颈、无新增大需求,不动就是最优解。
2. 金融/审计类业务,强制 mutation 留痕
举例:资金交易系统,每次余额变动必须记录操作人、时间、IP
Vuex 的 mutation 天然适合做审计日志:
// store/modules/account.js - Vuex 4 export default { state: { balance: 10000, auditLog: [] }, mutations: { // 所有余额变动必须走这里 UPDATE_BALANCE(state, { amount, operator, ip, reason }) { state.auditLog.push({ before: state.balance, after: state.balance + amount, amount, operator, ip, reason, timestamp: Date.now() }) state.balance += amount } }, actions: { withdraw({ commit }, { amount, operator, ip }) { // 审核逻辑... commit('UPDATE_BALANCE', { amount: -amount, operator, ip, reason: '提现' }) } } }Pinia 要实现同样效果,只能靠约定:
// stores/account.ts - Pinia export const useAccountStore = defineStore('account', () => { const balance = ref(10000) const auditLog = ref<AuditEntry[]>([]) // 全靠自觉:每次改 balance 前手动记录日志 function updateBalance(amount: number, meta: AuditMeta) { auditLog.value.push({ before: balance.value, after: balance.value + amount, ...meta, timestamp: Date.now() }) balance.value += amount } function withdraw(amount: number, meta: AuditMeta) { updateBalance(-amount, { ...meta, reason: '提现' }) } return { balance, auditLog, withdraw } })关键差异:
Vuex:mutation 是唯一的修改入口,审计是强制性的
Pinia:可以绕过
updateBalance直接balance.value += x,审计失效
实际案例:某支付公司规定"所有资金变动必须经过 mutation 审计",他们选择继续用 Vuex,并且在 CI 阶段加了 lint 规则禁止直接修改 state。
3. 深度依赖 Vuex 插件生态
举例:一套基于 mutation 的埋点系统和权限中间件
Vuex 插件示例:
// plugins/tracker.js - Vuex 插件 export function createTrackerPlugin(options) { return (store) => { // 监听所有 mutation store.subscribe((mutation, state) => { analytics.track('state_change', { type: mutation.type, payload: mutation.payload, timestamp: Date.now() }) }) } } // plugins/permission.js - 权限拦截 export function createPermissionPlugin(allowedModules) { return (store) => { store.subscribeAction((action, state) => { if (!allowedModules.includes(action.type.split('/')[0])) { console.warn(`Blocked action: ${action.type}`) throw new Error('Permission denied') } }) } } // 使用 const store = createStore({ plugins: [ createTrackerPlugin(), createPermissionPlugin(['products', 'orders']) ] })Pinia 没有 mutation,上述插件机制全部失效。如果要迁移:
埋点:需要在每个 action 里手动调用 analytics
权限:需要在每个 store 的 action 开头加检查逻辑
实际案例:某 SaaS 平台有 6 个自定义 Vuex 插件(缓存、埋点、权限、错误上报、数据同步、撤销恢复),迁移成本估算超过 2 周,最终决定等下一个大版本重构时一并处理。
三、迁移值不值?看"边界清晰度"
中型 Vue 3 项目从 Vuex 迁 Pinia,典型节奏是 1–3 天:
以一个真实的"任务管理系统"为例,迁移前后对比:
迁移前(Vuex):
// store/modules/tasks.js export default { namespaced: true, state: { list: [], loading: false, filter: 'all' }, mutations: { SET_LIST(state, list) { state.list = list }, SET_LOADING(state, v) { state.loading = v }, SET_FILTER(state, f) { state.filter = f }, ADD_TASK(state, task) { state.list.push(task) }, REMOVE_TASK(state, id) { state.list = state.list.filter(t => t.id !== id) }, TOGGLE_COMPLETE(state, id) { const task = state.list.find(t => t.id === id) if (task) task.completed = !task.completed } }, actions: { async fetchTasks({ commit }) { commit('SET_LOADING', true) const data = await api.getTasks() commit('SET_LIST', data) commit('SET_LOADING', false) }, async addTask({ commit }, title) { const task = await api.createTask(title) commit('ADD_TASK', task) } }, getters: { filteredTasks(state) { if (state.filter === 'completed') return state.list.filter(t => t.completed) if (state.filter === 'active') return state.list.filter(t => !t.completed) return state.list }, pendingCount(state) { return state.list.filter(t => !t.completed).length } } }迁移后(Pinia):
// stores/tasks.ts export const useTaskStore = defineStore('tasks', () => { const list = ref<Task[]>([]) const loading = ref(false) const filter = ref<'all' | 'active' | 'completed'>('all') async function fetchTasks() { loading.value = true list.value = await api.getTasks() loading.value = false } async function addTask(title: string) { const task = await api.createTask(title) list.value.push(task) } function removeTask(id: string) { list.value = list.value.filter(t => t.id !== id) } function toggleComplete(id: string) { const task = list.value.find(t => t.id === id) if (task) task.completed = !task.completed } const filteredTasks = computed(() => { if (filter.value === 'completed') return list.value.filter(t => t.completed) if (filter.value === 'active') return list.value.filter(t => !t.completed) return list.value }) const pendingCount = computed(() => list.value.filter(t => !t.completed).length) return { list, loading, filter, fetchTasks, addTask, removeTask, toggleComplete, filteredTasks, pendingCount } })迁移信号绿不绿,看三点:
1. store 里 mutation 是不是大多只有一行赋值?
如果是,像上面这个例子,6 个 mutation 全是简单赋值,Pinia 直接省掉 60% 的样板代码。
2. 新人入职要不要讲一小时 Flux 概念?
某团队统计:新人理解 Vuex 的 mutations/actions 区分平均需要 2 天,而 Pinia 只需要 30 分钟。如果你的团队有持续的新人 onboarding 压力,Pinia 节省的时间非常可观。
3. TS 报错是不是长期靠any糊弄?
比如这个常见场景:
// Vuex 里常见的摆烂写法 const tasks = (store.state as any).tasks.list const user = (store.getters as any).userInfo换成 Pinia 后:
const taskStore = useTaskStore() const tasks = taskStore.list // Task[] ✓ const userStore = useUserStore() const userInfo = userStore.userInfo // UserInfo ✓实际案例:某团队迁移后,TS 相关 bug 减少了 70%,因为之前很多运行时错误其实是类型不对导致的。
四、一句话收尾
Vuex 是把状态关进 Flux 的笼子里;Pinia 是把笼子拆了,因为 Vue 3 的响应式本身就是笼子。
新项目别犹豫,Pinia;老 Vue 2 别折腾,Vuex;Vue 3 老项目看上面那张绿灯表再动手。
最后的决策清单:
你的情况 | 建议 |
|---|---|
新项目,Vue 3 + TS | ✅ 闭眼 Pinia |
新项目,Vue 3 + JS | ✅ 依然 Pinia,少写一半代码 |
老项目,Vue 2 | ❌ 不动,除非重构 |
老项目,Vue 3,mutation 全是赋值 | ✅ 迁,1-2天搞定 |
老项目,Vue 3,有复杂插件/审计需求 | ⚠️ 评估后再动,可能不值得 |
团队全是新手 | ✅ Pinia,降低学习成本 |
金融/合规要求严格 | ⚠️ Vuex 可能更适合,看审计粒度 |