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 應用的狀態管理也能保持清晰可維護。
分享這篇文章