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大挑戰

  1. Store拆分粒度:太細導致Store爆炸,太粗導致職責不清
  2. 狀態持久化安全:localStorage明文儲存敏感資料存在XSS風險
  3. SSR Hydration:服務端和客戶端狀態不一致導致hydration mismatch
  4. 跨Store通訊:Store之間如何共享狀態和呼叫Action
  5. 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)效能最佳化的核心是懶載入和按需釋放。記住:好的狀態管理不是「什麼都能存」,而是「該存的存好,不該存的不存」。


線上工具推薦

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

#Vue3#Pinia#状态管理#TypeScript#前端架构#2026#Store设计