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 | ❌ 較難隔離 | ✅ 完全可控 |
線上工具推薦
- JSON格式化工具 — 除錯Pinia持久化資料時,格式化檢視localStorage中的Store快照
- 雜湊編碼工具 — 為持久化key生成確定性雜湊,避免多應用key衝突
- cURL轉程式碼工具 — 將API請求轉為Pinia Store中的action程式碼
總結與展望
Pinia插件系統是Vue 3狀態管理中被嚴重低估的能力。透過5個核心模式——基礎插件結構、持久化、DevTools整合、網路請求狀態、插件組合框架——你可以將Store中的通用邏輯抽離為可複用插件,讓業務Store保持精簡。2026年,Pinia 3有望引入官方持久化插件和更完善的插件生命週期API,屆時插件生態將更加成熟。
延伸閱讀
- Pinia Official Plugin Docs — Pinia插件官方文件
- pinia-plugin-persistedstate — 社群持久化插件參考實現
- Vue 3 Design Patterns — Vue 3設計模式與最佳實踐
- TypeScript Module Augmentation — 擴充Pinia型別的方法
- Pinia 3 Roadmap — Pinia未來版本規劃討論
本站提供瀏覽器本地工具,免註冊即可試用 →