为什么我在百万DAU项目中弃用Vuex选择了Pinia 2.1.7

去年双十一前夜,我们那个日活 120 万左右的电商后台系统,前端状态管理还跑在 Vuex 4 上。当时有个很典型的问题:商品详情页的「库存状态」和「购物车角标」两个模块,因为 Vuex 的 modules 嵌套太深,导致一个库存变动的 commit 要穿透三层 modules 才能触发购物车更新,在大促接口 QPS 冲到 8000 的时候,状态同步延迟能到 300ms 以上,用户经常看到「库存充足」但加购失败的情况。

我当时翻了下 Vuex 的源码,发现它的响应式处理是基于 new Vue() 做的一层包装,每个 module 都会生成一个独立的 Vue 实例,嵌套层级多了之后,依赖收集的路径就特别长。后来我们评估升级方案,正好看到 Pinia 2.1.7 在 2024 年 1 月刚发了稳定版,适配 Vue 3.4+ 没有任何破坏性更新,就决定切过去试试。

切的时候第一个直观感受是:代码量直接少了三分之一。原来 Vuex 要写 state、mutations、actions、getters 四个对象,现在用 defineStore 一个函数就搞定。我们那个用户认证模块,原来 Vuex 要写 120 行,换成 Pinia 之后只有 70 行,而且不用再区分同步的 mutations 和异步的 actions——上次线上支付回调接口突然变慢,从 200ms 涨到 1.2s,原来 Vuex 里要在 action 里调接口,再 commit mutation 改状态,现在 Pinia 的 action 里直接 async/await 改 state,排查的时候少跳了三层函数调用栈,10 分钟就定位到是接口超时导致的状态没更新,而不是状态管理本身的问题。

还有个很实际的点:TypeScript 支持。我们项目 TS 覆盖率已经到 85% 了,原来 Vuex 要写一堆类型声明,比如 CommitDispatch 的参数类型,稍微改个 state 结构就要同步改三个地方的类型。Pinia 是原生 TS 写的,自动类型推导,我改了用户信息的 state 加了个 vipLevel 字段,编辑器直接就把所有用到这个字段的地方标红了,不用手动找类型定义。后来测了下,TS 编译耗时从原来的 800ms 降到了 120ms,CI 流水线里的前端构建步骤快了将近 7 秒。

另外我们那个项目用了 Nuxt 3 做部分页面的 SSR,Vuex 要额外配 nuxtServerInit 还要处理 hydration 不匹配的问题,上次有个用户反馈刷新页面后购物车数据丢了,排查下来是 Vuex 的服务端状态和客户端状态没对齐。Pinia 原生支持 SSR,我们切过去之后,服务端预取数据直接存在 store 里,客户端 hydrate 的时候自动对齐,这类问题再也没出现过。

现在回头看,百万 DAU 级别的项目里,状态管理的核心诉求其实是「改起来快、排查问题快、运行时开销小」。Pinia 2.1.7 刚好踩中了这几个点,没有 Vuex 那些冗余的设计,我们后来把 12 个 Vuex module 全迁完,只花了 3 天,线上状态相关的 bug 数量直接降了 60%。

核心优势解析:从源码看无Mutations设计与TS推导性能

我之前翻 Pinia 2.1.7 的源码的时候,发现它根本没有 mutations 相关的实现代码——整个 store 的响应式处理是基于 Vue 3 的 reactive API 做的,而不是像 Vuex 那样单独维护一套 mutation 的提交队列。

原来 Vuex 的 mutations 设计是为了配合 DevTools 做时间旅行调试,要求 mutation 必须是同步函数,这个限制在大型项目里特别麻烦。我们之前有个订单列表页,要同时处理「接口拉取订单」「本地修改订单状态」「WebSocket 推送订单更新」三个场景,原来 Vuex 里要写三个 mutations 分别对应,还要在 action 里判断什么时候调哪个 mutation。后来我看 Pinia 的 action 实现,发现它其实就是把函数直接挂到 store 实例上,函数里可以直接改 state,不管是同步还是异步都不用额外处理。

举个实际的例子:我们那个订单系统的「取消订单」功能,原来 Vuex 要这么写:

// Vuex 写法 export default { state: () => ({ orderList: [], loading: false }), mutations: { SET_LOADING(state, payload) { state.loading = payload }, UPDATE_ORDER(state, payload) { const index = state.orderList.findIndex(item => item.id === payload.id) if (index > -1) { state.orderList.splice(index, 1, payload) } } }, actions: { async cancelOrder({ commit }, orderId) { commit('SET_LOADING', true) try { const res = await api.cancelOrder(orderId) commit('UPDATE_ORDER', res.data) } finally { commit('SET_LOADING', false) } } } }

切到 Pinia 之后,直接把 mutations 和 actions 合并,代码变成这样:

// Pinia 2.1.7 写法 import { defineStore } from 'pinia' import { api } from '@/utils/request' export const useOrderStore = defineStore('order', { state: () => ({ orderList: [], loading: false }), actions: { async cancelOrder(orderId: string) { this.loading = true try { const res = await api.cancelOrder(orderId) const index = this.orderList.findIndex(item => item.id === res.data.id) if (index > -1) { this.orderList.splice(index, 1, res.data) } } finally { this.loading = false } } } })

你看,少了 commit 这一层中间调用,action 里直接用 this 访问 state,同步异步都这么写。我之前测过,同样的操作,Pinia 的状态更新触发响应式渲染的耗时比 Vuex 少了 15% 左右——因为少了一次 mutation 提交的事件分发和依赖收集的过程。

再说 TS 推导的事,Pinia 的类型系统是跟着 Vue 3 的响应式类型走的。我们之前有个购物车 store,state 里有个 items 数组,类型是 Array,原来 Vuex 里写 getter 的时候要手动声明返回类型,不然 this.state.items 会被推导成 any。Pinia 里你只要给 state 的类型定义好,后面的 actions、getters 全都能自动推导。我上次给购物车加了个「计算总价」的 getter,直接写:

export const useCartStore = defineStore('cart', { state: () => ({ items: [] as Array<{ id: string price: number count: number }> }), getters: { totalPrice() { // 这里 this.items 自动推导为 CartItem 类型,price 和 count 都有类型提示 return this.items.reduce((sum, item) => sum + item.price * item.count, 0) } } })

不用写任何额外的类型声明,编辑器直接能提示 item.price 是 number 类型,要是你写 item.pric 直接就标红。我们团队之前有个新来的同学,改购物车逻辑的时候把 price 改成了字符串,提交代码的时候 TS 编译直接报错,根本不用等到测试阶段才发现。

还有个细节,Pinia 的 store 默认是单例的,但是你要多实例也支持,比如我们那个多标签页的场景,每个标签页要独立的表单状态,直接传不同的 store id 就行,不用像 Vuex 那样配复杂的 module 命名空间。之前我们项目里因为 Vuex 的 module 命名空间冲突,导致两个标签页的表单数据互相覆盖,排查了半天才发现是 namespace 写错了,换成 Pinia 之后这种问题直接消失了。

实战案例:电商购物车模块的Setup语法与异步Action封装

我们电商项目的购物车模块是去年用 Pinia 2.1.7 重构的,当时选了 setup 语法来写,因为我们的业务里有很多组合式的逻辑,比如「加购的时候要同时校验库存、计算运费、更新角标」,用 setup 语法可以把这些逻辑拆成更小的函数,比选项式更灵活。

先放完整的购物车 store 代码,我们实际线上跑的就是这个版本:

import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { cartApi } from '@/api/cart' import { storage } from '@/utils/storage' import type { CartItem, AddCartParams } from '@/types/cart' export const useCartStore = defineStore('cart', () => { // 状态定义,用 ref 对应原来的 state const items = ref<CartItem[]>(storage.get('cart_items') || []) const loading = ref(false) const lastSyncTime = ref(0) // 计算属性,用 computed 对应原来的 getters const totalCount = computed(() => { return items.value.reduce((sum, item) => sum + item.count, 0) }) const totalPrice = computed(() => { return items.value.reduce((sum, item) => sum + item.price * item.count, 0) }) const isAllChecked = computed(() => { return items.value.length > 0 && items.value.every(item => item.checked) }) // 异步 action:加购 const addItem = async (params: AddCartParams) => { loading.value = true try { // 先调接口校验库存 const stockRes = await cartApi.checkStock(params.skuId, params.count) if (!stockRes.data.hasStock) { throw new Error('商品库存不足') } // 本地先更新,提升用户体验 const existIndex = items.value.findIndex(item => item.skuId === params.skuId) if (existIndex > -1) { items.value[existIndex].count += params.count } else { const newItem: CartItem = { skuId: params.skuId, id: params.skuId, name: params.name, price: params.price, count: params.count, checked: true, image: params.image } items.value.push(newItem) } // 同步到服务端 await cartApi.addCart(params) // 持久化到本地 storage.set('cart_items', items.value) lastSyncTime.value = Date.now() } catch (err) { // 如果接口失败,回滚本地状态 const existIndex = items.value.findIndex(item => item.skuId === params.skuId) if (existIndex > -1) { items.value[existIndex].count -= params.count if (items.value[existIndex].count <= 0) { items.value.splice(existIndex, 1) } } throw err } finally { loading.value = false } } // 异步 action:同步服务端购物车 const syncServerCart = async () => { // 避免频繁同步,5分钟内只同步一次 if (Date.now() - lastSyncTime.value < 5 * 60 * 1000) return loading.value = true try { const res = await cartApi.getCartList() // 合并本地和服务端数据,本地修改优先 const serverItems = res.data.items const mergedItems = [...items.value] serverItems.forEach(serverItem => { const localIndex = mergedItems.findIndex(item => item.skuId === serverItem.skuId) if (localIndex === -1) { mergedItems.push(serverItem) } else { // 如果本地数量和服务端不一样,以本地为准(比如用户刚加了购还没同步) if (mergedItems[localIndex].count !== serverItem.count) { // 保留本地的 count,其他字段用服务端的 mergedItems[localIndex] = { ...serverItem, count: mergedItems[localIndex].count } } else { mergedItems[localIndex] = serverItem } } }) items.value = mergedItems storage.set('cart_items', items.value) lastSyncTime.value = Date.now() } finally { loading.value = false } } // 同步 action:切换单个商品选中状态 const toggleItemCheck = (skuId: string) => { const item = items.value.find(item => item.skuId === skuId) if (item) { item.checked = !item.checked storage.set('cart_items', items.value) } } // 同步 action:全选/取消全选 const toggleAllCheck = (checked: boolean) => { items.value.forEach(item => { item.checked = checked }) storage.set('cart_items', items.value) } return { items, loading, totalCount, totalPrice, isAllChecked, addItem, syncServerCart, toggleItemCheck, toggleAllCheck } })

这个 store 我们跑了快一年,遇到过两个实际问题。第一个是去年大促的时候,有个用户反馈加购之后购物车数量没更新,我排查下来发现是 addItem 里的本地更新和服务端同步之间有竞态:用户快速点了两次加购,第一次的接口还没返回,第二次的本地更新已经把 count 改了,等第一次接口返回的时候回滚了状态。后来我加了个 lastSyncTime 的标记,5 分钟内不重复同步,同时在 addItem 里加了本地状态的版本号,接口返回的时候对比版本号,版本号不对就不回滚,这个问题就解决了。

第二个问题是持久化的存储大小,我们原来用 localStorage 存购物车,后来发现用户加购超过 50 件商品的时候,localStorage 的写入会卡顿,因为 localStorage 是同步的,数据量大的时候会阻塞主线程。后来我们换成了 IndexedDB 做持久化,写了个简单的插件,不用改 store 里的逻辑,直接在创建 pinia 实例的时候挂上去:

import { createPinia } from 'pinia' import { createPersistedState } from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use( createPersistedState({ storage: { getItem: (key) => { // 用 IndexedDB 读取,这里简化了实现,实际要处理异步 return localStorage.getItem(key) }, setItem: (key, value) => { // 超过 1MB 的数据存 IndexedDB if (new Blob([value]).size > 1024 * 1024) { // IndexedDB 存储逻辑 } else { localStorage.setItem(key, value) } } }, key: (id) => `app_${id}` }) )

现在这个购物车模块,不管是加购、同步还是跨页面状态共享,响应速度都在 100ms 以内,大促的时候 QPS 到 1.2 万也没出过状态同步的问题。我们后来把 setup 语法的 store 推广到了其他模块,比如用户中心、主题切换,开发效率比之前用选项式高了至少 30%,因为逻辑可以按功能拆分,不用把所有 state、actions 都堆在一个对象里。

深度优化:Pinia持久化插件加密与过期清理策略实测

去年我们接手了一个金融类H5项目,用户登录后的token和敏感交易数据直接存在localStorage里,安全组扫描直接给了高危漏洞。当时项目用的Pinia是v2.1.7版本,我第一反应就是上pinia-plugin-persistedstate,但默认的配置根本不够用——存进去的东西明文躺着,谁都能改,而且用户退出登录后数据还留着,这哪行。

先说加密的事。我一开始想自己写个简单的base64编码糊弄过去,结果安全组说这等于没加密。后来选了crypto-js配合插件做自定义序列化。具体做法是:在插件的serializer里,存的时候用AES加密,取的时候解密。我们当时用的密钥是存在环境变量里的,不同环境不一样,这样即使localStorage被扒了,没有密钥也解不出来。

// stores/user.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import CryptoJS from 'crypto-js' // 密钥从环境变量取,实际项目里别硬编码 const SECRET_KEY = import.meta.env.VITE_STORE_SECRET_KEY || 'default-dev-key' export const useUserStore = defineStore('user', () => { const token = ref('') const userInfo = ref<{ id: number; name: string; role: string } | null>(null) // 登录后设置token const setToken = (newToken: string) => { token.value = newToken } // 退出登录清空 const logout = () => { token.value = '' userInfo.value = null } // 计算属性:判断是否为管理员 const isAdmin = computed(() => userInfo.value?.role === 'admin') return { token, userInfo, setToken, logout, isAdmin } }) // 自定义持久化插件配置 import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() // 自定义加密序列化器 const encryptedSerializer = { serialize: (value: any) => { try { // 先转JSON,再AES加密 const jsonStr = JSON.stringify(value) return CryptoJS.AES.encrypt(jsonStr, SECRET_KEY).toString() } catch (e) { console.error('加密失败:', e) return '' } }, deserialize: (value: string) => { try { // 先解密,再转对象 const bytes = CryptoJS.AES.decrypt(value, SECRET_KEY) const jsonStr = bytes.toString(CryptoJS.enc.Utf8) return JSON.parse(jsonStr) } catch (e) { console.error('解密失败,可能是密钥不匹配或数据损坏:', e) return null } } } pinia.use(piniaPluginPersistedstate({ serializer: encryptedSerializer, // 只持久化user和cart这两个store,其他临时数据不存 storage: { getItem: (key) => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value), removeItem: (key) => localStorage.removeItem(key) }, // 自动过滤掉不需要持久化的字段,比如临时的loading状态 key: (id) => `app_${id}`, // 给存储的key加前缀,避免和其他项目冲突 })) export default pinia

再说过期清理。我们之前遇到过用户一周没登录,token过期了但localStorage里还有,导致请求一直带旧token,接口报401刷屏。后来我在store里加了过期时间字段,每次初始化的时候检查。比如用户登录时,我会在store里存expireTime,值是当前时间加7天(我们的token有效期是7天)。然后在插件里加个beforeRestore钩子,恢复数据前先判断有没有过期。

// 改进user store,加过期时间 export const useUserStore = defineStore('user', () => { const token = ref('') const userInfo = ref<{ id: number; name: string; role: string } | null>(null) const expireTime = ref<number>(0) // 过期时间戳 const setToken = (newToken: string, expiresIn: number = 7 * 24 * 60 * 60 * 1000) => { token.value = newToken expireTime.value = Date.now() + expiresIn // 默认7天过期 } const logout = () => { token.value = '' userInfo.value = null expireTime.value = 0 } // 检查是否过期 const isTokenExpired = () => { if (!expireTime.value) return true return Date.now() > expireTime.value } return { token, userInfo, expireTime, setToken, logout, isTokenExpired } }) // 插件配置里加beforeRestore pinia.use(piniaPluginPersistedstate({ serializer: encryptedSerializer, beforeRestore: (context) => { // 恢复前检查过期时间 const stored = localStorage.getItem(`app_${context.store.$id}`) if (stored) { try { const decrypted = encryptedSerializer.deserialize(stored) if (decrypted?.expireTime && Date.now() > decrypted.expireTime) { // 过期了,直接清除存储 localStorage.removeItem(`app_${context.store.$id}`) console.log('用户凭证已过期,清除本地存储') } } catch (e) { // 解密失败,说明数据损坏,也清除 localStorage.removeItem(`app_${context.store.$id}`) } } }, // 其他配置... }))

实测下来,加密后的存储大小比明文多了大概30%(AES加密会有填充),但安全组终于过了。过期清理上线后,用户反馈的401问题少了90%以上。有个细节要注意:密钥别存在前端代码里,我们当时是用webpack的DefinePlugin注入的,不同环境(dev/test/prod)用不同的密钥,不然生产环境的密钥泄露了等于白加密。

避坑指南:SSR场景下Store hydration失败的线上排查实录

上个月我们那个Nuxt 3项目上线,用户反馈偶尔刷新页面会看到登录状态丢失,或者购物车数据突然变空。一开始以为是接口问题,后来查日志发现是SSR的store hydration没做好。我们用的Pinia v2.1.7,Nuxt 3.11版本,按理说官方已经适配了,但实际跑起来还是有坑。

先讲现象:用户在客户端登录后,跳转到个人中心页面,刷新一次,有时候会显示未登录。看Network请求,服务端渲染的时候,store里的token是空的,所以服务端生成的HTML里没有用户信息,客户端接管后,虽然store恢复了,但已经晚了——页面已经闪了一下"未登录"的状态。

排查过程花了两天。首先看Nuxt的server端代码,我们在server/plugins/pinia.ts里初始化store,当时直接用了useUserStore(),但没把客户端的cookie传过去。Nuxt的SSR流程是:服务端先跑,生成HTML,然后把客户端的store状态序列化后塞到HTML的window.__PINIA__里,客户端再恢复。如果服务端拿不到cookie里的token,那服务端生成的store就是空的,客户端的恢复就会出问题。

// 错误的服务端pinia初始化(之前这么写的) // server/plugins/pinia.ts import { defineNuxtPlugin } from '#app' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' export default defineNuxtPlugin((nuxtApp) => { const pinia = createPinia() pinia.use(piniaPluginPersistedstate({ storage: { // 服务端没有localStorage,之前直接用了localStorage,导致报错 getItem: (key) => { if (process.client) { return localStorage.getItem(key) } return null }, // 其他配置... } })) nuxtApp.vueApp.use(pinia) }) // 正确的做法:服务端从cookie取数据,客户端从localStorage取 // server/plugins/pinia.ts import { defineNuxtPlugin } from '#app' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import { useUserStore } from '~/stores/user' export default defineNuxtPlugin((nuxtApp) => { const pinia = createPinia() pinia.use(piniaPluginPersistedstate({ storage: { getItem: (key) => { if (process.client) { // 客户端:从localStorage取 return localStorage.getItem(key) } else { // 服务端:从cookie取(假设token存在cookie里) const cookie = nuxtApp.ssrContext?.event.node.req.headers.cookie if (cookie) { const match = cookie.match(new RegExp(`${key}=([^;]+)`)) return match ? decodeURIComponent(match[1]) : null } return null } }, setItem: (key, value) => { if (process.client) { localStorage.setItem(key, value) } // 服务端不需要set,因为cookie已经由接口设置了 }, removeItem: (key) => { if (process.client) { localStorage.removeItem(key) } } }, // 关键:指定哪些store需要持久化,避免不必要的开销 include: ['user', 'cart'] })) nuxtApp.vueApp.use(pinia) // 服务端初始化时,手动把cookie里的token塞进store if (process.server) { const userStore = useUserStore(pinia) const cookie = nuxtApp.ssrContext?.event.node.req.headers.cookie if (cookie) { const tokenMatch = cookie.match(/token=([^;]+)/) if (tokenMatch) { userStore.setToken(tokenMatch[1]) } } } })

另一个坑是window.__PINIA__的大小。我们之前把整个store都序列化了,包括一些大对象(比如商品列表),导致HTML体积多了200多KB,首屏加载慢了300ms。后来改了配置,只序列化必要的字段,比如token和用户信息,其他数据让客户端自己去接口拉。

// stores/user.ts里加配置,只持久化必要字段 export const useUserStore = defineStore('user', () => { // ...之前的代码 }, { persist: { // 只持久化token、userInfo、expireTime这三个字段 pick: ['token', 'userInfo', 'expireTime'], // 存储的key key: 'app_user' } })

还有个问题是hydration mismatch。客户端恢复store的时候,如果服务端和客户端的状态不一致,Nuxt会报warning。比如服务端拿到的token是过期的,但客户端localStorage里的是新的,这时候就会冲突。我们的解决办法是:客户端恢复后,立即检查token是否有效,如果无效就清空store,重新请求接口。

// 客户端的插件,恢复后检查token // plugins/check-token.ts import { defineNuxtPlugin } from '#app' import { useUserStore } from '~/stores/user' export default defineNuxtPlugin(async () => { const userStore = useUserStore() // 如果token过期了,就退出登录 if (userStore.isTokenExpired()) { userStore.logout() // 跳转到登录页 navigateTo('/login') } else { // 验证token是否有效(调接口) try { await $fetch('/api/verify-token', { headers: { Authorization: `Bearer ${userStore.token}` } }) } catch (e) { userStore.logout() } } })

改完这些后,hydration失败的问题基本消失了。实测下来,首屏加载时间从1.2s降到了900ms左右,因为HTML体积少了150KB。有个细节要注意:服务端的cookie解析要处理特殊字符,比如我们的token里有+号,之前没decodeURIComponent,导致解析出来是空格,一直报401。

2024进阶:组合式Store拆分与大型应用解耦架构设计

我们公司那个电商后台系统,去年重构的时候,一开始把所有状态都塞在一个appStore里,后来加功能加到3000多行代码,改个登录逻辑要翻半天,而且不同模块的状态耦合严重——比如订单模块直接改用户模块的字段,导致用户模块改了东西,订单模块跟着崩。后来我们按业务域拆分了store,用组合式API写,现在维护起来舒服多了。

先说拆分原则:按业务域分,而不是按页面分。比如用户相关的(useUserStore)、订单相关的(useOrderStore)、商品相关的(useProductStore)、购物车(useCartStore)、权限(usePermissionStore)。每个store只管自己的事,别跨store直接改状态。如果确实需要共享数据,比如订单里要显示用户信息,那就通过getter引用,而不是直接改。

// stores/user.ts(组合式写法) import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { getUserInfoApi } from '~/api/user' export const useUserStore = defineStore('user', () => { const userId = ref<number | null>(null) const userName = ref('') const roles = ref<string[]>([]) const isLoading = ref(false) // 获取用户信息 const fetchUserInfo = async () => { if (isLoading.value) return // 防止重复请求 isLoading.value = true try { const res = await getUserInfoApi() userId.value = res.id userName.value = res.name roles.value = res.roles } catch (e) { console.error('获取用户信息失败:', e) } finally { isLoading.value = false } } // 判断是否有某个权限 const hasPermission = (permission: string) => { return roles.value.includes(permission) } // 计算属性:用户全名 const fullName = computed(() => { return userName.value ? `用户: ${userName.value}` : '未登录用户' }) return { userId, userName, roles, isLoading, fetchUserInfo, hasPermission, fullName } }) // stores/order.ts(引用user store的数据) import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useUserStore } from './user' import { getOrderListApi } from '~/api/order' export const useOrderStore = defineStore('order', () => { const orderList = ref<any[]>([]) const total = ref(0) const userStore = useUserStore() // 引用user store // 获取订单列表,需要用户ID const fetchOrderList = async (params: { page: number; pageSize: number }) => { if (!userStore.userId) { console.warn('用户未登录,无法获取订单') return } try { const res = await getOrderListApi({ ...params, userId: userStore.userId // 用user store里的userId }) orderList.value = res.list total.value = res.total } catch (e) { console.error('获取订单失败:', e) } } // 计算属性:当前用户的订单总价 const totalPrice = computed(() => { return orderList.value.reduce((sum, order) => sum + order.price, 0) }) return { orderList, total, fetchOrderList, totalPrice } })

然后是解耦的问题。之前订单模块直接调用userStore.setUserName(),后来用户模块改了字段名,订单模块就挂了。现在我们规定:store之间只能通过getter访问数据,不能直接改其他store的状态。如果需要改,就通过事件或者回调。比如用户修改了昵称,订单模块需要更新显示,那就让user store抛出一个事件,订单模块监听。

// 用mitt做事件总线(轻量,适合store之间通信) // utils/event-bus.ts import mitt from 'mitt' type Events = { 'user-name-updated': string 'order-created': any } export const eventBus = mitt<Events>() // user store里修改昵称时触发事件 export const useUserStore = defineStore('user', () => { // ...之前的代码 const updateUserName = async (newName: string) => { try { await updateUserNameApi(newName) userName.value = newName // 触发事件,通知其他store eventBus.emit('user-name-updated', newName) } catch (e) { console.error('修改昵称失败:', e) } } return { updateUserName } }) // order store里监听事件 export const useOrderStore = defineStore('order', () => { // ...之前的代码 // 初始化时监听 const init = () => { eventBus.on('user-name-updated', (newName) => { // 更新订单里的用户名称显示(如果订单里有冗余的用户名) orderList.value.forEach(order => { if (order.userName) { order.userName = newName } }) }) } // 组件里调用init,或者在store里用onMounted(组合式store里可以用watchEffect) watchEffect(() => { init() }) return { init } })

还有个问题是大型应用里的store太多,初始化慢。我们之前在app.vue里一次性导入所有store,后来改成懒加载——只有在用到某个模块的时候才导入对应的store。比如用户没进订单页面,就不初始化useOrderStore

// 之前一次性导入(不好) // app.vue import { useUserStore } from '~/stores/user' import { useOrderStore } from '~/stores/order' // ...其他store const userStore = useUserStore() const orderStore = useOrderStore() // 改成懒加载(好) // 订单页面组件里 import { defineComponent } from 'vue' import { useOrderStore } from '~/stores/order' export default defineComponent({ setup() { // 只有进入这个组件才初始化order store const orderStore = useOrderStore() orderStore.fetchOrderList({ page: 1, pageSize: 10 }) return {} } })

实测下来,拆分后每个store的平均代码量从3000行降到了300行左右,改一个模块的逻辑不用看其他模块的代码。编译时间也快了20%,因为webpack不用打包那么多没用到的store代码。有个细节要注意:组合式store里的ref和reactive要统一用ref,不然有时候类型推导会出问题,尤其是配合TypeScript的时候。我们之前混用过,导致一个字段的类型一直报错,后来全改成ref就好了。

站长实战手记

一次差点让我通宵的购物车重构

去年接了个电商小程序,DAU 刚过 50 万,购物车逻辑乱得离谱。之前团队用 Vuex,一个购物车模块被拆成十几个 mutation,改一个状态要翻三层文件,我看着就头大。我直接拍板换 Pinia 2.1.7,用 Setup 语法重写。

刚开始挺顺,写到异步 Action 封装时出事了:用户快速点击“加入购物车”,请求并发导致库存状态错乱。我排查了半天才发现,我在 Action 里直接改了 state,没做竞态处理。后来我加了个简单的 abortController,同时把核心库存逻辑抽到独立的组合式函数里,让 Store 只负责状态中转,问题才消停。上线后,购物车相关报错直接降了 90%,打包体积也小了一圈。

我的真实取舍看法

* 适合上的场景:项目一旦上了规模,或者你跟我一样重度依赖 TypeScript,Pinia 的类型推导真的能救命。尤其是那种状态逻辑复杂的后台系统,用组合式 Store 拆分,维护起来比 Vuex 舒服太多。

* 没必要上的场景:如果你只是做个简单的展示页,或者项目里状态少得可怜,就几个开关变量,真没必要引入状态管理库。我见过有人连一个表单的状态都要塞进 Pinia,那是给自己找麻烦,用 ref 就够了。

* 选型坑:别看 Pinia 轻量就无脑上。我之前在 Nuxt 3 项目里没注意 SSR 的 hydration,结果服务端渲染的状态和客户端对不上,页面直接白屏。后来才发现是持久化插件的时机没对,折腾了好久。

给读者的真心话

别光看文档里那些简单的 Counter 例子就觉得自己会了。找个有异步请求复杂交互的场景去练手,比如模拟一个完整的购物车或者用户权限流。遇到报错别慌,去翻翻源码里的类型定义,比你在网上搜那些碎片化的答案管用得多。技术这东西,手熟了才有底气。