状态管理比图表渲染更考验架构——一个 session 被四个组件同时引用,改个字段要跨三个文件。对比了 Vuex 4、Pinia 和手写 composables 三条路之后,我们决定把状态层整体迁到 Pinia,因为它原生支持 TypeScript 推导、天然模块化,还能和 Composition API 无缝配合。这篇把我在这个项目里的完整实践记录下来——为什么选它、两种 store 写法的取舍、持久化怎么接、以及后来踩过的那些坑。
一、为什么用 Pinia 而不是 Vuex
刚开始我们没想换,Vuex 4 在项目里跑了两年,功能都能跑。真正促使我们下决心迁移的是两件事:一是评测页面要实时渲染流式输出,state 更新频率非常高,Vuex 每次改动都要走 mutation,样板代码占了大半,团队里新人看代码总是先找”哪里 dispatch 的”;二是 TypeScript 类型推导,Vuex 4 的 useStore() 拿到的类型默认是 any,配合 strict 模式要手写一堆类型包装,维护成本一天比一天高。后面又从体积、模块化、组合式 API 友好度几个角度做了详细对比。下面从 6 个维度把 Vuex 4 和 Pinia 摆在一起看。
| 维度 | Vuex 4 | Pinia |
|---|---|---|
| 体积 | 较大 | ~1KB |
| TS 支持 | 需手写类型包装 | 原生类型推导 |
| 模块化 | 手动注册 modules | 天然模块化 |
| Composition API 风格 | 不友好 | 完美融合 |
| mutations | 必须有 | 没有,直接改 state |
| 维护状态 | 仅维护 | 活跃开发 |
看完这张表,结论其实很直接:Pinia 在体积、类型推导和组合式 API 三个维度都是压倒性的优势,唯一要付出的成本就是迁移本身。我们当时的迁移决策分了三步走:
- 先挑一个低风险的设置类 store 做试点,跑通持久化和跨 store 调用;
- 再替换用户态和评测态这两个高频核心模块,验证流式输出场景下的性能;
- 最后处理老代码里残留的
mapState调用,统一收口。
试点到全量切换用了两周,中途没有回滚过。平台选 Pinia 的核心理由,说白了就是 TS 类型推导 + 与 Composition API 完美融合,这两点正好补上我们团队在 Vuex 时代最难受的地方。
二、Setup Store 风格(推荐)
迁移时我们面临的第一个选择是:Setup Store 还是 Options Store。团队大部分人是 Vue 2 加 Options API 出身,一开始天然倾向后者,觉得看着眼熟。我坚持先用 Setup Store 写用户态做试点,因为它的写法就是 setup 函数,ref 和 computed 一套下来,跟组件内的逻辑写法完全一致,团队不用记两套心智模型,新人也更容易上手。下面这段就是我们用户态的完整实现,登录、拉取资料、登出都收敛在一个 store 里。
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/api/client'
export const useUserStore = defineStore('user', () => {
// state
const userId = ref<number | null>(null)
const token = ref<string>('')
const profile = ref<UserProfile | null>(null)
// getters
const isLogin = computed(() => !!token.value)
const displayName = computed(() => profile.value?.nickname || '游客')
// actions
async function login(form: LoginForm) {
const res = await api.post<LoginResult>('/api/auth/login', form)
token.value = res.token
userId.value = res.userId
await fetchProfile()
}
async function fetchProfile() {
if (!userId.value) return
profile.value = await api.get<UserProfile>(`/api/user/${userId.value}`)
}
function logout() {
token.value = ''
userId.value = null
profile.value = null
}
return { userId, token, profile, isLogin, displayName,
login, fetchProfile, logout }
})
这段代码跑起来后,体感最明显的是删掉了一堆手写的类型定义:token 的类型由 ref<string> 自动推导,isLogin 的布尔值也不用单独声明接口。容易忽略的一点是返回对象里的 ref 不能展开,否则模板里的响应性会丢,这个坑后面第四节会专门讲。Setup Store 还有一个隐性好处,getter 依赖别的 state 时就是普通的 computed 链,调试时直接 console 打点看引用关系,比 Vuex 那套 getters['user/xxx'] 字符串路径清晰得多。
三、Options Store 风格(备选)
Options Store 我们也没有彻底放弃,主要留给从 Vuex 迁移过来的老模块做过渡,写法上几乎是无缝平移。
export const useUserStore = defineStore('user', {
state: () => ({
userId: null as number | null,
token: '',
profile: null as UserProfile | null
}),
getters: {
isLogin: state => !!state.token,
displayName: state => state.profile?.nickname || '游客'
},
actions: {
async login(form: LoginForm) { /* ... */ }
}
})
这套写法本质就是 Vuex 的 state/getters/actions 去掉 mutations,老同事上手零成本,把 mutations 块删掉、action 里直接赋值即可。但我们最终没有在核心模块使用它,原因是混用两种风格会让读代码的人反复切换心智模型,一个项目里既有函数式 store 又有对象式 store,新同事容易懵。所以我们的约定很明确:新代码统一 Setup Store,老代码按需迁移,过渡期不新增 Options Store。适合从 Vuex 迁移的项目;Setup Store 更现代,平台统一用前者。
四、状态订阅与持久化
评测平台的登录态和偏好设置必须持久化,不然用户刷新页面就得重新登录。这里我们踩过一个坑:最开始在组件里手动监听 state、再往 localStorage 里写,每个组件都这么干,代码重复而且容易漏写。后来发现 Pinia 本身提供了内建订阅能力,先看这段基础用法。
const userStore = useUserStore()
// 监听某个 state
userStore.$subscribe((mutation, state) => {
console.log('changed:', mutation.type, state.token)
localStorage.setItem('token', state.token)
})
$subscribe 的 mutation 参数会告诉你这次变更来自 action 还是直接赋值,排查”谁改了状态”时很有用。不过手写 localStorage 还是太原始,键名管理、序列化规则都得自己维护,所以我们引入了官方生态的持久化插件,在入口统一注册。
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// stores/settings.ts
export const useSettingsStore = defineStore('settings', () => {
const theme = ref<'light' | 'dark'>('light')
const sidebarCollapsed = ref(false)
return { theme, sidebarCollapsed }
}, {
persist: {
key: 'eval-settings',
storage: localStorage,
paths: ['theme', 'sidebarCollapsed']
}
})
插件的 persist 配置里 paths 白名单值得单独说:如果放任全量持久化,把带函数的 ref 也序列化进去,恢复的时候函数会丢。我们最初没配 paths,一次上线后用户主题集体恢复成默认,排查了半天才定位到是这个原因。另外 storage 可以用 sessionStorage 或自定义实现,多标签页同步可以配 storage: sessionStorage 再配合事件,这部分按场景取舍。
五、跨 store 调用
评测流程里创建 session 需要用户 ID,但 user store 和 eval store 是平级的。Vuex 时代要用 dispatch('user/xxx') 走全局命名空间,参数传递绕来绕去,类型还经常丢。Pinia 里直接 import 另一个 store 的实例方法就行,代码变得非常直白。
export const useEvalStore = defineStore('eval', () => {
const userStore = useUserStore()
async function createSession(form: EvalForm) {
const res = await api.post('/api/eval/session', {
...form,
userId: userStore.userId // 直接读其他 store
})
return res
}
return { createSession }
})
这样写的好处是类型跟着 import 走,userId 是什么类型一眼可见,编译器也能帮你兜底。不需要 Vuex 的 dispatch('user/xxx') 那种复杂调用。不过要提醒一点:在 store 的 setup 函数里调用 useUserStore(),相当于在 store 初始化时建立依赖,如果两个 store 互相引用会死循环,这个坑后面第十节会展开说。
六、与 Composition API 的协作
组件里最容易犯的错是直接解构 store。const { displayName } = userStore 看着没毛病,但 Pinia 的 state 是响应式对象,解构出来的是那一瞬间的快照值,模板里不会跟着状态变化刷新。正确的姿势是配合 storeToRefs 使用。
<template>
<div v-if="userStore.isLogin">
欢迎, {{ displayName }}
</div>
</template>
<!-- 模板逻辑段 (TS) -->
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { displayName } = storeToRefs(userStore) // 保持响应式
// 普通方法直接解构
const { login, logout } = userStore
<!-- 模板结束 -->
storeToRefs 会把 store 里的 ref 和 computed 解包成响应式引用,而 action 方法本来就是普通函数,直接解构反而没问题。不通过 storeToRefs 直接解构 state 会丢失响应性,这是初学者最常踩的坑,我们在代码 review 里强调过至少三次,每个新人几乎都要踩一遍才记住。
七、平台核心 stores
把平台最核心的两块状态单独拎出来看:评测和批量任务。评测 store 要处理流式输出的增量追加,批量 store 要轮询任务进度,两者场景不同,拆成两个 store 比混在一起好维护得多。
// stores/eval.ts - 评测状态
export const useEvalStore = defineStore('eval', () => {
const currentSession = ref<EvalSession | null>(null)
const items = ref<Record<number, EvalItem>>({})
const streamingItemId = ref<number | null>(null)
function startStream(itemId: number) {
streamingItemId.value = itemId
}
function appendChunk(itemId: number, chunk: string) {
const it = items.value[itemId]
if (it) it.output += chunk
}
function endStream() {
streamingItemId.value = null
}
return { currentSession, items, streamingItemId,
startStream, appendChunk, endStream }
})
// stores/batch.ts - 批量任务
export const useBatchStore = defineStore('batch', () => {
const batches = ref<EvalBatch[]>([])
const progressMap = ref<Record<number, BatchProgress>>({})
async function refresh(batchId: number) {
const p = await api.get<BatchProgress>(
`/api/batch/${batchId}/progress`)
progressMap.value[batchId] = p
}
return { batches, progressMap, refresh }
})
这里没有把 WebSocket 原始消息直接塞进 store,而是通过 appendChunk 这个 action 把增量 chunk 追加到已有输出上,保证流式渲染是增量更新而不是整段替换,卡顿明显少了很多。批量任务的进度轮询也收敛在 action 里,组件只调 refresh(),不用关心轮询逻辑放哪、什么时候停。
八、SSR / 模块联邦场景
我们的前端没有跑 SSR,但工程上用了模块联邦,子应用和主应用会同时 mount 在同一个页面里。如果 Pinia 实例是模块级单例,两个应用会互相污染对方的 state,登录态串来串去,排查起来非常痛苦。解决办法是每个应用入口单独创建实例。
// 每个请求创建一个 pinia 实例
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app, pinia }
}
把 createPinia() 放在 createApp 内部,每挂载一个应用就得到一个独立的实例,状态互不干扰。这个模式对 SSR 同样适用——每次请求都新建 pinia 实例,避免服务端共享状态导致用户数据串号。平台不直接 SSR,但用模块联邦(Module Federation)时也用同样模式避免状态污染。
九、性能优化
状态量上来之后,性能问题通常从三个地方冒出来:store 创建时机、大数组、嵌套对象。三个都处理掉,评测历史页面从卡顿恢复到流畅。
1. 按需加载 store
Pinia 的 store 是懒初始化的,第一次调用 useXxxStore() 才创建实例。
// 不要在 main.ts 全部 use
// 按需在组件里 use
const userStore = useUserStore() // 仅当组件需要时实例化
所以我们没有在 main.ts 里统一注册,按需在组件里 use,未访问的 store 完全不占内存,首屏体积也省了。
2. 大列表分片
历史评测记录动辄上万条,全量放进 store 会导致任何一次变更都触发整棵树的依赖更新。
export const useHistoryStore = defineStore('history', () => {
const all = ref<HistoryItem[]>([])
const paged = computed(() => all.value.slice(0, pageSize.value))
return { all, paged }
})
computed 里做 slice 只是展示层的切分,真正的数据还是来自接口分页,store 里只保留当前页。不要把全量历史放进 store,前端分页查询。早期原型里我们把全量数据塞进内存试过,三千条记录时页面就开始明显卡顿,后来全改成按页拉取才解决。
3. State 规范化
嵌套深的 state 更新时性能差,还会触发整条链路的响应,平台做了规范化(类似 Redux 思想)。
// 反例:嵌套更新要深拷贝
const state = { user: { profile: { name: 'X' } } }
state.user.profile.name = 'Y' // 触发整树响应
// 正例:扁平 + 索引
const state = {
usersById: { 1: { id: 1, name: 'Y' } },
profilesById: { 1: { id: 1, userId: 1, ... } }
}
扁平化加索引之后,更新单个用户只触发该条记录的依赖更新,其他数据不受牵连。这个思路跟 Redux 的 normalize 一致,只是 Pinia 不需要手写 reducer,直接改 ref 就行,实现成本低很多。
十、踩过的坑
这个列表里的每一条都是我们线上真实踩过的,写出来给后来的人省时间。有些是文档里提过但没重视,有些是文档里根本没有的。
- 解构丢失响应性:
const { name } = userStore后name不是 ref。改用storeToRefs(userStore)。 - Setup Store 改 state 要用 ref:直接
let count = 0不会触发响应。必须ref(0)。 - store 互相引用死循环:A store 用 B store,B store 又用 A store。延迟引用或合并 store。
- 持久化大对象:
persist全量 state 进 localStorage,单 store > 4MB 报错。配paths只持久化必要字段。 - HMR 不生效:早期没用
storeToRefs,改了 state 不刷新。改后正常。 - SSR 数据污染:服务端 store 共享导致用户数据串号。每个请求
createPinia()。
踩坑的共性是:大部分问题都出在”响应式引用”这一个概念上,理解了 ref 和普通变量、快照和引用的区别,一半以上的坑都能提前避开。
十一、调试
排查”这个值为什么变了”,光靠 console 打点效率太低,我们装了 Vue DevTools 的 Pinia 插件,状态问题定位速度快了一个量级。
- 状态树可视化;
- 时间旅行(Time Travel);
- 状态快照回放;
- 哪个 action 改了哪个 state 都能看到。
靠这套工具,我们曾经在一分钟内定位到一个跨 store 的意外改写:打开时间旅行面板,直接看是哪个 action 把 token 置空的,比断点调试快得多。排查”为什么这个值变了”极其方便。
常见问题(FAQ)
Q1:Pinia 能不能完全替代 Vuex?
能。Pinia 是 Vue 3 生态推荐,Vuex 5(曾经传要出)已经放弃,新项目直接 Pinia。
Q2:store 里能放业务逻辑吗?
能,但有限度。简单的数据处理放 store,复杂的领域逻辑放 composables(useXxx.ts)或 service 层。
Q3:Pinia 性能怎么样?
state 是 reactive 的,每个 ref 都有 Proxy 包装。state < 100 个字段时无感,> 1000 字段要做规范化拆分。