ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

《新项目用 Pinia 还是守 Vuex?一份不废话的选型清单》

2026/8/15 23:50:36 拓冰建站 浏览量
《新项目用 Pinia 还是守 Vuex?一份不废话的选型清单》 关键词技术选型、迁移成本、遗留系统、审计合规、Composition API 团队适配适用场景知乎回答、团队 Wiki、架构评审材料、技术负责人复盘被问Vuex 和 Pinia 选哪个时别急着背Pinia 官方推荐。选型从来不是选最好的是选最贴合当前工程约束的。下面直接给判断矩阵每个观点配真实代码对比。一、闭眼选 Pinia 的 4 种情况1. Vue 3 从零起步的项目官方立场已经锁死新 Vue 3 应用直接用 PiniaVuex 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 是配置即劝退。举例用户信息 storeVuex 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: PartialUserState) void } actions: { updateUser: (payload: PartialUserState) Promisevoid } } // 第二步手动声明 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: PartialUserState) { 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导致页面白屏。同样的逻辑用 Piniastate定义时就会强制你处理好初始值和可选类型。3. 重度用 Composition API /script setupPinia 的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) } /scriptPinia 的自然写法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-foritem in items :keyitem.id {{ item.name }} - ¥{{ item.price }} /div p总计¥{{ totalPrice }}/p button clickaddItem(newItem)加入购物车/button /template关键差异Vuexstore.state.cart.items这种三级路径在模板里反复出现且dispatch用字符串容易拼错Pinia就像在用本地refIDE 补全、重构、跳转定义全都支持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 refProduct[]([]) const detail refProductDetail | 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 3Vue 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-apiVue 2 的桥接包安装 pinia注意版本兼容性逐个 store 重写测试回归组件里所有mapState、mapActions替换实际案例某团队花了 3 天评估迁移发现 200 个组件引用了 store最终决定不迁。结论是功能稳定、无性能瓶颈、无新增大需求不动就是最优解。2. 金融/审计类业务强制 mutation 留痕举例资金交易系统每次余额变动必须记录操作人、时间、IPVuex 的 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 refAuditEntry[]([]) // 全靠自觉每次改 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 } })关键差异Vuexmutation 是唯一的修改入口审计是强制性的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 refTask[]([]) const loading ref(false) const filter refall | 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 别折腾VuexVue 3 老项目看上面那张绿灯表再动手。最后的决策清单你的情况建议新项目Vue 3 TS✅ 闭眼 Pinia新项目Vue 3 JS✅ 依然 Pinia少写一半代码老项目Vue 2❌ 不动除非重构老项目Vue 3mutation 全是赋值✅ 迁1-2天搞定老项目Vue 3有复杂插件/审计需求⚠️ 评估后再动可能不值得团队全是新手✅ Pinia降低学习成本金融/合规要求严格⚠️ Vuex 可能更适合看审计粒度