airi 状态管理实战:Pinia Store 中使用 Vue Composables 的完整指南
本篇围绕「在 Pinia Store 中调用 Vue Composables」这一主题展开:先讲清 Option Stores 与 Setup Stores 两条路线的能力边界与限制,再覆盖 SSR 场景下的 hydrate() 与 skipHydrate() 处理方案;随后结合 airi 仓库中 stage-tamagotchi、stage-web 等应用的真实 Store 代码,展示这套模式在 Web / 桌面端多应用架构中的落地方式与测试配套实践。读完后你将能够:正确判断哪种 Store 形态适配哪种 Composable、在桌面/移动多端应用中用 useLocalStorage 等 VueUse 能力持久化状态、并为含 Composable 的 Store 编写可运行的单元测试。
为什么要在 Pinia Store 中使用 Composables
Pinia 是 Vue 官方推荐的状态管理库,airi 的多个前端应用(stage-web、stage-tamagotchi、stage-pocket、ui-server-auth)都在入口中创建 Pinia 实例(见 apps/stage-web/src/main.ts 与 apps/stage-tamagotchi/src/renderer/main.ts 中的 createPinia())。
当状态逻辑是「可复用的有状态逻辑」时——比如读写 localStorage、监听媒体控件、检测断点——直接用原生 ref 手写会重复造轮子。Pinia 允许在 Store 内部调用 Vue Composables(如 VueUse 提供的 useLocalStorage、useMediaControls、useEyeDropper 等),把 Composable 返回的响应式数据直接纳入 Store 的 state 体系,获得 DevTools 可视化、跨组件共享、SSR 水合等能力。本文即 features-composables.md 所描述的这套模式的完整落地指南,其所属的 Pinia 技能索引见 .agents/skills/pinia/SKILL.md。
需要区分两类 Store 形态:Option Stores(选项式,state / getters / actions)和 Setup Stores(defineStore(id, () => {...}) 函数式)。两者对 Composable 的接纳程度差异很大。
Option Stores:只能在 state 中使用「返回可写 ref」的 Composable
Option Stores 调用 Composable 的位置是 state 属性,且只有返回可写 ref 的 Composable 才能工作:
import { defineStore } from 'pinia'
import { useLocalStorage } from '@vueuse/core'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: useLocalStorage('pinia/auth/login', 'bob'),
}),
})
可以工作(返回 ref())的 Composable:
useLocalStorageuseAsyncState
在 Option Stores 中不能工作的:
- 暴露函数的 Composable(Option Stores 的
state不接受函数作为状态) - 暴露只读数据的 Composable(getters 应派生自 state,而 Composable 内部结构无法被 Pinia 拆解为 state/getter)
原因在于 Option Stores 的 state 必须是可被 Pinia 直接接管的可写数据集合,函数型返回值没有「state」语义,只读数据则会被错误地视为可写状态。如果你的需求涉及函数或复杂组合逻辑,应改用 Setup Stores。
Setup Stores:几乎任何 Composable 都能用
Setup Stores 灵活得多,几乎可以任意组合 Composable。官方示例(一个视频播放器 Store,useMediaControls 返回的既有响应式状态、也有方法,Setup Store 都能完整消化):
import { defineStore } from 'pinia'
import { useMediaControls } from '@vueuse/core'
import { ref } from 'vue'
export const useVideoPlayer = defineStore('video', () => {
const videoElement = ref<HTMLVideoElement>()
const src = ref('/data/video.mp4')
const { playing, volume, currentTime, togglePictureInPicture } =
useMediaControls(videoElement, { src })
function loadVideo(element: HTMLVideoElement, newSrc: string) {
videoElement.value = element
src.value = newSrc
}
return {
src,
playing,
volume,
currentTime,
loadVideo,
togglePictureInPicture,
}
})
注意事项:不要返回不可序列化的 DOM 引用。 上例中的 videoElement 只是内部实现细节,刻意没有放进 return——Store 的 state 应保持可序列化(DevTools 序列化、SSR 传输都依赖这一点),DOM 节点这类值留在闭包内部即可。
airi 仓库中的真实示例
airi 的桌面端应用大量采用「Setup Store + VueUse Composable」的组合:
示例一:用 useLocalStorage 持久化用户偏好
useControlsIslandStore 是典型的轻量持久化 Store,完全对应上文的 useLocalStorage 模式:
export const useControlsIslandStore = defineStore('controls-island', () => {
// Persist fade-on-hover preference per user
const fadeOnHoverEnabled = useLocalStorage<boolean>('controls-island/fade-on-hover-enabled', false)
const dontShowItAgainNoticeFadeOnHover = useLocalStorage<boolean>('preferences/dont-show-it-again/notice/fade-on-hover', false)
function enableFadeOnHover() {
fadeOnHoverEnabled.value = true
}
function disableFadeOnHover() {
fadeOnHoverEnabled.value = false
}
return {
fadeOnHoverEnabled,
dontShowItAgainNoticeFadeOnHover,
enableFadeOnHover,
disableFadeOnHover,
}
})
注意两个细节:一是通过 useLocalStorage<boolean>(...) 显式标注泛型,让 TS 推导 key 对应的值类型;二是把 Composable 返回的 ref 原样放进 return,Pinia 会将其识别为 state,任何组件里 store.fadeOnHoverEnabled = true 都会同步写回 localStorage。
示例二:useLocalStorage + watch + 原生 IPC 的组合
useServerChannelSettingsStore 展示了 Composable 参与的更复杂流程:tlsConfig / hostname / authToken 三个字段均由 useLocalStorage 持久化(默认值分别是 null、'127.0.0.1'、''),随后用 watch 监听这三个 ref,将变更通过 useElectronEventaInvoke 桥接的 IPC 调用同步到主进程;同步失败时把三个字段回滚到旧值并弹出 toast 错误。这里可以看到 Composable 返回的 ref 与 Setup Store 闭包内的本地 shallowRef(如 syncingWithServer)协同工作——本地瞬时状态留在闭包中,只有需要持久化/共享的字段才进入 Store state,这正是「不把内部实现细节放进 return」原则的延伸实践。
示例三:仓库内自研 Composable 也遵循同一模式
mcp.ts 使用了 airi 自己的 useLocalStorageManualReset Composable(而非直接用 VueUse 原版)来管理 MCP 服务的 serverCmd、serverArgs、connected 三个持久化字段。从源码结构看,该封装在 useLocalStorage 基础上提供了手动重置语义,说明仓库把「可复用有状态逻辑」沉淀为自有 Composable 后,仍然无缝地放进 Setup Store——Setup Store 对 Composable 来源没有任何限制。
SSR 场景下的水合(Hydration)处理
在 SSR 应用中,服务端渲染产出的 state 会在客户端初始化时尝试水合进 Store。但像 localStorage 这类客户端专属数据,服务端并不存在,盲目水合会覆盖客户端刚读到的真实值。Pinia 提供了两种解法,分别对应两种 Store 形态。
Option Stores:定义 hydrate()
通过 hydrate() 钩子显式处理客户端水合(advanced-ssr 有更完整的 SSR 背景):
import { defineStore } from 'pinia'
import { useLocalStorage } from '@vueuse/core'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: useLocalStorage('pinia/auth/login', 'bob'),
}),
hydrate(state, initialState) {
// Ignore server state, read from browser
state.user = useLocalStorage('pinia/auth/login', 'bob')
},
})
语义是:忽略服务端传来的 initialState,直接从浏览器重新读取。
Setup Stores:用 skipHydrate() 标记
Setup Stores 则用 skipHydrate() 精确标记哪些 state 不应从服务端水合:
import { defineStore, skipHydrate } from 'pinia'
import { useEyeDropper, useLocalStorage } from '@vueuse/core'
export const useColorStore = defineStore('colors', () => {
const { isSupported, open, sRGBHex } = useEyeDropper()
const lastColor = useLocalStorage('lastColor', sRGBHex)
return {
// Skip hydration for client-only state
lastColor: skipHydrate(lastColor),
open, // Function - no hydration needed
isSupported, // Boolean - not reactive
}
})
关键限制:skipHydrate() 只作用于 state 属性(ref),对函数和非响应式值无效。 上例中 open 是函数、isSupported 是非响应式布尔值,它们本来就不参与水合,无需也不能包裹 skipHydrate()。
airi 自身的实践与这条规则一致:usePWAStore 是 stage-web(Vite 应用)中的 Setup Store,内部在 onMounted 里显式判断 import.meta.env.SSR 才注册 Service Worker 更新逻辑——在 Web/SSR 混合环境下,客户端专属副作用被明确隔离在 Store 的挂载钩子中,避免在服务端执行。从源码结构看,airi 的 Composable 型 Store 主要面向 SPA/桌面端场景(Electron、Capacitor 包装),并未启用 Nuxt 式的全量 SSR 水合流程,因此仓库内没有出现 skipHydrate 的调用;如果你的应用确有 SSR,则按上文两种方案处理。
为含 Composable 的 Store 编写测试
含 Composable 的 Store 的单元测试需要处理「Composable 依赖浏览器 API」的问题。airi 仓库的测试套件展示了标准做法:mock 掉 VueUse Composable + 注入测试用 Pinia 实例。
server-channel.test.ts 中通过 vi.mock('@vueuse/core', ...) 提供了 useLocalStorage 的替身(约在 L31 处以 (key, initialValue) => ref(initialValue) 形式返回一个纯内存 ref),并在 beforeEach 中用 createPinia() + setActivePinia() / disposePinia() 管理每个用例独立的 Pinia 实例。channel-server.test.ts 同样以 useLocalStorage: (_key, initialValue) => ref(initialValue) 的方式做轻量 mock(见 L110 附近)。
这种模式让测试完全脱离 localStorage 与 DOM:Composable 的持久化语义被替换为内存 ref,Store 本身的逻辑(watch 联动、回滚、IPC 调用)则可被完整断言。
小结:选型决策清单
结合本文与 SKILL.md 中的官方建议,可以沉淀出如下决策路径:
| 场景 | 推荐形态 | 依据 |
|---|---|---|
Composable 只返回可写 ref(useLocalStorage、useAsyncState 等) |
Option Stores 可用 | Option Stores 的 state 只能接管可写 ref |
| Composable 返回函数 / 只读数据 / 需要 watch、computed | Setup Stores | 官方 Key Recommendations 明确「Prefer Setup Stores for complex logic, composables, and watchers」 |
| 状态需持久化到 localStorage | useLocalStorage 放入 Setup Store,显式标注泛型 |
参见 controls-island.ts、server-channel.ts |
| 多窗口/多端同步场景 | 可参考 mcp.ts 中用持久化字段做窗口间状态同步的写法 | useLocalStorageManualReset 封装了手动重置语义 |
| SSR 应用 | Option Stores 用 hydrate();Setup Stores 用 skipHydrate() 且只包 ref |
见 advanced-ssr |
| 测试 | mock @vueuse/core + createPinia()/setActivePinia() |
参见 server-channel.test.ts |
两条铁律贯穿始终:Store 的 state 保持可序列化(DOM 引用等内部细节留在闭包),skipHydrate() 只用于 ref 型 state。遵循这两点,Composable 与 Pinia 的组合就能在 airi 这类 Web/桌面多端应用中稳定工作。
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