Vue 3 Pinia插件系統實戰:構建自定義Store插件的5個核心模式

前端工程

開篇引入

你是否遇到過這些場景:每次建立Store都要手動寫localStorage持久化邏輯,DevTools裡看不到Store的自訂除錯資訊,網路請求的loading/error狀態散落在各個元件中難以統一管理?Pinia雖然提供了簡潔的Store API,但其插件系統卻常被開發者忽視——而這恰恰是解決上述痛點的關鍵。

Pinia插件系統允許你在所有Store建立時注入通用邏輯,無需修改每個Store的定義。本文將透過5個核心模式,帶你從零構建生產級Pinia插件。

核心概念速查

概念 說明
Pinia Plugin 在Store建立時執行的函式,可擴充Store功能
PiniaPlugin介面 ({ store }) => void,插件函式簽名
store.$onAction 訂閱Store中所有action的呼叫
store.$subscribe 訂閱Store中所有state的變化
持久化 將Store狀態自動同步到儲存媒介
DevTools Vue瀏覽器開發工具,可除錯Store狀態
插件註冊 透過pinia.use()註冊全域插件
Store擴充 透過插件向Store注入新的state/getter/action

問題分析:Pinia插件的5大挑戰

1. 持久化需手動實現:每個需要持久化的Store都要重複編寫localStorage讀寫邏輯,且需處理序列化、版本遷移等邊界情況。

2. DevTools整合困難:自訂Store資料在DevTools中展示不友善,除錯時無法追蹤插件注入的狀態變化。

3. 插件API不熟悉$onAction$subscribe等鉤子的呼叫時機和引數結構不直觀,容易誤用。

4. Store生命週期鉤子缺失:Pinia沒有提供Store銷燬鉤子,插件註冊的副作用難以自動清理。

5. 插件組合衝突:多個插件同時修改Store時,屬性覆蓋和執行順序問題難以預測。

模式1:基礎Pinia插件結構

import type { PiniaPluginContext } from 'pinia'

interface PluginOptions {
  prefix?: string
}

export function createLoggerPlugin(options: PluginOptions = {}) {
  const { prefix = '[Pinia]' } = options

  return ({ store }: PiniaPluginContext) => {
    store.$onAction(({ name, args, after, onError }) => {
      const startTime = Date.now()
      console.log(`${prefix} ${store.$id}/${name} started`, args)

      after((result) => {
        console.log(
          `${prefix} ${store.$id}/${name} finished (${Date.now() - startTime}ms)`,
          result
        )
      })

      onError((error) => {
        console.error(
          `${prefix} ${store.$id}/${name} failed`,
          error
        )
      })
    })
  }
}

// 註冊
const pinia = createPinia()
pinia.use(createLoggerPlugin({ prefix: '[App]' }))

模式2:持久化插件實現

interface PersistenceOptions {
  key?: string
  storage?: Storage
  paths?: string[]
  serializer?: {
    serialize: (value: unknown) => string
    deserialize: (value: string) => unknown
  }
}

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S> {
    persistence?: PersistenceOptions
  }
}

export function createPersistencePlugin() {
  return ({ store, options }: PiniaPluginContext) => {
    const config = options.persistence
    if (!config) return

    const {
      key = store.$id,
      storage = localStorage,
      paths,
      serializer = {
        serialize: JSON.stringify,
        deserialize: JSON.parse,
      },
    } = config

    const hydrate = () => {
      try {
        const raw = storage.getItem(key)
        if (raw) {
          const data = serializer.deserialize(raw)
          if (paths) {
            paths.forEach((path) => {
              if (path in data) {
                store.$patch({ [path]: data[path] })
              }
            })
          } else {
            store.$patch(data)
          }
        }
      } catch (e) {
        console.error(`[PiniaPersistence] Failed to hydrate ${key}:`, e)
      }
    }

    hydrate()

    store.$subscribe((_mutation, state) => {
      try {
        const data = paths
          ? paths.reduce((acc, path) => {
              acc[path] = state[path]
              return acc
            }, {} as Record<string, unknown>)
          : state
        storage.setItem(key, serializer.serialize(data))
      } catch (e) {
        console.error(`[PiniaPersistence] Failed to persist ${key}:`, e)
      }
    })
  }
}

// 使用
export const useUserStore = defineStore('user', {
  state: () => ({ token: '', preferences: {} }),
  persistence: {
    paths: ['token', 'preferences'],
    storage: sessionStorage,
  },
})

模式3:DevTools整合插件

export function createDevToolsPlugin() {
  return ({ store, app }: PiniaPluginContext) => {
    if (import.meta.env.DEV) {
      store._customProperties = new Set()

      const original$patch = store.$patch.bind(store)
      store.$patch = function (...args: unknown[]) {
        const snapshot = JSON.parse(JSON.stringify(store.$state))
        console.groupCollapsed(`[DevTools] ${store.$id}.$patch`)
        console.table({ before: snapshot, after: 'pending...' })
        console.groupEnd()
        return original$patch(...args)
      }

      app.config.globalProperties.$piniaDevTools = {
        getStoreState: (id: string) => {
          const targetStore = pinia._s.get(id)
          return targetStore ? JSON.parse(JSON.stringify(targetStore.$state)) : null
        },
        getTimeTravelLog: () => {
          return store._timeTravelLog || []
        },
      }
    }
  }
}

模式4:網路請求狀態插件

interface AsyncState {
  loading: boolean
  error: string | null
  lastFetch: number | null
}

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S> {
    asyncActions?: Record<string, (...args: unknown[]) => Promise<unknown>>
  }
}

export function createAsyncActionPlugin() {
  return ({ store, options }: PiniaPluginContext) => {
    const asyncConfig = options.asyncActions
    if (!asyncConfig) return

    const asyncStates = reactive<Record<string, AsyncState>>({})

    Object.entries(asyncConfig).forEach(([name, action]) => {
      asyncStates[name] = { loading: false, error: null, lastFetch: null }

      store[name] = async (...args: unknown[]) => {
        asyncStates[name].loading = true
        asyncStates[name].error = null
        try {
          const result = await action.call(store, ...args)
          asyncStates[name].lastFetch = Date.now()
          return result
        } catch (e) {
          asyncStates[name].error = (e as Error).message
          throw e
        } finally {
          asyncStates[name].loading = false
        }
      }
    })

    store.$asyncStates = readonly(asyncStates)
  }
}

// 使用
export const useApiStore = defineStore('api', {
  state: () => ({ users: [] }),
  asyncActions: {
    async fetchUsers(this: any) {
      const res = await fetch('/api/users')
      this.users = await res.json()
    },
  },
})

// 元件中
const apiStore = useApiStore()
apiStore.$asyncStates.fetchUsers.loading // boolean

模式5:生產級插件組合框架

interface PluginConfig {
  id: string
  plugin: PiniaPlugin
  order?: number
  enabled?: boolean
}

export class PiniaPluginManager {
  private plugins: PluginConfig[] = []

  register(config: PluginConfig): this {
    this.plugins.push(config)
    return this
  }

  install(pinia: Pinia, app: App): void {
    const enabledPlugins = this.plugins
      .filter((p) => p.enabled !== false)
      .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))

    enabledPlugins.forEach(({ id, plugin }) => {
      try {
        pinia.use(plugin)
        if (import.meta.env.DEV) {
          console.log(`[PiniaPluginManager] Registered: ${id}`)
        }
      } catch (e) {
        console.error(`[PiniaPluginManager] Failed to register ${id}:`, e)
      }
    })
  }
}

// 使用
const manager = new PiniaPluginManager()
  .register({ id: 'logger', plugin: createLoggerPlugin(), order: 10 })
  .register({ id: 'persistence', plugin: createPersistencePlugin(), order: 20 })
  .register({ id: 'devtools', plugin: createDevToolsPlugin(), order: 30, enabled: import.meta.env.DEV })
  .register({ id: 'async', plugin: createAsyncActionPlugin(), order: 40 })

manager.install(pinia, app)

避坑指南:5大常見陷阱

1. ❌ 在插件中直接修改store.$state → ✅ 使用store.$patch()觸發響應式更新

2. ❌ 忘記處理storage異常 → ✅ 持久化插件必須try-catch,防止儲存滿或隱私模式崩潰

3. ❌ 插件中建立的響應式副作用無清理 → ✅ 利用onScopeDispose或元件卸載時手動清理

4. ❌ 多個插件注入同名屬性 → ✅ 使用命名空間前綴如$persistence_status避免衝突

5. ❌ 在SSR中使用localStorage → ✅ 偵測環境,SSR時跳過瀏覽器API或使用cookie

報錯排查:10大常見錯誤

錯誤訊息 原因 解決方案
Cannot read property 'getItem' of undefined SSR環境無localStorage 偵測typeof window後再使用storage
getActivePinia was called but no pinia was active 插件註冊在Store建立之後 確保pinia.use()在Store建立前呼叫
Maximum call stack exceeded $subscribe回呼中再次修改state導致迴圈 在回呼中判斷是否為自身觸發
Store "$asyncStates" is not defined 型別宣告未生效 確保declare module 'pinia'在d.ts中
Cannot set property which has only getter 插件注入唯讀屬性後嘗試修改 使用Object.defineProperty設定writable
Plugin did not return a function 插件工廠函式未呼叫 pinia.use(createPlugin())而非pinia.use(createPlugin)
hydration mismatch 持久化資料與SSR初始狀態不一致 SSR時跳過hydrate或使用一致的資料來源
$onAction callback not triggered 在action執行後才註冊監聽 確保在Store建立後立即註冊$onAction
Storage quota exceeded localStorage空間不足 限制持久化欄位或使用IndexedDB
Property '$asyncStates' does not exist TypeScript型別擴充未生效 檢查d.ts檔案是否被tsconfig包含

進階技巧

1. 插件熱更新:在開發模式下,利用Vite HMR實現插件邏輯的熱替換,無需重新整理頁面。

2. 插件配置驗證:使用zod或TypeBox對插件選項進行執行時校驗,在開發階段提前發現配置錯誤。

3. 插件效能監控:在$subscribe$onAction中記錄執行耗時,超過閾值時發出警告。

4. 插件間通訊:透過pinia._p存取已註冊插件列表,實現插件間的協調與依賴。

5. 條件性插件註冊:根據路由、使用者許可權等條件動態註冊插件,減少不必要的執行時開銷。

對比分析:Pinia插件 vs Vuex插件 vs 手動狀態管理

特性 Pinia插件 Vuex插件 手動狀態管理
型別安全 ✅ 完整TS支援 ❌ 需額外配置 ✅ 自行保證
插件API簡潔度 ✅ 單函式簽名 ❌ 複雜的subscribe/action -
DevTools整合 ✅ 原生支援 ✅ 原生支援 ❌ 需手動實現
Store擴充能力 ✅ 注入state/action ❌ 僅mutation/action ✅ 自由擴充
持久化實現 ✅ 插件一鍵實現 ❌ 需第三方函式庫 ✅ 手動實現
SSR相容 ✅ 內建支援 ⚠️ 需注意 ✅ 自行處理
學習曲線
可測試性 ✅ 易於mock ❌ 較難隔離 ✅ 完全可控

線上工具推薦

  1. JSON格式化工具 — 除錯Pinia持久化資料時,格式化檢視localStorage中的Store快照
  2. 雜湊編碼工具 — 為持久化key生成確定性雜湊,避免多應用key衝突
  3. cURL轉程式碼工具 — 將API請求轉為Pinia Store中的action程式碼

總結與展望

Pinia插件系統是Vue 3狀態管理中被嚴重低估的能力。透過5個核心模式——基礎插件結構、持久化、DevTools整合、網路請求狀態、插件組合框架——你可以將Store中的通用邏輯抽離為可複用插件,讓業務Store保持精簡。2026年,Pinia 3有望引入官方持久化插件和更完善的插件生命週期API,屆時插件生態將更加成熟。

延伸閱讀

  1. Pinia Official Plugin Docs — Pinia插件官方文件
  2. pinia-plugin-persistedstate — 社群持久化插件參考實現
  3. Vue 3 Design Patterns — Vue 3設計模式與最佳實踐
  4. TypeScript Module Augmentation — 擴充Pinia型別的方法
  5. Pinia 3 Roadmap — Pinia未來版本規劃討論

本站提供瀏覽器本地工具,免註冊即可試用 →

#Pinia插件系统#Vue3状态管理#Store插件#持久化插件#2026#前端工程