Vue3 Pinia狀態管理:從Store設計到持久化的6個企業級最佳實踐
前端工程
你的Vue3專案狀態管理又失控了
元件A改了使用者資訊,元件B沒重新整理;登入狀態存在localStorage裡,XSS一注入就全完了;Store越來越大,一個檔案3000行;SSR時hydration mismatch滿屏飄紅。你從Vuex遷移到Pinia,以為輕了就好了,結果只是換了個姿勢踩坑。
Vue3 Pinia狀態管理不是定義幾個Store就完事的。Store如何拆分?狀態如何持久化?外掛如何設計?SSR如何相容?TypeScript如何整合?這些不搞清楚,Pinia只是「輕量版Vuex」,該踩的坑一個不少。
本文將從6個企業級最佳實踐出發,帶你完成Store設計→外掛系統→狀態持久化→SSR相容→TypeScript整合→效能最佳化的全鏈路實戰。
Pinia核心概念
| 概念 | 說明 |
|---|---|
| defineStore | 定義Store的API,支援Options API和Composition API兩種風格 |
| useStore | 使用Store的Hook函式,在元件中呼叫取得Store實例 |
| State | Store的狀態資料,類似元件的data |
| Getters | Store的計算屬性,類似元件的computed |
| Actions | Store的方法,支援同步和非同步,類似元件的methods |
| Plugins | Pinia外掛系統,用於擴充套件Store功能(持久化、日誌、同步等) |
| $subscribe | 監聽State變化,類似Vuex的subscribe |
| $onAction | 監聽Action呼叫,類似Vuex的subscribeAction |
Pinia vs Vuex
Pinia優勢:
1. 完整TypeScript支援,無需手寫型別宣告
2. 去除Mutations,Actions同時支援同步和非同步
3. 支援多個Store實例,天然程式碼分割
4. 輕量,壓縮後約1KB
5. 支援Composition API風格,更靈活
6. 原生支援DevTools
Vuex劣勢:
1. TypeScript支援差,需要大量型別宣告
2. Mutations和Actions職責混亂
3. 單一Store導致檔案膨脹
4. 體積較大
問題分析:Pinia狀態管理的5大挑戰
- Store拆分粒度:太細導致Store爆炸,太粗導致職責不清
- 狀態持久化安全:localStorage明文儲存敏感資料存在XSS風險
- SSR Hydration:服務端和客戶端狀態不一致導致hydration mismatch
- 跨Store通訊:Store之間如何共享狀態和呼叫Action
- TypeScript型別安全:如何讓Store的State/Getters/Actions都有完整型別推導
分步實操:6個企業級最佳實踐
實踐1:Store模組化設計
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
const isLoggedIn = computed(() => !!token.value)
const userDisplayName = computed(() => userInfo.value?.displayName ?? 'Guest')
async function login(credentials: LoginCredentials) {
const response = await authApi.login(credentials)
token.value = response.token
userInfo.value = response.user
return response
}
function logout() {
token.value = ''
userInfo.value = null
}
async function fetchProfile() {
const profile = await userApi.getProfile()
userInfo.value = profile
return profile
}
return {
userInfo,
token,
isLoggedIn,
userDisplayName,
login,
logout,
fetchProfile,
}
})
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const totalAmount = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
function addItem(product: Product, quantity: number = 1) {
const existing = items.value.find(item => item.productId === product.id)
if (existing) {
existing.quantity += quantity
} else {
items.value.push({
productId: product.id,
name: product.name,
price: product.price,
quantity,
})
}
}
function removeItem(productId: string) {
items.value = items.value.filter(item => item.productId !== productId)
}
function clearCart() {
items.value = []
}
return {
items,
totalAmount,
itemCount,
addItem,
removeItem,
clearCart,
}
})
export const useAppStore = defineStore('app', () => {
const theme = ref<'light' | 'dark'>('light')
const sidebarCollapsed = ref(false)
const locale = ref<string>('zh-TW')
const loading = ref(false)
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function setLocale(newLocale: string) {
locale.value = newLocale
}
return {
theme,
sidebarCollapsed,
locale,
loading,
toggleTheme,
toggleSidebar,
setLocale,
}
})
實踐2:Pinia外掛系統
import type { PiniaPluginContext } from 'pinia'
interface PiniaPluginOptions {
enableLog?: boolean
enablePersistence?: boolean
persistenceConfig?: PersistenceConfig
}
interface PersistenceConfig {
key?: string
storage?: Storage
paths?: string[]
encrypt?: boolean
}
function piniaLoggerPlugin(context: PiniaPluginContext) {
context.store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`[${context.store.$id}] Action "${name}" started`, args)
after((result) => {
const duration = Date.now() - startTime
console.log(`[${context.store.$id}] Action "${name}" completed in ${duration}ms`, result)
})
onError((error) => {
const duration = Date.now() - startTime
console.error(`[${context.store.$id}] Action "${name}" failed in ${duration}ms`, error)
})
})
}
function piniaResetPlugin(context: PiniaPluginContext) {
const initialState = JSON.parse(JSON.stringify(context.store.$state))
context.store.$reset = () => {
context.store.$patch(JSON.parse(JSON.stringify(initialState)))
}
}
function createPiniaPlugins(options: PiniaPluginOptions = {}) {
return [
piniaLoggerPlugin,
piniaResetPlugin,
]
}
export { createPiniaPlugins, piniaLoggerPlugin, piniaResetPlugin }
export type { PiniaPluginOptions, PersistenceConfig }
實踐3:安全狀態持久化
import type { PiniaPluginContext } from 'pinia'
interface SecurePersistenceOptions {
key: string
storage?: Storage
paths?: string[]
encrypt?: boolean
encryptFn?: (data: string) => string
decryptFn?: (data: string) => string
}
function simpleEncrypt(data: string): string {
return btoa(encodeURIComponent(data))
}
function simpleDecrypt(data: string): string {
return decodeURIComponent(atob(data))
}
function createSecurePersistencePlugin(options: SecurePersistenceOptions) {
const {
key,
storage = localStorage,
paths,
encrypt = false,
encryptFn = simpleEncrypt,
decryptFn = simpleDecrypt,
} = options
return (context: PiniaPluginContext) => {
const storedData = storage.getItem(key)
if (storedData) {
try {
const parsed = JSON.parse(encrypt ? decryptFn(storedData) : storedData)
if (paths && paths.length > 0) {
const filteredState: Record<string, any> = {}
for (const path of paths) {
if (parsed[path] !== undefined) {
filteredState[path] = parsed[path]
}
}
context.store.$patch(filteredState)
} else {
context.store.$patch(parsed)
}
} catch (e) {
console.error(`[Pinia Persistence] Failed to parse stored data for "${key}":`, e)
}
}
context.store.$subscribe((mutation, state) => {
try {
let dataToStore: Record<string, any>
if (paths && paths.length > 0) {
dataToStore = {}
for (const path of paths) {
if ((state as any)[path] !== undefined) {
dataToStore[path] = (state as any)[path]
}
}
} else {
dataToStore = { ...state }
}
const serialized = JSON.stringify(dataToStore)
storage.setItem(key, encrypt ? encryptFn(serialized) : serialized)
} catch (e) {
console.error(`[Pinia Persistence] Failed to persist state for "${key}":`, e)
}
})
}
}
const userPersistence = createSecurePersistencePlugin({
key: 'app_user_store',
paths: ['token', 'userInfo'],
encrypt: true,
})
const appPersistence = createSecurePersistencePlugin({
key: 'app_settings_store',
paths: ['theme', 'locale', 'sidebarCollapsed'],
})
export { createSecurePersistencePlugin, userPersistence, appPersistence }
實踐4:SSR相容
import { createPinia, defineStore } from 'pinia'
import type { Pinia } from 'pinia'
export function createSSRPinia(): Pinia {
const pinia = createPinia()
if (import.meta.server) {
pinia.use(({ store }) => {
store.$onAction(({ after }) => {
after(() => {
console.log(`[SSR] Action completed in store: ${store.$id}`)
})
})
})
}
return pinia
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
async function hydrate(serverState: { userInfo: UserInfo | null; token: string }) {
userInfo.value = serverState.userInfo
token.value = serverState.token
}
return { userInfo, token, hydrate }
})
export function useSSRInitialState() {
if (import.meta.client) {
const initialState = (window as any).__PINIA_STATE__ || {}
return initialState
}
return {}
}
export function serializePiniaState(pinia: Pinia): Record<string, any> {
const state: Record<string, any> = {}
for (const [id, store] of pinia.state.value) {
state[id] = store
}
return state
}
實踐5:TypeScript完整型別推導
import { defineStore } from 'pinia'
interface UserInfo {
id: string
username: string
displayName: string
email: string
avatar: string
roles: string[]
}
interface LoginCredentials {
username: string
password: string
rememberMe?: boolean
}
interface LoginResponse {
token: string
user: UserInfo
expiresIn: number
}
interface UserPreferences {
theme: 'light' | 'dark'
language: string
notifications: boolean
}
export const useTypedUserStore = defineStore('typed-user', {
state: (): {
userInfo: UserInfo | null
token: string
lastLoginAt: Date | null
preferences: UserPreferences
} => ({
userInfo: null,
token: '',
lastLoginAt: null,
preferences: {
theme: 'light',
language: 'zh-TW',
notifications: true,
},
}),
getters: {
isLoggedIn: (state): boolean => !!state.token,
userDisplayName: (state): string => state.userInfo?.displayName ?? 'Guest',
userRoles: (state): string[] => state.userInfo?.roles ?? [],
isAdmin: (state): boolean => state.userInfo?.roles.includes('admin') ?? false,
},
actions: {
async login(credentials: LoginCredentials): Promise<LoginResponse> {
const response = await authApi.login(credentials)
this.token = response.token
this.userInfo = response.user
this.lastLoginAt = new Date()
return response
},
logout(): void {
this.token = ''
this.userInfo = null
this.lastLoginAt = null
},
async updateProfile(data: Partial<UserInfo>): Promise<UserInfo> {
const updated = await userApi.updateProfile(data)
this.userInfo = { ...this.userInfo!, ...updated }
return updated
},
updatePreferences(prefs: Partial<UserPreferences>): void {
this.preferences = { ...this.preferences, ...prefs }
},
},
})
實踐6:效能最佳化——Store懶載入與按需重置
import { defineStore } from 'pinia'
const storeCache = new Map<string, any>()
export function useLazyStore<T>(storeName: string, storeFactory: () => T): T {
if (!storeCache.has(storeName)) {
storeCache.set(storeName, storeFactory())
}
return storeCache.get(storeName) as T
}
export const useAdminStore = defineStore('admin', () => {
const dashboardData = ref<DashboardData | null>(null)
const userManagementList = ref<UserManagementItem[]>([])
const systemLogs = ref<SystemLog[]>([])
async function fetchDashboard() {
dashboardData.value = await adminApi.getDashboard()
}
async function fetchUsers(page: number, pageSize: number) {
const response = await adminApi.getUsers({ page, pageSize })
userManagementList.value = response.items
return response
}
function $dispose() {
dashboardData.value = null
userManagementList.value = []
systemLogs.value = []
}
return {
dashboardData,
userManagementList,
systemLogs,
fetchDashboard,
fetchUsers,
$dispose,
}
})
export function createStoreDisposer() {
const activeStores = new Set<string>()
return {
track(storeId: string) {
activeStores.add(storeId)
},
dispose(storeId: string) {
const store = getStoreById(storeId)
if (store && typeof store.$dispose === 'function') {
store.$dispose()
}
activeStores.delete(storeId)
},
disposeAll() {
for (const storeId of activeStores) {
this.dispose(storeId)
}
activeStores.clear()
},
}
}
避坑指南
坑1:直接解構Store丟失響應性
// ❌ 錯誤:直接解構丟失響應性
const { userInfo, token } = useUserStore()
// ✅ 正確:使用storeToRefs保持響應性
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo, token } = storeToRefs(userStore)
const { login, logout } = userStore // actions不需要storeToRefs
坑2:在Store外直接修改State
// ❌ 錯誤:直接修改State,無法追蹤變更
const userStore = useUserStore()
userStore.token = 'new-token'
// ✅ 正確:使用$patch批量更新或Action修改
const userStore = useUserStore()
userStore.$patch({ token: 'new-token' })
// 或透過Action
userStore.updateToken('new-token')
坑3:持久化儲存敏感資料未加密
// ❌ 錯誤:token明文儲存在localStorage
localStorage.setItem('token', token) // XSS可讀取
// ✅ 正確:敏感資料加密儲存或使用httpOnly Cookie
const encryptedToken = simpleEncrypt(token)
localStorage.setItem('token', encryptedToken)
// 最佳方案:token存在httpOnly Cookie中,前端不接觸
坑4:SSR中在setup外使用Store
// ❌ 錯誤:在元件setup外呼叫useStore
const userStore = useUserStore() // 在模組頂層呼叫,pinia實例可能不存在
export default defineComponent({
setup() {
// 這裡才能安全使用
}
})
// ✅ 正確:在setup函式內呼叫useStore
export default defineComponent({
setup() {
const userStore = useUserStore()
return { userStore }
}
})
坑5:Store迴圈依賴
// ❌ 錯誤:Store A依賴Store B,Store B又依賴Store A
// storeA.ts
export const useStoreA = defineStore('a', () => {
const storeB = useStoreB() // 迴圈依賴!
})
// ✅ 正確:在Action中取得依賴Store,延遲求值
export const useStoreA = defineStore('a', () => {
function doSomething() {
const storeB = useStoreB() // 在Action內部取得
storeB.someAction()
}
return { doSomething }
})
報錯排查
| 序號 | 報錯訊息 | 原因 | 解決方法 |
|---|---|---|---|
| 1 | getActivePinia was called with no active Pinia |
在pinia實例建立前呼叫useStore | 確保createPinia()在app.use(pinia)後呼叫useStore |
| 2 | Hydration mismatch |
SSR服務端狀態與客戶端不一致 | 確保SSR狀態正確注入,使用__PINIA_STATE__ |
| 3 | Cannot read property of null |
解構Store後丟失響應性 | 使用storeToRefs代替直接解構 |
| 4 | Maximum call stack size exceeded |
Store迴圈依賴 | 在Action內部取得依賴Store,延遲求值 |
| 5 | localStorage is not defined |
SSR中存取瀏覽器API | 使用import.meta.client守衛或條件判斷 |
| 6 | Pinia: store "$id" already exists |
重複註冊同名Store | 檢查defineStore的ID是否重複 |
| 7 | Computed is read-only |
試圖修改computed(getter)值 | 使用$patch或Action修改State,getter是隻讀的 |
| 8 | $subscribe callback not triggered |
$subscribe未觸發 | 確保使用$patch或直接賦值修改State,而非替換整個物件 |
| 9 | Type 'X' is not assignable to type 'Y' |
TypeScript型別不匹配 | 確保State/Action的回傳型別與介面定義一致 |
| 10 | Plugin did not return a function |
Pinia外掛格式錯誤 | 外掛必須回傳函式或使用正確的外掛簽名 |
進階最佳化
1. Store組合模式
import { defineStore } from 'pinia'
export const useOrderStore = defineStore('order', () => {
const userStore = useUserStore()
const cartStore = useCartStore()
const appStore = useAppStore()
const orders = ref<Order[]>([])
const currentOrder = ref<Order | null>(null)
const orderSummary = computed(() => ({
userName: userStore.userDisplayName,
items: cartStore.items,
totalAmount: cartStore.totalAmount,
currency: appStore.locale === 'zh-TW' ? 'TWD' : 'USD',
}))
async function placeOrder(): Promise<Order> {
if (!userStore.isLoggedIn) {
throw new Error('User must be logged in to place order')
}
const order: Order = {
id: generateOrderId(),
userId: userStore.userInfo!.id,
items: [...cartStore.items],
totalAmount: cartStore.totalAmount,
status: 'pending',
createdAt: new Date(),
}
const created = await orderApi.create(order)
orders.value.push(created)
currentOrder.value = created
cartStore.clearCart()
return created
}
return {
orders,
currentOrder,
orderSummary,
placeOrder,
}
})
2. 離線狀態同步
import { defineStore } from 'pinia'
interface OfflineAction {
id: string
storeId: string
actionName: string
args: any[]
timestamp: number
retryCount: number
}
export const useOfflineStore = defineStore('offline', () => {
const pendingActions = ref<OfflineAction[]>([])
const isOnline = ref(navigator.onLine)
const isSyncing = ref(false)
function enqueueAction(storeId: string, actionName: string, args: any[]) {
const action: OfflineAction = {
id: crypto.randomUUID(),
storeId,
actionName,
args,
timestamp: Date.now(),
retryCount: 0,
}
pendingActions.value.push(action)
persistPendingActions()
}
async function syncPendingActions(): Promise<void> {
if (isSyncing.value || !isOnline.value || pendingActions.value.length === 0) return
isSyncing.value = true
const actions = [...pendingActions.value]
for (const action of actions) {
try {
const store = getStoreById(action.storeId)
if (store && typeof store[action.actionName] === 'function') {
await store[action.actionName](...action.args)
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id)
}
} catch (error) {
const existing = pendingActions.value.find(a => a.id === action.id)
if (existing) {
existing.retryCount++
if (existing.retryCount >= 3) {
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id)
}
}
}
}
persistPendingActions()
isSyncing.value = false
}
function persistPendingActions() {
localStorage.setItem('offline_actions', JSON.stringify(pendingActions.value))
}
function restorePendingActions() {
const stored = localStorage.getItem('offline_actions')
if (stored) {
try {
pendingActions.value = JSON.parse(stored)
} catch {
pendingActions.value = []
}
}
}
if (import.meta.client) {
window.addEventListener('online', () => {
isOnline.value = true
syncPendingActions()
})
window.addEventListener('offline', () => {
isOnline.value = false
})
restorePendingActions()
}
return {
pendingActions,
isOnline,
isSyncing,
enqueueAction,
syncPendingActions,
}
})
3. Store測試工具
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useUserStore } from '@/stores/user'
function createTestingPinia() {
const pinia = createPinia()
setActivePinia(pinia)
return pinia
}
describe('useUserStore', () => {
beforeEach(() => {
createTestingPinia()
})
it('should start with empty state', () => {
const store = useUserStore()
expect(store.userInfo).toBeNull()
expect(store.token).toBe('')
expect(store.isLoggedIn).toBe(false)
})
it('should update state after login', async () => {
const store = useUserStore()
const mockLogin = vi.fn().mockResolvedValue({
token: 'test-token',
user: { id: '1', username: 'test', displayName: 'Test User', email: 'test@test.com', avatar: '', roles: ['user'] },
expiresIn: 3600,
})
vi.stubGlobal('authApi', { login: mockLogin })
await store.login({ username: 'test', password: 'password' })
expect(store.token).toBe('test-token')
expect(store.isLoggedIn).toBe(true)
expect(store.userDisplayName).toBe('Test User')
})
it('should clear state after logout', () => {
const store = useUserStore()
store.$patch({
token: 'test-token',
userInfo: { id: '1', username: 'test', displayName: 'Test', email: 't@t.com', avatar: '', roles: [] },
})
store.logout()
expect(store.token).toBe('')
expect(store.userInfo).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
})
對比分析
| 維度 | Pinia | Vuex 4 | Vuex 5(RFC) | Zustand(React) | Redux Toolkit |
|---|---|---|---|---|---|
| TypeScript | ✅原生支援 | ⚠️需手動宣告 | ✅ | ✅ | ✅ |
| Mutations | ❌去除 | ✅必須 | ❌去除 | ❌ | ❌(reducers) |
| 程式碼分割 | ✅天然支援 | ⚠️需動態註冊 | ✅ | ✅ | ⚠️需配置 |
| SSR | ✅原生支援 | ⚠️需配置 | ✅ | ⚠️需配置 | ⚠️需配置 |
| DevTools | ✅ | ✅ | ✅ | ⚠️需外掛 | ✅ |
| 體積 | ~1KB | ~6KB | ~2KB | ~1.5KB | ~11KB |
| 學習曲線 | 低 | 中 | 低 | 低 | 中 |
| 外掛生態 | 中 | 豐富 | 中 | 少 | 豐富 |
總結:Pinia不是「輕量版Vuex」,而是「重新思考後的狀態管理」。6個企業級最佳實踐的核心:1)Store按業務域拆分,不要按技術層拆分;2)外掛系統統一處理橫切關注點(日誌、持久化、重置);3)持久化必須考慮安全性——敏感資料加密或存httpOnly Cookie;4)SSR的關鍵是狀態注入和hydration時機;5)TypeScript完整型別推導從介面定義開始;6)效能最佳化的核心是懶載入和按需釋放。記住:好的狀態管理不是「什麼都能存」,而是「該存的存好,不該存的不存」。
線上工具推薦
- JSON格式化:/zh-TW/json/format
- Base64編解碼:/zh-TW/encode/base64
- Hash計算:/zh-TW/encode/hash
- JWT解碼:/zh-TW/encode/jwt-decode
本站提供瀏覽器本地工具,免註冊即可試用 →
#Vue3#Pinia#状态管理#TypeScript#前端架构#2026#Store设计