首页
/ Airi 中的 Pinia 插件机制:为所有 Store 注入自定义属性、状态与行为的完整实践

Airi 中的 Pinia 插件机制:为所有 Store 注入自定义属性、状态与行为的完整实践

2026-09-05 16:08:39作者:董宙帆

Pinia 的插件(Plugin)是跨 Store 统一增强能力的关键扩展点:通过一个在 pinia.use() 注册的函数,即可为应用中的每一个 store 注入自定义属性、响应式状态或全新行为。本文以 Pinia 官方插件文档骨架为主线,完整覆盖插件上下文、属性/状态注入、markRaw 外部对象、自定义 Store 选项、TypeScript 模块增强与 Nuxt 集成等全部实操内容,并结合 airi 仓库中真实落地的 piniaPluginTracing 与跨渲染器同步插件源码,展示这些模式在 Web / Electron 多窗口场景下的工程化运用。

插件的基本形态:一个返回对象的函数

Pinia 插件最简单的形态是一个函数:接收插件上下文(可以完全忽略),返回一个对象,该对象的所有键值对被展开到每一个 store 实例上。以官方文档中的示例为基础:

import { createPinia } from 'pinia'

function SecretPiniaPlugin() {
  return { secret: 'the cake is a lie' }
}

const pinia = createPinia()
pinia.use(SecretPiniaPlugin)

// In any store
const store = useStore()
store.secret // 'the cake is a lie'

这里的关键点是注册时机:pinia.use() 既对新创建的 store 生效,也会立即应用于已经存在的 store——即使用先调用 useStore() 再注册插件,插件依然会补齐属性。airi 的 Web 端入口 apps/stage-web/src/main.ts 就是这个模式的标准用法:

const pinia = createPinia()
const synced = setupSynced()
pinia.use(synced.pinia)
if (import.meta.env.DEV)
  pinia.use(piniaPluginTracing)

可以看出两个工程细节:其一,多个插件按注册顺序依次执行,synced.pinia 是运行时对象的 plugin 属性(下文展开);其二,调试插件通过 import.meta.env.DEV 门控,仅在开发构建中安装,避免生产环境残留跟踪逻辑。

插件上下文(PiniaPluginContext):四大字段

插件函数接收一个上下文对象,类型为 PiniaPluginContext,这是编写任何非平凡插件的起点:

import { PiniaPluginContext } from 'pinia'

export function myPiniaPlugin(context: PiniaPluginContext) {
  context.pinia   // pinia instance
  context.app     // Vue app instance
  context.store   // store being augmented
  context.options // store definition options
}

各字段的作用边界如下:

  • context.pinia:当前 pinia 实例,可访问 $id、已注册插件列表(_p)等;
  • context.app:宿主 Vue 应用实例,可用于 app.provide() 向组件注入资源;
  • context.store正在被扩展的 store 本身,这是绝大多数插件操作的主体,直接在其上赋值即可挂载属性;
  • context.options:Options Store 的原始定义对象(setup store 场景下见下文的第三个参数),是读取自定义选项的唯一入口。

注意 context 对每个 store 调用一次,因此插件函数体内不能假设只执行一次;如需全局单例资源(如 channel 连接),应使用模块级变量缓存。

添加自定义属性:返回对象 vs 直接赋值

官方文档给出两种等价路径:

方式一:返回对象。返回值会被展开到 store 上,并且默认即被 devtools 识别:

pinia.use(() => ({ hello: 'world' }))

方式二:直接赋值给 store。这种方式下,开发工具不会自动感知新属性,需要手动登记:

pinia.use(({ store }) => {
  store.hello = 'world'
  // For devtools visibility in dev mode
  if (process.env.NODE_ENV === 'development') {
    store._customProperties.add('hello')
  }
})

store._customProperties 是一个 Set<string>,devtools 通过它枚举非 state 来源的自定义属性。仅登记属性名即可让 DevTools 在面板中展示该字段,避免“运行时存在但面板里看不到”的排查成本。

添加自定义 State:同时写入 store 与 store.$state

与直接挂属性不同,自定义 state 需要同时操作两个位置,才能兼顾响应式、$patch/$state 重置语义以及 SSR 与 devtools 的正确性:

import { toRef, ref } from 'vue'

pinia.use(({ store }) => {
  if (!store.$state.hasOwnProperty('hasError')) {
    const hasError = ref(false)
    store.$state.hasError = hasError
  }
  store.hasError = toRef(store.$state, 'hasError')
})

这段代码体现了三条规则:

  1. store.$state 为事实源:先创建 ref(false) 写入 store.$state.hasError,这样 store.$state 的整体替换(如 $patch 或 hydration)会自然覆盖新字段;
  2. toRef 建立代理store.hasError = toRef(store.$state, 'hasError') 使 store 顶层出现一个只读视图,读写都透传到 $state,组件模板中 store.hasError 与普通 state 字段无差别;
  3. hasOwnProperty 守卫:防止同一插件被重复安装(或测试中多次 createPinia 复用 store)时重复初始化导致覆盖。

注入非响应式外部对象:markRaw

Vue Router 实例、WebGL 上下文、第三方 SDK 这类大型且不应被代理化的对象,注入前必须用 markRaw() 包裹,否则 Vue 的 reactive 系统会递归代理它们,造成性能损耗与不可预期的副作用:

import { markRaw } from 'vue'
import { router } from './router'

pinia.use(({ store }) => {
  store.router = markRaw(router)
})

配合下文的 PiniaCustomProperties 模块增强后,store.router 即获得完整的 Router 类型推导。

自定义 Store 选项:让插件读取 store 的私有配置

这是插件机制最灵活的用法:store 定义中声明自定义选项,插件统一消费。文档以「按 action 名做防抖」为例,展示了 Options Store 的完整链路。

Store 定义侧:

defineStore('search', {
  actions: {
    searchContacts() { /* ... */ },
  },
  debounce: {
    searchContacts: 300,
  },
})

插件侧读取 options 并用 lodash debounce 包装原 action:

import debounce from 'lodash/debounce'

pinia.use(({ options, store }) => {
  if (options.debounce) {
    return Object.keys(options.debounce).reduce((acc, action) => {
      acc[action] = debounce(store[action], options.debounce[action])
      return acc
    }, {})
  }
})

注意这里同时使用了两种增强手段:直接改写了 store.searchContacts(原 action 被防抖版本替换),又通过返回值再次挂载——插件返回值会覆盖同名属性,最终 store.searchContacts 就是防抖后的函数。

对于 Setup Store,由于没有 options 对象,自定义选项作为 defineStore第三个参数传入,插件中仍通过 context.options 读到同一份数据:

defineStore(
  'search',
  () => { /* ... */ },
  {
    debounce: { searchContacts: 300 },
  }
)

两种写法对插件是透明的,这让「同一套插件代码兼容两种 store 风格」成为可能。

TypeScript 模块增强:类型层面的三块拼图

仅靠运行时赋值,store.hellostore.hasErrordebounce 选项在 TS 中都报类型错误。Pinia 预留了三个可合并的接口,分别对应三种增强场景:

自定义属性(挂到 store 实例上):

import 'pinia'
import type { Router } from 'vue-router'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    router: Router
    hello: string
  }
}

自定义 State$state 上的字段):

declare module 'pinia' {
  export interface PiniaCustomStateProperties<S> {
    hasError: boolean
  }
}

自定义 OptionsdefineStore 的 options 字段):

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    debounce?: Partial<Record<keyof StoreActions<Store>, number>>
  }
}

其中 keyof StoreActions<Store> 的写法值得注意:它把 debounce 的键约束为该 store 真实存在的 action 名,拼错 action 名时编译器直接报错。实际落地时,这三段增强声明应集中放在每个应用(或共享包)的全局类型文件中,保证所有引用方统一生效。

在插件中订阅:$subscribe 与 $onAction

插件内部可以注册 store.$subscribe(state 变更回调)与 store.$onAction(action 生命周期钩子),实现日志、埋点、跨窗口通知等横切逻辑:

pinia.use(({ store }) => {
  store.$subscribe(() => {
    // React to state changes
  })
  store.$onAction(() => {
    // React to actions
  })
})

airi 的 piniaPluginTracing 正是该模式的完整工程化实现,位于 packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts

export const piniaPluginTracing: PiniaPlugin = ({ store }) => {
  const tracedWindow = startRateTracing()

  if (tracedWindow) {
    store.$subscribe((mutation) => {
      incrementRateTraceCount(tracedWindow.mutations, mutation.storeId)
      incrementRateTraceCount(tracedWindow.mutationTypes, mutation.type)
    }, { detached: true, flush: 'sync' })
  }

  store.$onAction(({ name, after, onError }) => {
    if (tracedWindow)
      incrementRateTraceCount(tracedWindow.actions, `${store.$id}.${name}`)

    const event = {
      invocationId: nanoid(),
      storeId: store.$id,
      actionName: name,
      ...(typeof location === 'undefined' ? {} : { sourceUrl: location.href }),
    }

    emitActionEvent(event, 'started')
    after(() => emitActionEvent(event, 'completed'))
    onError((error) => {
      if (tracedWindow)
        tracedWindow.actionFailures += 1
      emitActionEvent(event, 'failed', error)
    })
  })
}

对照文档中的模式,这份实现补充了几个细节:

  • $onAction 的三段式钩子:回调参数里的 afteronError 分别注册 action 成功完成与抛错时的回调,配合 started 事件即可完整追踪 started → completed | failed 生命周期。事件通过 BroadcastChannel(见 emitActionEvent)发出,供独立渲染的调试浮层消费——注意插件刻意不保留 action 参数、结果或 state 快照,只记录调用元信息;
  • $subscribe 的第二参数{ detached: true, flush: 'sync' } 使订阅不随 store 销毁、且以同步 flush 触发,保证统计窗口内计数不丢帧;
  • 限频统计窗:开发环境下在 localStorageairi:debug:pinia-tracing = 'true' 后刷新,插件每 5 秒(rateTraceIntervalMs = 5_000)输出一次 action/mutation 速率摘要,并对 top 10 热点按名聚合(见 startRateTracingreportRateTraceWindow)。其行为有测试用例 pinia-plugin-tracing.test.ts 覆盖;
  • 安装位置:该插件随 @proj-airi/stage-ui/libs/piniaindex.ts 统一导出,除 apps/stage-web/src/main.ts 外,Electron 桌面端 apps/stage-tamagotchi/src/renderer/main.ts 与移动端 apps/stage-pocket/src/main.ts 均以同样的 pinia.use(...) 方式安装,保证了三个 stage 应用插件行为一致。

跨渲染器同步插件:插件返回值的另一种打开方式

airi 的 stage 应用常存在「同一浏览器多个渲染器 / 多个窗口」运行同一套 UI 的形态,此时 state 需要在渲染器之间选举主从并同步。仓库将这一能力封装为 setupSynced(),位于 packages/stage-ui/src/libs/pinia/setup-synced.ts

export function setupSynced(options: Pick<SyncedOptions, 'leadership'> = {}): { pinia: PiniaPlugin, vue: Plugin } {
  const runtime = createSyncedPiniaPlugin({
    namespace: 'airi:stage:pinia',
    // Chat and image-generation actions can outlive the plugin's 30-second
    // default. Keep the timeout aligned with the previous Electron coordinator.
    callTimeout: 5 * 60 * 1000,
    ...options,
    onError(error) {
      console.error('[stage-synced-pinia] Synchronization failed:', error)
    },
  })
  // ... 封装 Vue 侧 provide/生命周期清理
  return {
    pinia: runtime.plugin,
    vue,
  }
}

这里有几个值得留意的点:runtime.plugin 是一个标准 PiniaPlugin,因此同步能力完全复用 pinia.use() 通道,与文档描述的基本插件形态无缝衔接;callTimeout 从插件默认 30 秒调整为 5 分钟,注释明确说明原因是「聊天与图像生成类 action 可能超时」——这类长任务 action 正是上文 $onAction 追踪要关注的对象;返回的 vue 插件则通过 app.provide(injectKeyPiniaSynced, runtime) 暴露运行时,并提供 pagehide/onUnmount 双重清理,保证同步通道随应用卸载释放。组件侧可通过 usePiniaSynced() 获取运行时(未安装时会抛出明确错误提示先安装 Vue 插件)。

Nuxt 环境:在 Nuxt 插件中注册 Pinia 插件

文档最后一节覆盖 Nuxt 场景:由于 Nuxt 中 pinia 实例通过 $pinia 注入,官方建议将 pinia.use() 放进一个 Nuxt 插件文件:

// plugins/myPiniaPlugin.ts
import { PiniaPluginContext } from 'pinia'

function MyPiniaPlugin({ store }: PiniaPluginContext) {
  store.$subscribe((mutation) => {
    console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}`)
  })
  return { creationTime: new Date() }
}

export default defineNuxtPlugin(({ $pinia }) => {
  $pinia.use(MyPiniaPlugin)
})

这里同时演示了「直接改 store」与「返回新增属性」的混合:$subscribe 挂在 store 上做变更日志,返回的 creationTime 自动成为每个 store 的新属性。airi 目前基于 Vite + Vue 直接 createApp().use(pinia) 装配(见 apps/stage-web/src/main.ts),未采用 Nuxt;该小节主要面向同样基于 Pinia 的 Nuxt 项目读者,模式本身与框架无关。

小结:插件机制的能力矩阵

需求 官方文档模式 airi 仓库对应实践
全 store 注入只读属性 返回对象(如 secretcreationTime N/A(通用模式)
开发期调试/埋点 $subscribe + $onAction piniaPluginTracingDEV 门控安装
跨渲染器 state 同步 第三方 PiniaPlugin 经 pinia.use() 安装 setupSyncedcallTimeout 调至 5 分钟
类型安全 PiniaCustomProperties / PiniaCustomStateProperties<S> / DefineStoreOptionsBase 三段模块增强 类型声明随包内类型文件组织

掌握这条链路后,读者可以在任何 Pinia 应用里按「注册插件 → 消费 context → 返回对象或直接改 store → TS 模块增强」四步扩展 store 能力,并在 airi 仓库中直接参照 tracing 与 synced 两个真实插件实现,验证 action 生命周期追踪与多窗口状态选举的落地细节。

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

项目优选

收起
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.79 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
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384