airi 中的 Pinia 实战:Store 定义、State、Getters 与 Actions 全解析
本文以 airi 仓库的 Pinia 参考文档 core-stores.md 为主体,系统讲解 defineStore() 的两种定义风格(Options Store 与 Setup Store)、state / getters / actions 三大核心概念、storeToRefs、$patch、$subscribe、$onAction 等 API,并结合 airi 仓库中 聊天会话 store 与 Pinia 行为追踪插件 的真实源码,验证这些概念在大型 Vue 3 应用中的落地方式,帮助你在 airi 这类 Web / 桌面多端项目中正确编写、订阅和调试 Pinia store。
1. Store 的三大核心概念
Pinia 中的每个 store 通过 defineStore() 以唯一名称定义,包含三个核心概念:state(状态)、getters(派生值) 和 actions(行为)。可以用 Vue 的概念类比:state 对应 data,getters 对应 computed,actions 对应 methods。
2. 定义 Store 的两种风格
2.1 Options Store
与 Vue 的 Options API 类似,state、getters、actions 分别写在各自字段中:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo',
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
Options Store 有一个实用特性:内置 $reset() 方法,可直接将 state 恢复为初始值。
2.2 Setup Store(官方推荐)
使用组合式 API 语法定义,更灵活强大,也是 airi 仓库中 store 的主流写法:
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 }
})
映射关系为:ref() → state,computed() → getters,function() → actions。
关键约束:必须通过 return 返回所有需要被 Pinia 追踪的 state 属性,未被返回的 ref 只是组件外的普通响应式变量,无法通过 store 实例访问。
在 airi 中可以看到两种典型用法。PWA 更新 store 是一个精简的 Setup Store,它把内部依赖(如 service worker 的 updateSW 句柄)保留在闭包内,只返回需要暴露的 updateReadyHooks,同时通过 import.meta.env.SSR 判断避免在服务端执行注册逻辑:
export const usePWAStore = defineStore('pwa', () => {
const updateReadyHooks = ref<(() => void)[]>([])
// ...内部逻辑不返回,保持私有
onMounted(async () => {
if (import.meta.env.SSR) return
const { registerSW } = await import('../modules/pwa')
// 动态注册 service worker 并监听更新
})
})
而更复杂的场景如 聊天会话 store,在 Setup Store 内组合了多个 ref(sessionMessages、sessionMetas、index 等)、computed(isReady),以及大量非响应式的内部守卫变量(如 ensureActiveEpoch、reconcileEpoch),展示了 Setup Store 相对 Options Store 的灵活性:任意闭包状态、单例 Promise、Set/Map 等都可以与响应式状态共存。
2.3 使用 Store 与 storeToRefs 解构
在 <script setup> 中调用返回的函数即可获得 store 实例:
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// 访问:store.count、store.doubleCount、store.increment()
</script>
解构时有一个经典的响应性陷阱:直接解构 state 和 getters 会丢失响应性,必须用 storeToRefs;而 actions 是普通方法,可以直接解构:
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ❌ 破坏响应性
const { name, doubleCount } = store
// ✅ 对 state/getters 保留响应性
const { name, doubleCount } = storeToRefs(store)
// ✅ actions 可以直接解构
const { increment } = store
</script>
airi 的 session-store.ts 在 store 内部跨 store 引用时正是这样用的——注意它把 useAuthStore() 的返回交给 storeToRefs 再解构,保证 userId、authToken 在后续 getter/action 中仍是响应式引用:
export const useChatSessionStore = defineStore('chat-session', () => {
const { userId, token: authToken } = storeToRefs(useAuthStore())
const { activeCardId, systemPrompt } = storeToRefs(useAiriCardStore())
// ...
})
airi 仓库中 stage-web 的 App.vue、stage-tamagotchi 的多个 island 组件 等页面普遍采用这种 storeToRefs 解构模式,说明这是该仓库组件层消费 store 的标准姿势。
3. State:定义、访问、修改与订阅
3.1 State 定义为函数返回初始值
State 是一个返回初始状态对象的函数,延迟求值以避免共享可变对象。
3.2 TypeScript 类型标注
简单类型可以自动推断;对复杂类型有两种标注方式。方式一:在属性上用 as 断言:
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.3 访问与修改
const store = useStore()
store.count++
<input v-model="store.count" type="number" />
3.4 用 $patch 批量变更
一次应用多处修改时,比逐条赋值更清晰,也便于统一追踪 mutation:
// 对象语法
store.$patch({
count: store.count + 1,
name: 'DIO',
})
// 函数语法(适合复杂变更,如数组操作)
store.$patch((state) => {
state.items.push({ name: 'shoes', quantity: 1 })
state.hasChanged = true
})
airi 的 角色服务 与 provider 测试 中都可以看到 $patch 的实际使用,测试里大量通过 store.$patch 构造初始状态再断言行为,说明它是 airi store 测试中的标准手段。
3.5 重置 State
Options Store 自带 $reset();Setup Store 则需自行实现一个语义等价的动作:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function $reset() {
count.value = 0
}
return { count, $reset }
})
3.6 订阅状态变更:$subscribe
cartStore.$subscribe((mutation, state) => {
mutation.type // 'direct' | 'patch object' | 'patch function'
mutation.storeId // 'cart'
mutation.payload // patch object(仅 'patch object' 类型时存在)
localStorage.setItem('cart', JSON.stringify(state))
})
// 可选配置
cartStore.$subscribe(callback, { flush: 'sync' }) // 立即执行,而非默认的 'pre'
cartStore.$subscribe(callback, { detached: true }) // 组件卸载后仍保留订阅
airi 的 pinia-plugin-tracing.ts 是这一 API 的工程化范例:该 Pinia 插件在开发模式下对每个 store 挂载 $subscribe,并显式传入 { detached: true, flush: 'sync' },用于按 5 秒窗口统计每个 store 的 mutation 频率与类型分布(direct / patch object / patch function),输出 [DEBUG-pinia-rate] 汇总日志。从源码结构看,detached: true 是必要的——插件在 Pinia 创建期就完成订阅,生命周期不依附任何组件;flush: 'sync' 则确保计数与变更严格同步,避免异步 flush 带来的时序偏差。
4. Getters:派生状态的所有姿势
Getters 等价于 Vue 的 computed(),是带缓存的派生值。
4.1 基础 Getter
getters: {
doubleCount: (state) => state.count * 2,
}
4.2 访问其他 Getters
通过 this 访问同 store 的其他 getter,注意建议显式标注返回类型:
getters: {
doubleCount: (state) => state.count * 2,
doublePlusOne(): number {
return this.doubleCount + 1
},
},
4.3 带参数的 Getter
返回函数即可让 getter 接受参数,但要注意:此时 getter 缓存的只是"返回查找函数"这一步,每次调用参数化函数本身都会重新执行,因此缓存效果会弱化。推荐的写法是把不变部分(如过滤结果)留在外层被缓存,把参数化查询放在内层:
// 每次调用都遍历全量用户
getters: {
getUserById: (state) => {
return (userId: string) => state.users.find((user) => user.id === userId)
},
},
// 外层计算(过滤 active 用户)被缓存,仅内层 find 随参数执行
getters: {
getActiveUserById(state) {
const activeUsers = state.users.filter((user) => user.active)
return (userId: string) => activeUsers.find((user) => user.id === userId)
},
},
4.4 在 Getter 中访问其他 Store
import { useOtherStore } from './other-store'
getters: {
combined(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
},
这与 airi 的 session-store.ts 的组织方式一致:它把"当前选中会话"拆到独立的 chat-session-selection store,再通过 computed 的 get/set 桥接为主 store 的 activeSessionId,源码注释解释动机是"选中的会话属于某个窗口,同步的会话数据不应让另一个窗口跳转到同一会话"——这正是跨 store 派生值设计要解决的典型问题。
5. Actions:业务逻辑与异步处理
Actions 是方法,与 getters 的关键区别是可以异步。
5.1 定义 Actions
actions: {
increment() {
this.count++
},
randomizeCounter() {
this.count = Math.round(100 * Math.random())
},
},
5.2 异步 Actions
actions: {
async registerUser(login: string, password: string) {
try {
this.userData = await api.post({ login, password })
} catch (error) {
return error
}
},
},
5.3 在 Actions 中访问其他 Store
import { useAuthStore } from './auth-store'
actions: {
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
}
},
},
SSR 注意事项:在任何 await 之前调用所有 useStore(),不要在 await 之后才调用,否则在服务端渲染时可能绑定到错误的 Pinia 实例:
async orderCart() {
// ✅ 在 await 前调用 store
const user = useUserStore()
await apiOrderCart(user.token, this.items)
// ❌ SSR 下不要在 await 之后调用 useStore()
}
5.4 订阅 Action 生命周期:$onAction
$onAction 提供 action 调用前后的钩子,是埋点、性能度量、错误上报的标准入口:
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 可让订阅在组件卸载后仍然保留。
airi 的 pinia-plugin-tracing.ts 展示了 $onAction 的完整生产用法:插件为每个 action 生成 invocationId,在 action 开始、完成(after)、失败(onError)三个节点通过 BroadcastChannel 广播 started / completed / failed 事件,失败事件附带 errorMessageFrom(error) 提取的报错文本。该插件刻意不保留 action 的参数、结果或 state 快照,只在开发模式下按窗口聚合输出速率统计——这与文档中"订阅可用于性能度量"的示例完全对应,只是扩展成了跨窗口的分布式追踪。
6. Options API 场景下的映射辅助函数
对于仍在使用 Options API 的组件,Pinia 提供了 mapState、mapWritableState、mapActions(注意:getter 不需要单独映射,mapState 同样映射只读的 getter):
import { mapState, mapWritableState, mapActions } from 'pinia'
import { useCounterStore } from '../stores/counter'
export default {
computed: {
// 只读的 state/getter
...mapState(useCounterStore, ['count', 'doubleCount']),
// 可写的 state
...mapWritableState(useCounterStore, ['count']),
},
methods: {
...mapActions(useCounterStore, ['increment']),
},
}
7. 在 Setup Store 中访问全局 Provider
Setup Store 的函数体中可以使用 inject() 和 useRoute() 等组合式 API 获取全局注入的值。典型约定是:这些依赖只用于内部逻辑,不要返回到 store 中,组件应自行获取:
import { inject } from 'vue'
import { useRoute } from 'vue-router'
import { defineStore } from 'pinia'
export const useSearchFilters = defineStore('search-filters', () => {
const route = useRoute()
const appProvided = inject('appProvided')
// 不要返回这些值,直接在组件中访问它们
return { /* ... */ }
})
8. 仓库中的验证:测试与同步插件
airi 的 store 测试进一步佐证了上述 API 的用法。session-store.browser.test.ts 在测试中现场 defineStore 出 mock 的 auth、airi-card 两个 setup store,并通过 vi.doMock 替换真实模块,说明 Setup Store 的 mock 方式就是"返回 ref 对象"这一最小形态。该测试还引入了 pinia-plugin-synced 插件(store 定义时传入 { synced: { state: true } } 选项),从源码结构看,airi 用它在浏览器端实现跨窗口的 store 状态同步,这也解释了 session-store.ts 中"多窗口、epoch 防串号"等设计——Pinia store 在该应用中承担着跨标签页共享会话状态的角色。
此外,background.ts 展示了 store 的再导出模式:stage-web 不重复定义,而是 export { useBackgroundStore } from '@proj-airi/stage-layouts/stores/background',把共享 store 收敛到 stage-layouts 包 中,供 web / pocket / tamagotchi 多个端复用。
小结
- 优先使用 Setup Store:
ref→ state,computed→ getters,函数 → actions;务必 return 所有需要追踪的响应式属性; - 组件中解构 state/getter 一律走
storeToRefs,actions 可直接解构;跨 store 引用同样先storeToRefs; - 批量修改用
$patch(对象或函数语法),Options Store 用$reset,Setup Store 自定义同名动作; - 用
$subscribe(可配flush/detached)做持久化与变更审计,用$onAction的after/onError做性能度量和错误上报; - 参数化 getter 要利用外层缓存;异步 action 中在
await前完成所有useStore()调用; - airi 的 pinia-plugin-tracing.ts 提供了
$subscribe+$onAction组合成开发期追踪插件的完整参考实现。
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 StartedRust0622
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