为什么我在百万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 要写一堆类型声明,比如 Commit、Dispatch 的参数类型,稍微改个 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 例子就觉得自己会了。找个有异步请求和复杂交互的场景去练手,比如模拟一个完整的购物车或者用户权限流。遇到报错别慌,去翻翻源码里的类型定义,比你在网上搜那些碎片化的答案管用得多。技术这东西,手熟了才有底气。