首页
/ Airi 实战指南:在组件外部正确使用 Pinia Store —— 初始化时序、导航守卫与 SSR 场景全解析

Airi 实战指南:在组件外部正确使用 Pinia Store —— 初始化时序、导航守卫与 SSR 场景全解析

2026-09-05 21:39:55作者:温艾琴Wonderful

本文围绕 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-webui-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 秒窗口限频,并通过 BroadcastChannelpiniaActionTracingChannelName 常量定义于 @proj-airi/stage-shared)把事件广播给其他调试面板。该插件只在开发环境挂载(apps/stage-web/src/main.tsif (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 代码可以归纳为四条可验证的实践:

  1. 入口文件createPinia()pinia.use(plugins)createApp(App).use(pinia),全程不在顶层取 store(apps/stage-web/src/main.tsapps/ui-server-auth/src/main.ts);
  2. 导航守卫useStore() 写在 beforeEach 回调体内,绝不作为模块级闭包变量;
  3. SSR:一律 useStore(pinia) 显式传参,避免依赖全局/组件注入;
  4. 测试与插件:用 setActivePinia(createPinia()) 或插件参数接收实例,保证"实例来源"始终由执行期上下文决定。

遵循这些约定,store 在任何上下文中的生命周期都由明确的调用时机保证,不会因模块导入顺序、SSR 请求复用或测试实例替换而悄然失效。

登录后查看全文
热门项目推荐
相关项目推荐