Airi 项目实践:基于 Pinia v3 的 Vue 状态管理完整指南
本指南以仓库
.agents/skills/pinia/SKILL.md技能文档(基于 Pinia v3.0.4 整理)为骨架,结合 Airi 仓库中stage-web、stage-pocket、stage-tamagotchi、stage-ui等真实源码场景展开。读完你将掌握:Option/Setup 两种 Store 的定义方式、state/getters/actions 的正确使用姿势、storeToRefs 响应式解构、Store 组合与循环依赖规避、Pinia 插件机制、@pinia/testing 单元测试,以及 SSR/Nuxt/HMR 与组件外使用 Store 的工程化要点。
一、Pinia 是什么:官方状态库的定位与设计
Pinia 是 Vue 官方推荐的状态管理库,与 Vuex 相比最大变化是去掉 mutations、全面拥抱 Composition API、原生 TypeScript 类型推导,并内置 Vue Devtools 支持。它同时支持 Options API 与 Composition API(Setup)两种写法,是 defineStore 之后"即用即所得"的轻量方案。
在 Airi 仓库中,Pinia 被广泛用于多个 Vue 应用/包:
- apps/stage-web/src/main.ts 中通过
createPinia()+app.use(pinia)挂载,并在其上注册了synced.pinia与仅在import.meta.env.DEV下启用的piniaPluginTracing; - apps/stage-web/src/stores/ 下维护了
pwa.ts、background.ts、devtools-lag.ts等 store; - packages/stage-layouts/src/stores/background.ts 负责舞台背景主题选项状态;
- packages/stage-ui/src/stores/characters.ts 甚至用 Pinia 结合
@pinia/colada承担了角色列表的请求缓存与本地乐观更新。
原技能文档位于 .agents/skills/pinia/SKILL.md,本仓库 Store 相关技能引用可直接查看其
references/子目录中的 9 篇主题文档(下文每节均标注对应参考文档链接),便于按需深入。
二、定义 Store:Option Store 与 Setup Store 的选择
Store 通过 defineStore() 定义,必须有一个全局唯一名称(建议文件名与 store id 一致)。核心是三大概念:state(状态)、getters(计算派生值)、actions(业务动作)。
2.1 Option Store:把 Pinia 当作增强版 Options API
Option Store 写法与 Vue 的 Options API 一一对应:state 相当于 data,getters 相当于 computed,actions 相当于 methods:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo',
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
2.2 Setup Store:官方技能文档推荐的主流写法
Setup Store 使用 Composition API 语法,本质是一个普通函数:函数体内的 ref()/reactive() 即 state,computed() 即 getters,普通 function 即 actions:
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Eduardo')
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, name, doubleCount, increment }
})
关键约束:setup 内创建的响应式状态必须全部通过 return 暴露,否则 Pinia 无法跟踪它们。
Airi 仓库绝大多数 Store 采用 Setup Store。例如 apps/stage-web/src/stores/pwa.ts 定义了 PWA 更新感知 Store,state 是 ref<(() => void)[]>([]) 的更新钩子队列,action 则借助 Vue 生命周期 onMounted 动态注册 Service Worker 更新流程:
export const usePWAStore = defineStore('pwa', () => {
const updateReadyHooks = ref<(() => void)[]>([])
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
onMounted(async () => {
if (import.meta.env.SSR) return
// ... 动态 import 注册 SW 并 push 更新钩子
updateReadyHooks.value.push(updateSW)
})
})
而 apps/stage-web/src/stores/devtools-lag.ts 则是一个复杂得多、完全面向逻辑的 Setup Store:它用 reactive() 维护 fps/帧耗时/longtask/memory 采样缓冲区,用 shallowRef 保存采样器支持性,配合 watch、onScopeDispose 清理定时器——这在 Option Store 里很难组织,正是 Setup Store 适合"复杂逻辑、组合式与 watcher"的典型证据(对应 .agents/skills/pinia/references/core-stores.md)。
2.3 在组件中使用 Store
在 <script setup> 中直接调用以 useXxx 命名的 useStore() 即可获得 Store 单例,Pinia 会把当前应用上下文自动注入:
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// 访问:store.count / store.doubleCount / store.increment()
</script>
Actions 本身就是绑定到 store 上的,因此可以直接解构;但 state/getters 解构会丢失响应性,必须用 storeToRefs() 包裹:
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ❌ 破坏响应性
const { name, doubleCount } = store
// ✅ state / getters 用 storeToRefs 保持响应
const { name, doubleCount } = storeToRefs(store)
// ✅ actions 可以直接解构(已绑定 store)
const { increment } = store
</script>
Airi 仓库中对这一模式的应用十分密集,如 apps/stage-web/src/pages/index.vue 一次从多个 store 中解构出响应式状态与动作:
const backgroundStore = useBackgroundStore()
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
const settingsAudioDeviceStore = useSettingsAudioDevice()
const { stream, enabled } = storeToRefs(settingsAudioDeviceStore)
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
const chatStore = useChatStore()
这里 activeProvider 还演示了 getters 用别名重命名后交给组件消费的常见模式。
三、State:类型、变更、重置与订阅
3.1 TypeScript 下的 State 声明
TypeScript 推断可自动工作;复杂结构建议声明接口或使用类型断言:
interface UserInfo {
name: string
age: number
}
// 方式一:内联类型断言
export const useUserStore = defineStore('user', {
state: () => ({
userList: [] as UserInfo[],
user: null as UserInfo | null,
}),
})
// 方式二:为 state 函数标注返回类型(推荐,语义更清晰)
interface State {
userList: UserInfo[]
user: UserInfo | null
}
export const useUserStore = defineStore('user', {
state: (): State => ({
userList: [],
user: null,
}),
})
3.2 读取与直接修改
Pinia 中 state 是"可写的",可以直接赋值,模板中也可直接 v-model 绑定到 store 状态:
const store = useStore()
store.count++
<input v-model="store.count" type="number" />
3.3 批量变更 $patch
多字段同时变更用 $patch,会一次性触发更新且更利于 Devtools 追踪:
// 对象语法(适合扁平状态)
store.$patch({
count: store.count + 1,
name: 'DIO',
})
// 函数语法(适合数组 push / 复合逻辑)
store.$patch((state) => {
state.items.push({ name: 'shoes', quantity: 1 })
state.hasChanged = true
})
3.4 重置 $reset
- Option Store 内置
$reset(),会把状态重置到state函数的初始值; - Setup Store 没有内置
$reset,需要自行实现同名函数(与生命周期/内部 ref 协作):
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function $reset() {
count.value = 0
}
return { count, $reset }
})
3.5 订阅 $subscribe(持久化等场景)
$subscribe 与 Vue 的 watch 类似但只观察该 store 的 state 变化,回调能拿到 mutation 元信息:
cartStore.$subscribe((mutation, state) => {
mutation.type // 'direct' | 'patch object' | 'patch function'
mutation.storeId // 'cart'
mutation.payload // 仅 'patch object' 时有值
localStorage.setItem('cart', JSON.stringify(state))
})
// 选项:默认 flush 为 'pre'(组件更新前)
cartStore.$subscribe(callback, { flush: 'sync' }) // 立即执行
cartStore.$subscribe(callback, { detached: true }) // 组件卸载后仍保留
Airi 中 background 相关的持久化偏好就是借助 VueUse useLocalStorage + computed 可写 getter 实现的(见 packages/stage-layouts/src/stores/background.ts),这类"订阅即持久化"的思路在生产项目里非常实用。
四、Getters:派生状态的正确姿势
Getter 等价于 Vue 的 computed,适合放依赖 state 的派生值,避免在多个组件里重复计算。
4.1 基础与访问其他 getter
getters: {
doubleCount: (state) => state.count * 2,
// 访问同 store 的其他 getter,必须用 this 并显式标注返回类型
doublePlusOne(): number {
return this.doubleCount + 1
},
}
4.2 带参数的 getter(注意缓存丢失)
返回一个函数的写法可以让 getter 接收参数,但每次调用都不走缓存:
getters: {
getUserById: (state) => {
return (userId: string) => state.users.find((user) => user.id === userId)
},
}
若想保留一部分缓存收益,可先在 getter 内部做一次过滤得到稳定集合,再返回查找函数:
getters: {
getActiveUserById(state) {
const activeUsers = state.users.filter((user) => user.active)
return (userId: string) => activeUsers.find((user) => user.id === userId)
},
}
4.3 在 getter 中访问其他 store
import { useOtherStore } from './other-store'
getters: {
combined(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
}
五、Actions:业务逻辑与异步流程
与 getters 不同,actions 可以是异步的,是组织业务逻辑的推荐位置。
actions: {
increment() {
this.count++
},
randomizeCounter() {
this.count = Math.round(100 * Math.random())
},
// 异步 action
async registerUser(login: string, password: string) {
try {
this.userData = await api.post({ login, password })
} catch (error) {
return error
}
},
// 在 action 中组合其他 store
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
}
},
}
SSR 黄金法则:所有 useStore() 调用都必须发生在任意 await 之前,以保证使用正确的 Pinia 实例:
async orderCart() {
// ✅ 先取 store
const user = useUserStore()
await apiOrderCart(user.token, this.items)
// ❌ 不要在 await 之后再调用 useStore()(SSR 下拿不到正确实例)
}
Airi 仓库中 packages/stage-ui/src/stores/characters.ts 是一个重量级实战范本:它在 Setup Store 里通过 @pinia/colada 的 useQuery / useMutation 封装远程角色数据,实现"先写本地、失败回退、成功再同步远端"的 local-first 乐观更新,并将一整套可变逻辑收敛进 createCharacterStoreController 纯函数工厂,保证控制器可脱离 Pinia 单独测试。
5.1 订阅 actions:$onAction
const unsubscribe = someStore.$onAction(
({ name, store, args, after, onError }) => {
const startTime = Date.now()
console.log(`Start "${name}" with params [${args.join(', ')}]`)
after((result) => {
console.log(`Finished "${name}" after ${Date.now() - startTime}ms`)
})
onError((error) => {
console.warn(`Failed "${name}": ${error}`)
})
}
)
unsubscribe() // 返回的取消函数,组件卸载时应调用
// 传第二个参数 true 可在组件卸载后继续保留
someStore.$onAction(callback, true)
这正是 packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts 这类"性能/错误追踪插件"的基础:它对所有 action 进行观测、按 piniaActionTracingChannelName 广播,并附带失败率限流统计(每 5 秒窗口最多记录 10 条),在 apps/stage-web/src/main.ts 中仅 DEV 环境注册。
六、Options API 辅助函数与 Setup Store 注入
- 若仍在使用 Options API 组件,可用
mapState/mapWritableState/mapActions把 store 映射到组件的computed/methods:
import { mapState, mapWritableState, mapActions } from 'pinia'
import { useCounterStore } from '../stores/counter'
export default {
computed: {
...mapState(useCounterStore, ['count', 'doubleCount']), // 只读
...mapWritableState(useCounterStore, ['count']), // 可写
},
methods: {
...mapActions(useCounterStore, ['increment']),
},
}
- Setup Store 内也能直接消费组件级全局注入(
inject、useRoute等),返回给组件的应是业务状态而不是这些内部句柄:
import { inject } from 'vue'
import { useRoute } from 'vue-router'
export const useSearchFilters = defineStore('search-filters', () => {
const route = useRoute()
const appProvided = inject('appProvided')
// 不要 return 这些内部引用
return { /* ... */ }
})
七、在 Store 中使用组合式函数(Composables)
Setup Store 的最大红利之一是可以直接调用任意 VueUse 组合式函数,把"模块级共享状态"装进 Pinia 语义里。
7.1 Option Store:仅限返回可写 ref 的 composable
import { defineStore } from 'pinia'
import { useLocalStorage } from '@vueuse/core'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: useLocalStorage('pinia/auth/login', 'bob'), // ✅ useLocalStorage 返回可写 ref
}),
})
可以:useLocalStorage、useAsyncState 这类返回可写 ref 的组合式函数。
不行:返回函数、或只暴露只读数据的 composable(无法放入 state 函数)。
7.2 Setup Store:几乎任何 composable 都行
import { defineStore } from 'pinia'
import { useMediaControls } from '@vueuse/core'
import { ref } from 'vue'
export const useVideoPlayer = defineStore('video', () => {
const videoElement = ref<HTMLVideoElement>()
const src = ref('/data/video.mp4')
const { playing, volume, currentTime, togglePictureInPicture } =
useMediaControls(videoElement, { src })
function loadVideo(element: HTMLVideoElement, newSrc: string) {
videoElement.value = element
src.value = newSrc
}
return { src, playing, volume, currentTime, loadVideo, togglePictureInPicture }
})
注意:不要返回
videoElement这类不可序列化的 DOM 引用——它们是内部实现细节。
Airi 对这种"composable + Store"的组合运用得淋漓尽致:
- apps/stage-tamagotchi/src/renderer/stores/window.ts 的
useWindowStore直接组合useWindowSize()与useElectronRelativeMouse(),把窗口尺寸、鼠标坐标变成可跨组件共享的响应式 Store; - packages/stage-layouts/src/stores/background.ts 在 Setup Store 中大量使用
useLocalStorage、useObjectUrl来维护背景预设/选中项/ObjectURL 生命周期。
详细说明见 .agents/skills/pinia/references/features-composables.md。
八、组合 Store:跨 Store 通信与循环依赖规避
Store 之间可以互相调用共享状态,但有一个铁律:
两个 Store 不能在各自 setup 阶段直接读取对方的 state,否则会无限循环:
// ❌ 无限循环
const useX = defineStore('x', () => {
const y = useY()
y.name // 不要在 setup 阶段读取!
return { name: ref('X') }
})
const useY = defineStore('y', () => {
const x = useX()
x.name // 同样禁止!
return { name: ref('Y') }
})
解法:把跨 Store 读取推迟到 getter / computed / action 里执行:
const useX = defineStore('x', () => {
const y = useY()
function doSomething() {
const yName = y.name // ✅ 运行时读取
}
return { name: ref('X'), doSomething }
})
在 Setup Store 中组合其他 Store 时,把 useUserStore() 放在顶部:
export const useCartStore = defineStore('cart', () => {
const user = useUserStore()
const list = ref([])
const summary = computed(() =>
`Hi ${user.name}, you have ${list.value.length} items`,
)
function purchase() {
return apiPurchase(user.id, list.value)
}
return { list, summary, purchase }
})
Option Store 则在 getter / action 内部调用 useUserStore()。跨 store 的 action 调用同样如此(例如角色 Store 内部就引用了 useAuthStore() 读取 userId,见 packages/stage-ui/src/stores/characters.ts)。SSR 下记住:所有 useStore() 必须在 await 前完成。详见 .agents/skills/pinia/references/features-composing-stores.md。
九、插件机制:给所有 Store 统一注入能力
Pinia 插件通过 pinia.use(plugin) 注册,作用于每一个之后创建的 store,可用来注入属性、状态、订阅甚至封装第三库对象。
9.1 基础插件与上下文
import { createPinia } from 'pinia'
function SecretPiniaPlugin() {
return { secret: 'the cake is a lie' }
}
const pinia = createPinia()
pinia.use(SecretPiniaPlugin)
// 任意 store 都能访问 store.secret
插件回调会收到一个上下文对象,含 pinia(实例)、app(Vue 应用)、store(正在被扩展的 store)、options(该 store 的定义选项):
import { PiniaPluginContext } from 'pinia'
export function myPiniaPlugin(context: PiniaPluginContext) {
context.pinia // pinia 实例
context.app // Vue app 实例
context.store // 被扩展的 store
context.options // store 定义选项
}
9.2 注入属性 / 状态 / 外部对象
// 注入属性(return 对象会自动纳入 Devtools 追踪)
pinia.use(() => ({ hello: 'world' }))
// 或在回调里直接赋值,并手动登记到 _customProperties 让 dev 下可见
pinia.use(({ store }) => {
store.hello = 'world'
if (process.env.NODE_ENV === 'development')
store._customProperties.add('hello')
})
// 注入响应式状态:需同时写 store 与 store.$state(SSR/Devtools 才正确)
pinia.use(({ store }) => {
if (!store.$state.hasOwnProperty('hasError')) {
const hasError = ref(false)
store.$state.hasError = hasError
}
store.hasError = toRef(store.$state, 'hasError')
})
// 注入非响应式外部对象(router 等)用 markRaw 包裹,避免被代理成响应式
pinia.use(({ store }) => {
store.router = markRaw(router)
})
9.3 自定义 Store 选项(把插件做成声明式)
可以在 store 定义里追加自定义字段(Option Store 直接加属性;Setup Store 通过 defineStore 的第三个参数传对象),由插件统一消费:
// Option Store 定义自定义选项
defineStore('search', {
actions: {
searchContacts() { /* ... */ },
},
debounce: {
searchContacts: 300,
},
})
// Setup Store 的写法:defineStore(id, setupFn, options)
defineStore('search', () => { /* ... */ }, {
debounce: { searchContacts: 300 },
})
插件读取并加工:
pinia.use(({ options, store }) => {
if (options.debounce) {
return Object.keys(options.debounce).reduce((acc, action) => {
acc[action] = debounce(store[action], options.debounce[action])
return acc
}, {})
}
})
9.4 TypeScript 模块扩展
为了让插件注入的类型对使用者可见,需要对 pinia 模块做声明合并:
// 自定义属性
declare module 'pinia' {
export interface PiniaCustomProperties {
router: Router
hello: string
}
}
// 自定义状态
declare module 'pinia' {
export interface PiniaCustomStateProperties<S> {
hasError: boolean
}
}
// 自定义选项
declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
debounce?: Partial<Record<keyof StoreActions<Store>, number>>
}
}
9.5 在插件里统一订阅
插件内可以直接调用 $subscribe / $onAction,实现"所有 Store 统一打点":
pinia.use(({ store }) => {
store.$subscribe(() => { /* 响应 state 变化 */ })
store.$onAction(() => { /* 响应 actions */ })
})
完整插件文档见 .agents/skills/pinia/references/features-plugins.md。
Airi 仓库的插件实践:Airi 在运行时配置了两个 Pinia 插件(见 apps/stage-web/src/main.ts):
const pinia = createPinia()
const synced = setupSynced()
pinia.use(synced.pinia) // 跨标签页/窗口状态同步
if (import.meta.env.DEV)
pinia.use(piniaPluginTracing) // DEV 下的 action 追踪
其中 piniaPluginTracing 的实现位于 packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts,并有对应的 pinia-plugin-tracing.test.ts 作为行为验证。此外依赖上还可见 pinia-plugin-synced 与 @pinia/colada(声明在 packages/stage-ui/package.json),是"官方生态全家桶"的真实样板。
十、单元测试:@pinia/testing 的正确用法
10.1 纯 Store 单测:每个用例新建 Pinia
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '../src/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia()) // 每个用例独立实例,避免用例间状态串扰
})
it('increments', () => {
const counter = useCounterStore()
expect(counter.n).toBe(0)
counter.increment()
expect(counter.n).toBe(1)
})
})
带插件的场景可先构造插件化 Pinia 再激活:
const app = createApp({})
beforeEach(() => {
const pinia = createPinia().use(somePlugin)
app.use(pinia)
setActivePinia(pinia)
})
10.2 组件测试:createTestingPinia
npm i -D @pinia/testing
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
const wrapper = mount(Counter, {
global: {
plugins: [createTestingPinia()],
},
})
const store = useSomeStore()
// 直接改 state 或 $patch
store.name = 'new name'
store.$patch({ name: 'new name' })
// actions 默认被 stub 成 vi.fn,可用断言验证调用
store.someAction()
expect(store.someAction).toHaveBeenCalledTimes(1)
关键参数一览:
| 参数 | 取值/默认 | 作用 |
|---|---|---|
initialState |
{ counter: { n: 20 } } |
按 store id 预置初始状态 |
stubActions |
默认 true |
false 执行真实 action;传数组或 (name, store) => boolean 可选择性 stub |
createSpy |
默认 vi.fn |
无全局 spy 时可传 sinon.spy 等 |
plugins |
[] |
在测试 Pinia 上注册真实插件,不要用 testingPinia.use(...) |
// 选择性 stub:只 stub increment/reset
createTestingPinia({ stubActions: ['increment', 'reset'] })
// 函数式控制
createTestingPinia({
stubActions: (actionName) => actionName.startsWith('set'),
})
// 真实执行部分 action,其余 stub
createTestingPinia({ stubActions: false })
// mock action 的返回值(action 即 Mock)
import type { Mock } from 'vitest'
store.someAction.mockResolvedValue('mocked value')
10.3 Mock getters 与类型安全
测试中 getter 是可写的(可覆盖其计算值,赋 undefined 恢复默认行为);需要类型安全的 Mock Store 时可用条件类型包装:
const pinia = createTestingPinia()
const counter = useCounterStore(pinia)
counter.double = 3 // 覆盖 computed
counter.double = undefined // 恢复默认计算
// 类型安全的 mocked store
function mockedStore<TStoreDef extends () => unknown>(
useStore: TStoreDef,
): TStoreDef extends StoreDefinition<infer Id, infer State, infer Getters, infer Actions>
? Store<Id, State, Record<string, never>, {
[K in keyof Actions]: Actions[K] extends (...args: any[]) => any
? Mock<Actions[K]>
: Actions[K]
}>
: ReturnType<TStoreDef> {
return useStore() as any
}
E2E 测试无需任何特殊处理——Pinia 在真实环境正常工作。仓库中 packages/stage-ui/package.json 即声明了 @pinia/testing 为开发依赖,且真实项目里也能看到 store 的 vitest 测试(例如 pinia-plugin-tracing.test.ts),可作参考。完整测试方法论见 .agents/skills/pinia/references/best-practices-testing.md。
十一、组件之外使用 Store(导航守卫/插件/中间件)
Store 依赖 pinia 实例——组件内自动注入,组件外则需确保在 pinia 安装后调用,必要时显式传入实例。
11.1 SPA:在安装 pinia 之后再调用
import { useUserStore } from '@/stores/user'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
// ❌ pinia 尚未创建,报错
const userStore = useUserStore()
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// ✅ 此时才能安全调用
const userStore = useUserStore()
11.2 路由守卫:把调用放进回调
模块顶层调用可能因 import 顺序失败,应延迟到守卫回调内:
router.beforeEach((to) => {
// ✅ 守卫触发时 pinia 必然已安装
const store = useUserStore()
if (to.meta.requiresAuth && !store.isLoggedIn)
return '/login'
})
11.3 SSR:始终显式传 pinia
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
router.beforeEach((to) => {
const main = useMainStore(pinia) // ✅ SSR 下必须显式传递
if (to.meta.requiresAuth && !main.isLoggedIn)
return '/login'
})
11.4 serverPrefetch / onServerPrefetch
Options 组件用 this.$pinia;<script setup> 中 onServerPrefetch 正常工作无需特殊处理。核心原则只有一条:把 useStore() 推迟到 pinia 安装之后运行的函数中,而不是模块作用域。详见 .agents/skills/pinia/references/best-practices-outside-component.md。
十二、SSR:状态水合与跨请求隔离
Pinia 在 SSR 下要求 store 在 setup 顶部、getter 或 action 中调用(此时 Pinia 能识别应用上下文)。
12.1 序列化与注入
服务端渲染完成后,把 pinia.state.value 序列化注入 HTML(推荐使用 devalue 这类 XSS 安全的序列化工具,而非裸 JSON),客户端在任何 useStore() 之前反序列化回填:
// 服务端
import devalue from 'devalue'
import { createPinia } from 'pinia'
const pinia = createPinia()
// ... renderToString 完成后
const serializedState = devalue(pinia.state.value)
// 将 serializedState 注入 HTML 全局变量
// 客户端(首次 useStore() 之前)
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
if (typeof window !== 'undefined') {
pinia.state.value = JSON.parse(window.__pinia)
}
12.2 composable 状态在 SSR 下的水合处理
- Option Store:用
hydrate(state, initialState)忽略服务端状态、改从浏览器读取(典型如useLocalStorage持久化的登录态); - Setup Store:用
skipHydrate()标记不应从服务端水合的状态。它只作用于 ref 形式的 state,对函数与非响应式值无效。
export const useColorStore = defineStore('colors', () => {
const { isSupported, open, sRGBHex } = useEyeDropper()
const lastColor = useLocalStorage('lastColor', sRGBHex)
return {
lastColor: skipHydrate(lastColor), // 客户端专属,跳过水合
open, // 函数,无水合需求
isSupported, // 布尔值非响应式
}
})
12.3 SSR 关键点清单
- store 调用放在函数内而非模块作用域;
- SSR 下组件外使用 store 必须传
pinia实例; - 任何
useStore()之前完成状态水合; - 用
devalue等做安全序列化; - 每个请求新建 Pinia 实例,杜绝跨请求状态污染。
详见 .agents/skills/pinia/references/advanced-ssr.md。
十三、Nuxt 集成(供 SSR 场景扩展)
Airi 仓库本身是 Vite + Vue(非 Nuxt 应用),但技能文档的 Nuxt 章节对任何 SSR 或迁移到 Nuxt 的场景都值得保留。要点如下:
- 安装:
npx nuxi@latest module add pinia,会同时装好@pinia/nuxt与pinia;npm 用户若遇ERESOLVE可加"overrides": { "vue": "latest" }。 - 配置
nuxt.config.ts的modules: ['@pinia/nuxt'];自定义 store 目录用pinia.storesDirs,默认自动引入app/stores/(Nuxt 4)或stores/。 - 自动引入
usePinia()、defineStore()、storeToRefs()、acceptHMRUpdate()。 - 页面数据抓取优先
await callOnce('user', () => store.fetchUser()),导航级刷新可加{ mode: 'navigation' }。 - 中间件 / 守卫中显式传
nuxtApp.$pinia。 - Pinia 插件统一写在
plugins/myPiniaPlugin.ts的defineNuxtPlugin(({ $pinia }) => { $pinia.use(...) })中。
完整内容见 .agents/skills/pinia/references/advanced-nuxt.md。
十四、HMR:开发期无刷新改 Store
Pinia 支持在编辑 store 逻辑时通过热更新保留当前 state。做法是在每个 store 定义之后追加一段 HMR 代码:
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useAuth = defineStore('auth', {
// store options...
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAuth, import.meta.hot))
}
Setup Store 写法一致。兼容性方面:Vite 通过 import.meta.hot 官方支持,Webpack 走 import.meta.webpackHot,任何实现该规范的打包器均可工作;Nuxt 下 acceptHMRUpdate 会自动引入,但仍需手写 HMR 片段。收益是:改 state/getters/actions 时页面不重载、当前运行时状态不丢失,开发迭代显著提速。详见 .agents/skills/pinia/references/advanced-hmr.md。
十五、官方技能文档给出的核心建议汇总
以下来自 .agents/skills/pinia/SKILL.md 的 Key Recommendations,是直接可用的团队规范:
- 复杂逻辑、组合式函数、watcher 场景优先使用 Setup Store(本仓库的
pwa.ts、devtools-lag.ts、background.ts、characters.ts全部如此); - 解构 state/getters 必须用
storeToRefs()以保持响应性; - actions 可直接解构,它们已绑定到 store 实例;
- 在函数内部调用 store,而非模块顶层——尤其 SSR;
- 为每个 store 添加 HMR 支持以改善开发体验;
- 组件测试用
@pinia/testing获得 mock 化 store。
十六、在 Airi 仓库中进一步阅读
- 技能文档主索引:.agents/skills/pinia/SKILL.md,共 9 篇主题参考:
- Store 核心概念:references/core-stores.md
- 插件:references/features-plugins.md
- Composables:references/features-composables.md
- Store 组合:references/features-composing-stores.md
- 测试:references/best-practices-testing.md
- 组件外使用:references/best-practices-outside-component.md
- SSR:references/advanced-ssr.md
- Nuxt:references/advanced-nuxt.md
- HMR:references/advanced-hmr.md
- Pinia 安装与插件注册示例:apps/stage-web/src/main.ts
- 简单 Setup Store 示例:apps/stage-web/src/stores/pwa.ts、apps/stage-tamagotchi/src/renderer/stores/window.ts
- 复杂 Setup Store + 性能采样:apps/stage-web/src/stores/devtools-lag.ts
- 跨 Store 组合 + 请求库集成:packages/stage-ui/src/stores/characters.ts
- 真实 Pinia 插件与其测试:packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts
- 组件内 storeToRefs 消费范式:apps/stage-web/src/pages/index.vue
结合本篇指南与上述真实代码对照阅读,即可把 Pinia 的"定义—使用—扩展—测试—SSR—HMR"完整链路落到 Airi 这般规模的工程实践中。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00