首页
/ airi 仓库 Pinia Store 组合实战:跨 Store 通信与循环依赖规避指南

airi 仓库 Pinia Store 组合实战:跨 Store 通信与循环依赖规避指南

2026-09-05 15:34:39作者:董灵辛Dennis

本文聚焦 Vue 状态管理库 Pinia 的 Store 组合(Composing Stores)机制,讲解 Store 之间共享状态与逻辑时的四条核心规则:避免循环依赖、在 setup 顶层获取其他 Store、共享 getter 与 action 的写法,以及 SSR 场景下"await 之前调用所有 Store"的约束。文章以 airi 仓库中真实的设置 Store 聚合层 useSettings 及多处跨 Store 调用代码为佐证,读完你可以掌握多 Store 项目的状态组织方式,并了解 airi 如何用"子 Store + 聚合 Store + storeToRefs"落地这一模式。

为什么需要 Store 组合

Pinia 中的 Store 可以被其他 Store 使用,用于共享状态和逻辑。官方技能文档(features-composing-stores.md)将这一主题归纳为四个规则点:

  1. 规则:避免循环依赖 —— 两个 Store 不能在 setup 阶段直接互相读取状态;
  2. Setup Store:在顶层使用 Store —— 在被组合的 Store 内部,先调用 useXxxStore() 拿到实例,再用于 computed 和 action;
  3. 共享 getter —— Options API 风格中,在 getter 内部调用 useStore()
  4. 共享 action 与 SSR 约束 —— 在 action 内部调用 useStore(),且所有 useStore() 调用必须出现在 await 之前。

airi 是一个包含 Web(apps/stage-web)、桌面(apps/stage-tamagotchi)以及共享 UI 包(packages/stage-ui)的大型 Vue 项目,其中大量使用 defineStore 定义 Store(如 packages/stage-ui/src/stores/settings/index.tsapps/stage-web/src/stores/pwa.ts),Store 组合是其状态层的基本组织方式。下文逐条展开文档规则,并对照仓库源码说明落地细节。

规则一:避免循环依赖

官方文档给出的反例是两个 Store 在 setup 函数体内互相实例化并立即读取对方状态:

// ❌ Infinite loop
const useX = defineStore('x', () => {
  const y = useY()
  y.name // Don't read here!
  return { name: ref('X') }
})

const useY = defineStore('y', () => {
  const x = useX()
  x.name // Don't read here!
  return { name: ref('Y') }
})

问题在于:useX() 的执行会触发 useY(),而 useY() 又反过来触发 useX(),setup 函数在两个 Store 初始化未完成时就被反复执行,形成无限循环。

文档给出的解法是:不要在 setup 顶层读取对方状态,把读取动作推迟到 computed、getter 或 action 中执行

const useX = defineStore('x', () => {
  const y = useY()

  // ✅ Read in computed/actions
  function doSomething() {
    const yName = y.name
  }

  return { name: ref('X'), doSomething }
})

这样 useY() 的实例化发生在 useX 的 setup 中(此时只是获取实例引用,不涉及读取),而真正的状态读取发生在响应式依赖建立之后或方法被调用时,打破了初始化期的相互触发。

从 airi 源码结构看,这个原则被严格执行。以 packages/stage-ui/src/stores/settings/index.ts 中的聚合 Store 为例:

export const useSettings = defineStore('settings', () => {
  const general = useSettingsGeneral()
  const analytics = useSettingsAnalytics()
  const stageModel = useSettingsStageModel()
  const spine = useSettingsSpine()
  const theme = useSettingsTheme()
  const tachie = useTachie()
  const controlsIsland = useSettingsControlsIsland()
  const developer = useSettingsDeveloper()
  // ...
})

useSettings 在 setup 顶层获取了 8 个子 Store 的实例,但没有在 setup 体内直接读取它们的响应式状态;读取行为都被封装在 resetState()(action,第 42-51 行)和通过 storeToRefs 建立的响应式转发(第 54-60 行)中。同时由于子 Store(如 useSettingsGeneral)从不反向调用 useSettings(),整个依赖图是有向无环的,天然规避了循环依赖。

规则二:Setup Store 中在顶层调用其他 Store

文档推荐的组合范式是:在 setup Store 的函数体最上方调用 useXxxStore(),之后的 ref、computed、action 都可以使用这个实例:

import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', () => {
  const user = useUserStore()
  const list = ref([])

  const summary = computed(() => {
    return `Hi ${user.name}, you have ${list.value.length} items`
  })

  function purchase() {
    return apiPurchase(user.id, list.value)
  }

  return { list, summary, purchase }
})

两个要点:

  • computed 可以安全地引用其他 Store:computed 的求值是惰性的,只有被订阅时才会读取 user.name,此时两个 Store 的 setup 均已完成,这正是上一节"推迟读取"思想的响应式版本;
  • action 内部直接读取同样安全purchase() 只在被调用时执行,不产生初始化期依赖。

airi 的 settings 子 Store 本身还演示了另一种"顶层组合":在 setup 顶层组合 Vue composable。例如 packages/stage-ui/src/stores/settings/general.ts 中:

export const useSettingsGeneral = defineStore('settings-general', () => {
  const language = useLocalStorageManualReset<string>('settings/language', '')
  const disableTransitions = useLocalStorageManualReset<boolean>('settings/disable-transitions', true)
  const usePageSpecificTransitions = useLocalStorageManualReset<boolean>('settings/use-page-specific-transitions', true)
  const websocketSecureEnabled = useLocalStorageManualReset<boolean>('settings/websocket/secure-enabled', false)

  function getLanguage() {
    let language = localStorage.getItem('settings/language')
    if (!language) {
      language = navigator.language || 'en'
    }
    return resolveSupportedLocale(language, Object.keys(messages!))
  }

  function resetState() {
    language.reset()
    disableTransitions.reset()
    usePageSpecificTransitions.reset()
    websocketSecureEnabled.reset()
  }

  onMounted(() => language.value = getLanguage())

  return { language, disableTransitions, usePageSpecificTransitions, websocketSecureEnabled, getLanguage, resetState }
})

每个子 Store 以 settings-generalsettings-theme 等独立 id 注册,各自持久化到 localStorage 并暴露 resetState();聚合 Store 再把这些 resetState() 组装成统一的 resetState() 动作(见 settings/index.ts 第 42-51 行)。这是"Store 组合 + composable 组合"在同一 setup 函数中的叠加使用。

规则三:Options 风格 Store 的共享 getter 与共享 action

除 setup 风格外,文档同时给出 Options API 风格(getters / actions 选项)的组合写法。

共享 getter:在 getter 内部调用 useStore()

import { useUserStore } from './user'

export const useCartStore = defineStore('cart', {
  getters: {
    summary(state) {
      const user = useUserStore()
      return `Hi ${user.name}, you have ${state.list.length} items`
    },
  },
})

getter 同样具有惰性求值特性,useUserStore() 的调用发生在 getter 被访问时,因此不会在 Store 初始化阶段形成循环。

共享 action:在 action 内部调用 useStore(),并可借助 this 访问当前 Store 的状态与其他 action:

import { useUserStore } from './user'
import { apiOrderCart } from './api'

export const useCartStore = defineStore('cart', {
  actions: {
    async orderCart() {
      const user = useUserStore()

      try {
        await apiOrderCart(user.token, this.items)
        this.emptyCart()
      } catch (err) {
        displayError(err)
      }
    },
  },
})

在 airi 仓库中,Options 风格与 setup 风格并存(如 apps/stage-web/src/stores/devtools-lag.tsapps/stage-tamagotchi/src/renderer/stores/window.ts 均为 defineStore 定义),跨 Store 调用则集中在函数作用域内。例如 packages/stage-ui/src/stores/background.ts 中的多处 useAiriCardStore() 调用都位于具体方法内部而非模块顶层,符合"调用 Store 应放在函数内而不是模块作用域"(见 SKILL.md Key Recommendations)的官方建议。

规则四:SSR 场景下,await 之前调用所有 Store

对于异步 action,官方文档强调:所有 useStore() 调用必须出现在任何 await 之前

actions: {
  async orderCart() {
    // ✅ All useStore() calls before await
    const user = useUserStore()
    const analytics = useAnalyticsStore()

    try {
      await apiOrderCart(user.token, this.items)
      // ❌ Don't call useStore() after await (SSR issue)
      // const otherStore = useOtherStore()
    } catch (err) {
      displayError(err)
    }
  },
}

原因:在 SSR(Nuxt 等框架)下,useStore() 需要绑定当前请求对应的 Pinia 实例;跨过 await 之后执行上下文可能发生切换(微任务/宏任务边界),此时再调用 useStore() 可能拿到错误的 Pinia 实例,导致状态水合异常。把获取 Store 实例集中放在异步操作之前,可以确保整个 action 生命周期内使用的是同一实例。该约束详见文档中的 SSR 章节(advanced-ssr.md)。

airi 的 useSettings.resetState() 是一个可对照的写法:它 await stageModel.resetState() 之后继续同步调用其余子 Store 的 resetState()settings/index.ts),所有子 Store 实例都在函数入口前就已完成获取,没有在 await 之后重新获取 Store 实例的行为。虽然 airi 主要是 Web/桌面应用、SSR 压力小于 Nuxt 项目,但该写法同样保证了异步重置过程中的实例一致性。

在 airi 中的整体落地方式

综合仓库源码,可以推断 airi 采用了一种"领域子 Store + 聚合 Store"的组合架构:

  1. 按领域拆分子 Storesettings-generalsettings-themesettings-stage-modelsettings-spinesettings-analyicssettings-controls-islandsettings-developer 各自独立注册,状态以 useLocalStorageManualReset(来自 packages/stage-shared)持久化,且各自暴露 resetState()
  2. 聚合 Store 只做转发useSettingssettings/index.ts)在 setup 顶层获取全部子 Store,用 storeToRefs 取出响应式引用后平铺返回,保持旧代码 settings.language 之类的访问方式兼容;
  3. 组合方向单向:聚合 Store → 子 Store,子 Store 之间互不引用,不存在双向读取,因此不会触发循环依赖;
  4. 兼容层显式标记弃用:聚合 Store 的注释明确 @deprecated Use individual setting stores,表明组合层是迁移期的兼容手段,最终形态是让调用方直接使用各子 Store。

这套结构与官方技能的推荐一致:优先使用 Setup Store 承载复杂逻辑(见 SKILL.md 的 Key Recommendations),跨 Store 读取一律放在 computed、getter 或 action 中,而不是 setup 顶层。

要点速查

场景 做法 依据
两 Store 互相需要状态 只在 computed/getter/action 中读取,禁止 setup 顶层互读 features-composing-stores.md "Rule: Avoid Circular Dependencies"
setup Store 组合 useXxxStore() 放在函数体最上方,之后用于 computed/action 同上 "Setup Stores: Use Store at Top"
Options 风格组合 getter/action 内部再调用 useStore() 同上 "Shared Getters / Shared Actions"
异步 action 所有 useStore() 放在第一个 await 之前 同上 "SSR: Call Stores Before Await"
解构子 Store 响应式成员 storeToRefs() 保持响应性 SKILL.md Key Recommendations,airi 聚合 Store 实际采用
模块作用域调用 Store 避免,改在函数内调用(尤其 SSR) SKILL.md Key Recommendations

需要注意的适用前提:本文档基于 Pinia v3.0.4 时代的技能参考(见 SKILL.md 头部标注 "based on Pinia v3.0.4"),airi 仓库实际依赖的 Pinia 版本以 pnpm-lock.yaml 为准;SSR 相关约束(第四条规则)对纯客户端的 Web/桌面应用约束最弱,但在 Nuxt 或任何做了水合的 SSR 应用中必须严格执行。

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