Airi 实战指南:在组件外部正确使用 Pinia Store —— 初始化时序、导航守卫与 SSR 场景全解析
本文围绕 Pinia 在 Vue 组件外部(导航守卫、插件、SSR 等场景)使用 Store 的正确方式展开,以 Airi 多端应用(Web 端 stage-web、认证端 ui-server-auth、桌面端 stage-tamagotchi)中的真实初始化和守卫代码为佐证。读完后你将掌握:如何在应用启动阶段避免"pinia 未安装"错误、如何把 useStore() 调用推迟到执行期而非模块加载期,以及在 SSR 环境下如何显式传递 pinia 实例。
核心问题:为什么组件外部必须手动提供 pinia 实例
Store 依赖 pinia 实例才能工作。在组件内部,Vue 会在组件实例创建时自动注入这个实例,所以在 <script setup> 里直接调用 useStore() 毫无问题;但在组件外部(模块顶层代码、导航守卫注册处、SSR 服务端逻辑中),这个自动注入不存在,必须手动提供 pinia 实例,或者确保调用时机发生在 app.use(pinia) 之后。
Airi 的 Pinia 技能文档将这一场景列为独立的最佳实践条目,其入口索引见 pinia SKILL,其中明确给出了一条关键建议:"Call stores inside functions not at module scope, especially for SSR"(在函数内调用 store,而非模块作用域,尤其是 SSR 场景)。本文完整展开 best-practices-outside-component 中记载的全部场景,并结合 Airi 仓库源码逐一对应验证。
单页应用:务必在 pinia 安装之后调用 store
最典型的错误发生在应用入口文件:在 createPinia() 或 app.use(pinia) 执行之前,就在模块顶层调用 useStore()。此时 pinia 尚未绑定到任何 Vue 应用,调用必然失败。官方参考文档给出的对照示例如下:
import { useUserStore } from '@/stores/user'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
// ❌ Fails - pinia not created yet
const userStore = useUserStore()
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// ✅ Works - pinia is active
const userStore = useUserStore()
Airi 的多个应用入口文件正是按"先创建 pinia、后装配应用"的顺序组织的。以 Web 端入口 apps/stage-web/src/main.ts 为例,启动时序为:
const pinia = createPinia() // L39:先创建 pinia
const synced = setupSynced()
pinia.use(synced.pinia) // L41:挂载状态同步插件
if (import.meta.env.DEV)
pinia.use(piniaPluginTracing) // L43:开发环境挂载行为追踪插件
// ...创建 router...
createApp(App)
// ...
.use(pinia) // L69:装配到应用
.use(PiniaColada)
// ...
.mount('#app')
值得注意的是,入口文件创建 pinia 之后还通过 pinia.use() 挂载了自定义插件(见下文"插件是组件外上下文"一节),但入口本身并没有在模块顶层调用任何 useXxxStore()——所有 store 的实例化都被推迟到了组件或守卫等运行时上下文,这与文档强调的时机原则完全一致。认证应用入口 apps/ui-server-auth/src/main.ts 的结构相同:const pinia = createPinia() 在 L34 完成,router.beforeEach 在 L45 注册(守卫回调内不提前取 store),最后 createApp(App).use(router).use(pinia) 完成装配。桌面端渲染进程 apps/stage-tamagotchi/src/renderer/main.ts 与移动端 apps/stage-pocket/src/main.ts 也遵循同一模式。
导航守卫:不要写"模块级单例 store"
导航守卫(router.beforeEach)是组件外使用 store 的高频场景。一个常见但危险的写法是在注册守卫的模块顶层提前取一次 store,当作闭包变量传给守卫:
错误写法(文档标记为 Wrong)——在模块级调用:
import { createRouter } from 'vue-router'
const router = createRouter({ /* ... */ })
// ❌ May fail depending on import order
const store = useUserStore()
router.beforeEach((to) => {
if (store.isLoggedIn) { /* ... */ }
})
这种做法失败与否取决于模块的导入顺序:如果守卫所在模块先于 main.ts 执行 app.use(pinia) 被加载,模块顶层的 useUserStore() 就会因为 pinia 未安装而抛错。即使暂时不报错,模块级取到的 store 也无法响应 pinia 实例的更换(例如测试中每个用例都会 setActivePinia(createPinia()))。
正确写法(文档标记为 Correct)——把调用放进守卫回调内部:
router.beforeEach((to) => {
// ✅ Called after pinia is installed
const store = useUserStore()
if (to.meta.requiresAuth && !store.isLoggedIn) {
return '/login'
}
})
守卫回调只在路由真正跳转时才执行,此时应用已完成挂载、pinia 必然处于激活状态,因此函数内取 store 是安全的。
Airi 桌面端的设置页 apps/stage-tamagotchi/src/renderer/pages/settings/index.vue 展示了一个更复杂的真实案例:在组件 setup 阶段注册一个临时守卫,用 useSettings() 里的偏好控制页面转场动画:
const settingsStore = useSettings()
const removeBeforeEach = router.beforeEach(async (_, __, next) => {
if (!settingsStore.usePageSpecificTransitions || settingsStore.disableTransitions) {
next()
return
}
await new Promise<void>((resolve) => {
resolveAnimation.value = resolve
})
removeBeforeEach() // 守卫只触发一次,随后自我移除
next()
})
这里 useSettings() 之所以能在 setup 顶部安全调用,是因为它已经处于组件上下文(pinia 已由应用注入);而守卫回调内部读取 settingsStore.usePageSpecificTransitions 时,同样发生在导航执行时刻而非模块加载时刻。两个调用点都满足"pinia 已激活"这一前提,这正是该文档方法论的实际落地。
需要区分的一点:各应用入口中的 router.beforeEach(如 apps/ui-server-auth/src/main.ts 里用于驱动 NProgress 进度条的守卫)没有引用任何 store,因此不涉及组件外取 store 的时序问题;而一旦守卫逻辑需要读取登录态、权限或设置项,就必须遵守"守卫内调用"的规则。
SSR 应用:显式传递 pinia 实例
在服务端渲染场景中,模块可能被多个请求复用,而每个请求需要独立的 pinia 实例来隔离状态。因此文档给出的规则更严格:始终把 pinia 实例作为第二个参数传给 useStore():
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
router.beforeEach((to) => {
// ✅ Pass pinia instance
const main = useMainStore(pinia)
if (to.meta.requiresAuth && !main.isLoggedIn) {
return '/login'
}
})
useStore(pinia) 的显式传参让"pinia 在哪"这个问题不再依赖全局状态或组件注入,而是由调用方直接指定,这在多实例并发的服务端代码中是唯一稳妥的写法。Airi 当前的主要应用为纯客户端 SPA(stage-web、ui-server-auth 等均以 createWebHistory 启动),从入口代码看未启用服务端渲染管线,因此该小节更多是面向后续引入 SSR/Nuxt 形态时遵循的规范;其原则与 pinia 技能索引 中指向的 SSR 进阶文档(references/advanced-ssr.md)一脉相承。
serverPrefetch():通过 this.$pinia 访问
在 Options API 组件中定义 serverPrefetch() 钩子时,组件实例上可以直接读取 $pinia 属性,将其传给 useStore():
export default {
serverPrefetch() {
const store = useStore(this.$pinia)
return store.fetchData()
},
}
onServerPrefetch():script setup 中的常规用法
与 serverPrefetch() 不同,<script setup> 中的 onServerPrefetch() 运行在组件上下文内,pinia 已被自动注入,因此直接调用 useStore() 即可,无需传参:
<script setup>
const store = useStore()
onServerPrefetch(async () => {
// ✅ Just works
await store.fetchData()
})
</script>
两种钩子的差异本质上是"是否有组件实例可用":Options API 的 serverPrefetch 通过 this.$pinia 拿到实例,Composition API 的 onServerPrefetch 则依赖组件上下文的自动注入。
补充实践:Airi 仓库中另外两类组件外上下文
文档正文覆盖的场景之外,Airi 仓库还有两类"组件外使用 pinia"的真实代码,可以进一步印证上述原则。
1. Pinia 插件:天然的组件外上下文
Pinia 插件在 pinia.use(plugin) 时接收 pinia 实例,插件逻辑完全不依赖组件注入。Airi 在 packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts 中实现了一个行为追踪插件:它统计 action 调用与失败次数、按 5 秒窗口限频,并通过 BroadcastChannel(piniaActionTracingChannelName 常量定义于 @proj-airi/stage-shared)把事件广播给其他调试面板。该插件只在开发环境挂载(apps/stage-web/src/main.ts 中 if (import.meta.env.DEV) pinia.use(piniaPluginTracing))。同目录下的 setup-synced.ts 则提供跨窗口(如桌面端多个渲染窗口)的状态同步能力,两者统一从 packages/stage-ui/src/libs/pinia/index.ts 导出。插件正是"pinia 实例显式传递"模式的典型:实例由挂载方在创建应用时注入,而非插件自行创建。
2. 单元测试:用 setActivePinia 手动激活实例
测试代码是另一个典型的非组件上下文。Airi 的 store 测试普遍采用 setActivePinia(createPinia()) 在每个用例前激活全新的 pinia 实例,例如 apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts:
beforeEach(() => {
setActivePinia(createPinia())
invokeMocks.listMcpTools.mockClear()
invokeMocks.callMcpTool.mockClear()
})
it('loads MCP tools, ...', async () => {
const store = useTamagotchiMcpToolsStore() // 用例内调用,安全
await store.refresh()
// ...
})
注意 useTamagotchiMcpToolsStore() 的调用位于 it 用例体内,而不是 describe 顶层——这与文档"推迟到执行期调用"的核心原则同构:测试框架的 beforeEach 保证 pinia 激活先于用例执行,用例内取 store 才可靠。此外,需要挂载到组件上的测试(如 apps/stage-web/src/pages/devtools/polaroid.browser.test.ts)则直接 app.use(createPinia()),走的是完整的插件装配路径。两种写法覆盖了"纯 store 逻辑"与"组件+store"两类测试需求。
关键结论:把 useStore() 推迟到 pinia 安装之后执行
综合文档与 Airi 仓库的源码实践,核心准则只有一条:
Defer
useStore()calls to functions that run after pinia is installed, rather than calling at module scope.(把useStore()调用推迟到 pinia 安装后执行的函数中,而不是在模块作用域调用。)
对照 Airi 代码可以归纳为四条可验证的实践:
- 入口文件:
createPinia()→pinia.use(plugins)→createApp(App).use(pinia),全程不在顶层取 store(apps/stage-web/src/main.ts、apps/ui-server-auth/src/main.ts); - 导航守卫:
useStore()写在beforeEach回调体内,绝不作为模块级闭包变量; - SSR:一律
useStore(pinia)显式传参,避免依赖全局/组件注入; - 测试与插件:用
setActivePinia(createPinia())或插件参数接收实例,保证"实例来源"始终由执行期上下文决定。
遵循这些约定,store 在任何上下文中的生命周期都由明确的调用时机保证,不会因模块导入顺序、SSR 请求复用或测试实例替换而悄然失效。
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 StartedRust0623
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