首页
/ airi 状态管理实战:Pinia Store 中使用 Vue Composables 的完整指南

airi 状态管理实战:Pinia Store 中使用 Vue Composables 的完整指南

2026-09-05 17:16:43作者:晏闻田Solitary

本篇围绕「在 Pinia Store 中调用 Vue Composables」这一主题展开:先讲清 Option Stores 与 Setup Stores 两条路线的能力边界与限制,再覆盖 SSR 场景下的 hydrate()skipHydrate() 处理方案;随后结合 airi 仓库中 stage-tamagotchistage-web 等应用的真实 Store 代码,展示这套模式在 Web / 桌面端多应用架构中的落地方式与测试配套实践。读完后你将能够:正确判断哪种 Store 形态适配哪种 Composable、在桌面/移动多端应用中用 useLocalStorage 等 VueUse 能力持久化状态、并为含 Composable 的 Store 编写可运行的单元测试。

为什么要在 Pinia Store 中使用 Composables

Pinia 是 Vue 官方推荐的状态管理库,airi 的多个前端应用(stage-webstage-tamagotchistage-pocketui-server-auth)都在入口中创建 Pinia 实例(见 apps/stage-web/src/main.tsapps/stage-tamagotchi/src/renderer/main.ts 中的 createPinia())。

当状态逻辑是「可复用的有状态逻辑」时——比如读写 localStorage、监听媒体控件、检测断点——直接用原生 ref 手写会重复造轮子。Pinia 允许在 Store 内部调用 Vue Composables(如 VueUse 提供的 useLocalStorageuseMediaControlsuseEyeDropper 等),把 Composable 返回的响应式数据直接纳入 Store 的 state 体系,获得 DevTools 可视化、跨组件共享、SSR 水合等能力。本文即 features-composables.md 所描述的这套模式的完整落地指南,其所属的 Pinia 技能索引见 .agents/skills/pinia/SKILL.md

需要区分两类 Store 形态:Option Stores(选项式,state / getters / actions)和 Setup StoresdefineStore(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:

  • useLocalStorage
  • useAsyncState

在 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 服务的 serverCmdserverArgsconnected 三个持久化字段。从源码结构看,该封装在 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 自身的实践与这条规则一致:usePWAStorestage-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(useLocalStorageuseAsyncState 等) 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.tsserver-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/桌面多端应用中稳定工作。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384