airi 仓库 Pinia Store 组合实战:跨 Store 通信与循环依赖规避指南
本文聚焦 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)将这一主题归纳为四个规则点:
- 规则:避免循环依赖 —— 两个 Store 不能在 setup 阶段直接互相读取状态;
- Setup Store:在顶层使用 Store —— 在被组合的 Store 内部,先调用
useXxxStore()拿到实例,再用于 computed 和 action; - 共享 getter —— Options API 风格中,在 getter 内部调用
useStore(); - 共享 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.ts、apps/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-general、settings-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.ts、apps/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"的组合架构:
- 按领域拆分子 Store:
settings-general、settings-theme、settings-stage-model、settings-spine、settings-analyics、settings-controls-island、settings-developer各自独立注册,状态以useLocalStorageManualReset(来自 packages/stage-shared)持久化,且各自暴露resetState(); - 聚合 Store 只做转发:
useSettings(settings/index.ts)在 setup 顶层获取全部子 Store,用storeToRefs取出响应式引用后平铺返回,保持旧代码settings.language之类的访问方式兼容; - 组合方向单向:聚合 Store → 子 Store,子 Store 之间互不引用,不存在双向读取,因此不会触发循环依赖;
- 兼容层显式标记弃用:聚合 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 应用中必须严格执行。
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