Pinia 状态管理使用方法(AI 大模型评测平台的状态方案)

状态管理比图表渲染更考验架构——一个 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 三个维度都是压倒性的优势,唯一要付出的成本就是迁移本身。我们当时的迁移决策分了三步走:

  1. 先挑一个低风险的设置类 store 做试点,跑通持久化和跨 store 调用;
  2. 再替换用户态和评测态这两个高频核心模块,验证流式输出场景下的性能;
  3. 最后处理老代码里残留的 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 字段要做规范化拆分。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部