本文 AI 產出,尚未審核
Vue3 Pinia 狀態管理 – 多個 Store 模組管理
簡介
在大型 Vue3 專案中,單一的全域 Store 很快就會變得雜亂,功能彼此交叉、命名衝突、維護成本上升。Pinia 作為 Vue 官方推薦的狀態管理解決方案,支援 模組化(module) 的 Store 結構,讓開發者能以「功能區塊」切分狀態、Getter、Action,從而提升可讀性與可維護性。
本篇文章將帶你一步步了解 如何在 Pinia 中建立與管理多個 Store 模組,並提供實作範例、常見陷阱與最佳實踐,讓你在真實專案中快速上手、降低錯誤率。
核心概念
1. 為什麼需要多個 Store?
- 職責分離:每個模組只負責單一領域(例如使用者、商品、購物車),避免單一 Store 內部過於龐雜。
- 命名空間:模組自帶命名空間,避免 Getter、Action、State 名稱衝突。
- 懶載入:可以在需要時才載入模組,減少初始載入大小。
- 測試友好:每個模組可獨立單元測試,提升測試覆蓋率。
2. Pinia 的 Store 建立方式
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
id: null,
name: '',
token: '',
}),
getters: {
isLoggedIn: (state) => !!state.token,
},
actions: {
login(payload) {
// 假設向 API 請求成功後
this.id = payload.id
this.name = payload.name
this.token = payload.token
},
logout() {
this.id = null
this.name = ''
this.token = ''
},
},
})
- 第一個參數
'user'為 Store ID,在多模組環境下必須唯一。 state、getters、actions皆為 模組內部的私有成員,外部存取時會自動加上 Store ID 的命名空間。
3. 多 Store 的組織方式
3.1 目錄結構建議
src/
└─ stores/
├─ index.ts // Pinia 註冊與全局設定
├─ user/
│ └─ userStore.ts
├─ product/
│ └─ productStore.ts
└─ cart/
└─ cartStore.ts
- 每個功能放在獨立資料夾,檔名與 Store ID 保持一致,方便搜尋與自動匯入。
3.2 在 main.ts 中註冊 Pinia
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
// 可在此加入插件、持久化等全局設定
pinia.use(({ store }) => {
// 例如自動把狀態保存到 localStorage
store.$subscribe((mutation, state) => {
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
})
})
app.use(pinia)
app.mount('#app')
4. 互相呼叫與資料共享
4.1 直接引用其他 Store
import { defineStore } from 'pinia'
import { useCartStore } from '@/stores/cart/cartStore'
export const useProductStore = defineStore('product', {
state: () => ({
products: [] as Array<{ id: number; name: string; price: number }>,
}),
actions: {
async fetchProducts() {
const data = await fetch('/api/products').then((res) => res.json())
this.products = data
},
addToCart(productId: number) {
const cartStore = useCartStore()
const product = this.products.find((p) => p.id === productId)
if (product) cartStore.addItem(product)
},
},
})
- 注意:在 Action 中呼叫其他 Store 時,務必要使用
useXStore()取得實例,避免循環依賴或多實例問題。
4.2 使用 storeToRefs 解構 State(在組件內)
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user/userStore'
const userStore = useUserStore()
const { name, isLoggedIn } = storeToRefs(userStore)
</script>
<template>
<div v-if="isLoggedIn">
歡迎,{{ name }}!
</div>
<div v-else>
請先登入
</div>
</template>
storeToRefs可將 reactive 的 State 轉成 ref,在解構時不會失去響應式。
5. 程式碼範例
以下提供 五個實用範例,涵蓋建立模組、跨模組呼叫、持久化、懶載入與型別安全(TypeScript)。
範例 1️⃣ 建立 cartStore(支援本地持久化)
// src/stores/cart/cartStore.ts
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as Array<{ id: number; name: string; qty: number; price: number }>,
}),
getters: {
totalAmount: (state) => state.items.reduce((sum, i) => sum + i.price * i.qty, 0),
itemCount: (state) => state.items.length,
},
actions: {
addItem(product) {
const existed = this.items.find((i) => i.id === product.id)
if (existed) existed.qty++
else this.items.push({ ...product, qty: 1 })
},
removeItem(productId) {
this.items = this.items.filter((i) => i.id !== productId)
},
clearCart() {
this.items = []
},
},
// ★ 使用 Pinia 的 persist 插件(或自行實作) ★
persist: {
key: 'my-app-cart',
storage: localStorage,
// 只保存 items 欄位
paths: ['items'],
},
})
重點:
persist需要額外安裝pinia-plugin-persistedstate,但示意寫法已足以說明概念。
範例 2️⃣ orderStore 懶載入(只有在結帳頁面才載入)
// src/stores/order/orderStore.ts
import { defineStore } from 'pinia'
export const useOrderStore = defineStore('order', () => {
const orders = ref<Array<{ id: number; items: any[]; total: number }>>([])
async function placeOrder(cartItems) {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify({ items: cartItems }),
}).then((res) => res.json())
orders.value.push(response)
// 清空購物車
const cartStore = useCartStore()
cartStore.clearCart()
}
return { orders, placeOrder }
})
- 使用 Composition API 形式 (
defineStore('order', () => {...})) 可以更彈性地控制 懶載入,僅在import時才執行。
範例 3️⃣ 跨模組的同步更新(使用 watch)
// src/stores/user/userStore.ts
import { defineStore } from 'pinia'
import { watch } from 'vue'
import { useCartStore } from '@/stores/cart/cartStore'
export const useUserStore = defineStore('user', {
state: () => ({
id: null as number | null,
name: '',
token: '',
}),
actions: {
login(payload) {
this.id = payload.id
this.name = payload.name
this.token = payload.token
// 登入成功後自動把購物車資料從 localStorage 合併到遠端
const cartStore = useCartStore()
// 假設有同步 API
fetch(`/api/users/${this.id}/cart`, {
method: 'POST',
body: JSON.stringify(cartStore.items),
headers: { Authorization: `Bearer ${this.token}` },
})
},
},
})
// 監聽 token 變化,若 token 被清除則自動清空購物車
watch(
() => useUserStore().token,
(newToken) => {
if (!newToken) {
const cartStore = useCartStore()
cartStore.clearCart()
}
}
)
watch放在 Store 檔案最外層,不會產生副作用,只要 Store 被載入即會生效。
範例 4️⃣ 型別安全的 Store(TypeScript)
// src/stores/product/productStore.ts
import { defineStore } from 'pinia'
export interface Product {
id: number
name: string
price: number
stock: number
}
export const useProductStore = defineStore('product', {
state: (): { list: Product[] } => ({
list: [],
}),
getters: {
availableProducts: (state) => state.list.filter((p) => p.stock > 0),
},
actions: {
setProducts(products: Product[]) {
this.list = products
},
decreaseStock(id: number, qty = 1) {
const product = this.list.find((p) => p.id === id)
if (product && product.stock >= qty) product.stock -= qty
},
},
})
- 使用 介面 (
interface) 定義資料結構,使得編輯器能提供自動補完與錯誤檢查。
範例 5️⃣ 使用 storeToRefs 在組件中同時使用多個 Store
<!-- src/components/HeaderBar.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user/userStore'
import { useCartStore } from '@/stores/cart/cartStore'
const userStore = useUserStore()
const cartStore = useCartStore()
const { name, isLoggedIn } = storeToRefs(userStore)
const { itemCount } = storeToRefs(cartStore)
function handleLogout() {
userStore.logout()
}
</script>
<template>
<nav class="header">
<div v-if="isLoggedIn">
<span>嗨,{{ name }}!</span>
<router-link to="/cart">購物車 ({{ itemCount }})</router-link>
<button @click="handleLogout">登出</button>
</div>
<div v-else>
<router-link to="/login">登入</router-link>
</div>
</nav>
</template>
- 透過
storeToRefs同時取得多個 Store 的 ref,避免在模板中直接解構導致失去響應式。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| Store ID 重複 | 多個檔案不小心使用相同的 defineStore('user', ...) 會導致狀態被覆寫。 |
在建立 Store 時,統一以功能命名(如 user, product, cart),並在 IDE 中設定檔案命名規則。 |
| 跨模組循環依賴 | A Store 內部呼叫 B Store,B Store 又呼叫 A,會產生無限遞迴或 undefined。 |
把 共用邏輯抽離成 composable(如 useAuth()),或使用 事件總線(mitt)傳遞訊息。 |
| State 被直接改寫 | 在組件外直接 store.state = … 會失去 Pinia 的追蹤。 |
永遠使用 actions 或 直接改動 this(在 Store 內部)來變更狀態。 |
| 忘記持久化設定 | 刷新頁面後狀態全部消失,使用者體驗不佳。 | 在需要的 Store 加入 persist 插件,或自行在 pinia.use() 中實作持久化。 |
| 過度拆分 Store | 把每一個小功能都獨立成 Store,導致管理成本反而上升。 | 根據業務領域(Domain)劃分模組,保持每個 Store 內部 功能聚焦。 |
最佳實踐要點
- 統一命名規則:
use{Domain}Store+ Store ID 為小寫字串。 - 使用 TypeScript:即使是 JavaScript 專案,也建議加入
.d.ts,提升可維護性。 - 在
pinia.use中注入全局插件:如日誌、錯誤捕捉或自動持久化。 - 懶載入不常用的 Store:使用
defineStore('order', () => {...})方式,減少首屏 bundle 大小。 - 單元測試:每個 Store 的 Action、Getter 都可以獨立測試,確保業務邏輯不會因重構而破壞。
實際應用場景
1. 電子商務平台
- UserStore:管理登入資訊、使用者偏好。
- ProductStore:抓取商品列表、篩選、分頁。
- CartStore:即時更新購物車、持久化至 localStorage。
- OrderStore:結帳流程、訂單歷史。
在結帳頁面只載入
OrderStore,其他 Store 仍可在全局使用,減少不必要的 API 呼叫。
2. 多語系管理系統
- LocaleStore:儲存目前語系、切換語系的 Action。
- PermissionStore:根據使用者角色動態載入功能權限。
- DashboardStore:根據權限載入不同的圖表資料。
透過 PermissionStore 判斷是否載入 DashboardStore,避免未授權使用者取得敏感資料。
3. SaaS 後台管理
- TeamStore:管理團隊成員、邀請流程。
- BillingStore:訂閱方案、付款紀錄。
- NotificationStore:即時推播與訊息列。
透過
watch監聽 TeamStore 中的成員變化,自動更新 NotificationStore 的通知設定。
總結
多個 Pinia Store 模組 為 Vue3 大型應用 提供了 結構化、可維護、可測試 的狀態管理方式。本文從 為何需要多 Store、建立與組織方式、跨模組呼叫、實務範例,到 常見陷阱與最佳實踐,一步步說明了如何在專案中落實模組化管理。只要掌握以下幾點,即可在開發過程中:
- 以業務領域切分 Store,保持職責單一。
- 使用唯一的 Store ID,避免衝突與覆寫。
- 透過 Action、Getter、watch 互動,保持狀態的單向資料流。
- 適度使用持久化與懶載入,提升使用者體驗與效能。
- 遵循命名與測試規範,讓團隊協作更順暢。
掌握這套模組化的 Pinia 使用方式,你將能更自信地開發 從小型網站到企業級平台 的 Vue3 應用,讓狀態管理不再是瓶頸,而是提升開發效率與程式品質的利器。祝開發順利,玩得開心! 🚀