跳至主要內容

Pinia 狀態管理進階:模組化、持久化與最佳實踐

Pinia 狀態管理進階:模組化、持久化與最佳實踐

Pinia 自從成為 Vue 3 官方推薦的狀態管理方案後,已經完全取代 Vuex 成為 Vue 生態的首選。它的 API 更簡潔、對 TypeScript 的支援更好,也更容易測試。

這篇文章不再介紹基礎用法,而是聚焦在實際專案中常見的進階需求:如何設計可維護的 Store 架構、如何實現持久化、如何在 Store 之間共享邏輯,以及測試的策略。

Setup Store vs Options Store

Pinia 支援兩種風格,但我推薦 Setup Store 因為它更靈活:

// Options Store 風格(類似 Vuex)
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

// Setup Store 風格(推薦,更靈活)
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})

設計可維護的 Store 架構

按功能模組分割

src/stores/
├── index.ts          # 匯出所有 store
├── auth.ts           # 認證相關
├── user.ts           # 使用者資料
├── cart.ts           # 購物車
├── ui.ts             # UI 狀態(側邊欄、modal 等)
└── product/
    ├── index.ts
    ├── list.ts       # 商品列表
    └── detail.ts     # 商品詳情

認證 Store 範例

// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase'
import type { User } from '@supabase/supabase-js'

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const isLoading = ref(false)
  const error = ref<string | null>(null)

  const isAuthenticated = computed(() => user.value !== null)
  const userId = computed(() => user.value?.id ?? null)

  async function signIn(email: string, password: string) {
    isLoading.value = true
    error.value = null
    try {
      const { data, error: authError } = await supabase.auth.signInWithPassword({
        email,
        password
      })
      if (authError) throw authError
      user.value = data.user
    } catch (e: any) {
      error.value = e.message
      throw e
    } finally {
      isLoading.value = false
    }
  }

  async function signOut() {
    await supabase.auth.signOut()
    user.value = null
  }

  async function initAuth() {
    const { data } = await supabase.auth.getSession()
    user.value = data.session?.user ?? null

    supabase.auth.onAuthStateChange((_, session) => {
      user.value = session?.user ?? null
    })
  }

  return {
    user,
    isLoading,
    error,
    isAuthenticated,
    userId,
    signIn,
    signOut,
    initAuth
  }
})

Store 之間的組合與依賴

// src/stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './auth'

export const useCartStore = defineStore('cart', () => {
  const authStore = useAuthStore()
  const items = ref<CartItem[]>([])

  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  async function addItem(productId: string, quantity: number) {
    // 需要登入才能加入購物車
    if (!authStore.isAuthenticated) {
      throw new Error('請先登入')
    }

    // 加入邏輯...
  }

  async function syncWithServer() {
    if (!authStore.userId) return
    // 從伺服器同步購物車
  }

  return { items, total, addItem, syncWithServer }
})

狀態持久化

pinia-plugin-persistedstate 讓狀態在頁面重新整理後仍然保留:

npm install pinia-plugin-persistedstate
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

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

createApp(App).use(pinia).mount('#app')
// src/stores/settings.ts
export const useSettingsStore = defineStore('settings', () => {
  const theme = ref<'light' | 'dark'>('light')
  const language = ref('zh-TW')
  const sidebarCollapsed = ref(false)

  return { theme, language, sidebarCollapsed }
}, {
  persist: {
    key: 'app-settings',
    storage: localStorage,
    // 只持久化部分狀態
    pick: ['theme', 'language']
  }
})

自訂序列化

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  return { items }
}, {
  persist: {
    serializer: {
      serialize: JSON.stringify,
      deserialize: JSON.parse,
    },
    // 設定過期時間(自訂邏輯)
    afterRestore: (ctx) => {
      const store = ctx.store
      // 可以在還原後做額外處理
    }
  }
})

可組合的 Store 邏輯(Composable Store)

把共用的狀態邏輯抽象成 composable:

// src/composables/useAsyncState.ts
export function useAsyncState<T>(initialValue: T) {
  const data = ref<T>(initialValue)
  const isLoading = ref(false)
  const error = ref<Error | null>(null)

  async function execute(promise: Promise<T>) {
    isLoading.value = true
    error.value = null
    try {
      data.value = await promise
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
      throw e
    } finally {
      isLoading.value = false
    }
  }

  return { data, isLoading, error, execute }
}

// 在 Store 中使用
export const useProductStore = defineStore('product', () => {
  const { data: products, isLoading, error, execute } = useAsyncState<Product[]>([])

  async function fetchProducts(categoryId?: string) {
    await execute(
      fetch(`/api/products${categoryId ? `?category=${categoryId}` : ''}`)
        .then(r => r.json())
    )
  }

  return { products, isLoading, error, fetchProducts }
})

測試 Pinia Store

// src/stores/__tests__/auth.spec.ts
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { useAuthStore } from '../auth'

// Mock Supabase
vi.mock('@/lib/supabase', () => ({
  supabase: {
    auth: {
      signInWithPassword: vi.fn(),
      signOut: vi.fn(),
      getSession: vi.fn(() => ({ data: { session: null } })),
      onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } }))
    }
  }
}))

describe('Auth Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('初始狀態應為未登入', () => {
    const store = useAuthStore()
    expect(store.isAuthenticated).toBe(false)
    expect(store.user).toBeNull()
  })

  it('登入成功後應更新使用者狀態', async () => {
    const { supabase } = await import('@/lib/supabase')
    const mockUser = { id: '123', email: 'test@example.com' }

    vi.mocked(supabase.auth.signInWithPassword).mockResolvedValue({
      data: { user: mockUser, session: null },
      error: null
    } as any)

    const store = useAuthStore()
    await store.signIn('test@example.com', 'password')

    expect(store.isAuthenticated).toBe(true)
    expect(store.user).toEqual(mockUser)
  })
})

效能優化

// 使用 storeToRefs 避免解構時失去響應性
import { storeToRefs } from 'pinia'

const store = useProductStore()
// 錯誤:這樣解構出來的不是響應式的
// const { products, isLoading } = store

// 正確:使用 storeToRefs
const { products, isLoading } = storeToRefs(store)
// action 可以直接解構
const { fetchProducts } = store

總結

Pinia 的設計哲學是「簡單優先」,大多數場景下的用法都很直覺。進階用法的核心是:以功能模組切割 Store、善用 composable 抽象共用邏輯、搭配插件實現持久化,以及保持每個 Store 的單一職責。做到這幾點,即使是大型 Vue 應用的狀態管理也能保持清晰可維護。

分享這篇文章