本文 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,在多模組環境下必須唯一。
  • stategettersactions 皆為 模組內部的私有成員,外部存取時會自動加上 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 內部 功能聚焦

最佳實踐要點

  1. 統一命名規則use{Domain}Store + Store ID 為小寫字串。
  2. 使用 TypeScript:即使是 JavaScript 專案,也建議加入 .d.ts,提升可維護性。
  3. pinia.use 中注入全局插件:如日誌、錯誤捕捉或自動持久化。
  4. 懶載入不常用的 Store:使用 defineStore('order', () => {...}) 方式,減少首屏 bundle 大小。
  5. 單元測試:每個 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建立與組織方式跨模組呼叫實務範例,到 常見陷阱與最佳實踐,一步步說明了如何在專案中落實模組化管理。只要掌握以下幾點,即可在開發過程中:

  1. 以業務領域切分 Store,保持職責單一。
  2. 使用唯一的 Store ID,避免衝突與覆寫。
  3. 透過 Action、Getter、watch 互動,保持狀態的單向資料流。
  4. 適度使用持久化與懶載入,提升使用者體驗與效能。
  5. 遵循命名與測試規範,讓團隊協作更順暢。

掌握這套模組化的 Pinia 使用方式,你將能更自信地開發 從小型網站到企業級平台 的 Vue3 應用,讓狀態管理不再是瓶頸,而是提升開發效率與程式品質的利器。祝開發順利,玩得開心! 🚀