本文 AI 產出,尚未審核

Vue3 Pinia 狀態管理:在 Composition API 中使用 Store

簡介

在單頁應用(SPA)中,元件之間的資料共享與同步是常見且重要的需求。Vue 2 時代的 Vuex 已經提供了完整的全域狀態管理機制,但隨著 Vue 3 的推出,官方更推薦使用 Pinia 這個輕量、直覺且與 Composition API 完全相容的庫。

本篇文章將聚焦於 「Composition API 中使用 Pinia Store」,說明如何在 Vue 3 專案裡建立、使用與維護全域狀態。透過實作範例,你將了解 Pinia 為何比 Vuex 更簡潔、效能更佳,並能快速上手於真實專案中。


核心概念

1. Pinia 的基本結構

Pinia 的 Store 由三個主要部分組成:

部分 功能 在 Composition API 中的寫法
state 保存可被觀測的資料 return { count: ref(0) }
getters state 派生的計算屬性 doubleCount: (state) => state.count * 2
actions 改變 state 或執行非同步邏輯 increment() { this.count++ }

重點:在 Composition API 中,state 可以直接使用 ref / reactive,而不需要 Vuex 那樣的 mutations

2. 建立 Pinia 實例與掛載

main.js(或 main.ts)中,我們先建立 Pinia 實例,然後把它掛載到 Vue 應用上:

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)

// 建立 Pinia 實例
const pinia = createPinia()
app.use(pinia)   // 掛載到 Vue 應用

app.mount('#app')

提示:若有多個 Store,僅需建立一次 Pinia,所有 Store 都會共享同一個 Pinia 實例。

3. 定義 Store:使用 defineStore

Pinia 提供 defineStore 這個函式,讓我們以 Composition API 的方式撰寫 Store。以下是一個最簡單的計數器 Store:

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // ---------- state ----------
  const count = ref(0)

  // ---------- getters ----------
  const doubleCount = computed(() => count.value * 2)

  // ---------- actions ----------
  function increment(step = 1) {
    count.value += step
  }

  function decrement(step = 1) {
    count.value -= step
  }

  // 必須回傳要公開的屬性與方法
  return { count, doubleCount, increment, decrement }
})

為什麼要使用 defineStore 的第二個參數是函式?

  • 更好的 TypeScript 支援:函式內部的型別推斷更準確。
  • 符合 Composition API 思維:所有邏輯都寫在同一個函式裡,易於拆解與重用。

4. 在元件中使用 Store

<template>
  <div class="counter">
    <p>目前值:{{ counter.count }}</p>
    <p>兩倍值:{{ counter.doubleCount }}</p>
    <button @click="counter.increment()">+1</button>
    <button @click="counter.decrement()">-1</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

// 取得 Store 實例(同一個 Store 只會建立一次)
const counter = useCounterStore()
</script>

<style scoped>
.counter { text-align:center; }
button { margin: 0 5px; }
</style>

小技巧:在 script setup 中直接呼叫 useXXXStore(),即可把 Store 暴露給模板使用,省去 computed 包裝。

5. 多 Store 與模組化

在大型專案中,我們會把功能拆成多個 Store,例如 user, product, cart。以下示範一個簡易的使用者 Store,並說明如何在另一個 Store 中取得它的狀態。

// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  const token = ref(localStorage.getItem('token') || '')
  const profile = ref(null)

  const isLoggedIn = computed(() => !!token.value)

  async function login(username, password) {
    // 假設調用 API,回傳 token 與使用者資料
    const response = await fakeApiLogin(username, password)
    token.value = response.token
    profile.value = response.user
    localStorage.setItem('token', token.value)
  }

  function logout() {
    token.value = ''
    profile.value = null
    localStorage.removeItem('token')
  }

  return { token, profile, isLoggedIn, login, logout }
})
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores/user'

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

  // 直接使用其他 Store
  const userStore = useUserStore()
  const isGuest = computed(() => !userStore.isLoggedIn)

  function addItem(product, qty = 1) {
    const existed = items.value.find(i => i.id === product.id)
    if (existed) existed.qty += qty
    else items.value.push({ ...product, qty })
  }

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

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

注意:在 Store 內部直接呼叫其他 Store(如 useUserStore())是安全的,Pinia 會在第一次使用時自動建立實例,之後皆共用同一個。

6. 持久化(Persistence)

Pinia 本身不提供持久化功能,但我們可以藉由插件或自行在 store 中加入 watch 來同步到 localStoragesessionStorage 或 IndexedDB。

// plugins/piniaPersist.js
import { watch } from 'vue'

export function piniaPersist(pinia) {
  pinia.state.value = JSON.parse(localStorage.getItem('pinia-state') || '{}')

  // 每次 state 改變時寫回 localStorage
  pinia.state.value && watch(
    () => pinia.state.value,
    (state) => {
      localStorage.setItem('pinia-state', JSON.stringify(state))
    },
    { deep: true }
  )
}

main.js 中註冊:

import { piniaPersist } from '@/plugins/piniaPersist'

app.use(pinia)
pinia.use(piniaPersist)   // 套用插件

常見陷阱與最佳實踐

陷阱 說明 解決方式
直接改寫 state 在 Composition API 中,ref / reactive 的值必須透過 .value 或解構賦值修改。若忘記 .value 會造成 無法觸發更新 使用 increment()setX()action 包裝變更,避免直接在模板中改寫。
Store 被多次建立 在非 setup 之外(如普通的 JS 檔)多次呼叫 useStore() 可能產生不同實例 確保所有 Store 呼叫都在同一個 Pinia 實例下,且在 Vue 組件或 setup 中使用。
Getter 失去反應性 若在 defineStore 中使用普通函式返回計算結果,而非 computed,則不具備響應式。 必須使用 computed(() => ...) 包裝所有衍生值。
跨 Store 直接存取 state 直接讀寫其他 Store 的 state 會破壞 單向資料流,不易追蹤。 透過 actions 互相呼叫,或使用 getters 取得需要的資料。
持久化時的 JSON 循環參考 state 中包含循環參考,JSON.stringify 會拋錯。 避免把完整的 Vue 實例或函式存入 state,只保存純資料結構。

最佳實踐

  1. 保持 Store 輕量:只放置與該功能相關的 state、getter、action。
  2. 使用 TypeScript:Pinia 天生支援型別推斷,能減少錯誤。
  3. 將非同步邏輯放在 actions,保持 state 只負責資料。
  4. 模組化:每個功能領域一個 Store,避免單一 Store 變得龐大。
  5. 測試:利用 Jest 或 Vitest 撰寫 Store 的單元測試,確保 business logic 正確。

實際應用場景

場景 為何使用 Pinia 實作要點
使用者登入與權限管理 需要在多個頁面共享 token、使用者資料,且要支援持久化。 建立 userStore,在 login 時寫入 localStorage,在 router.beforeEach 中檢查 isLoggedIn
購物車 商品列表與結算頁面都需要即時同步的購物車資料。 cartStore 中使用 ref([]) 保存 items,提供 addItemremoveItemcheckout 等 actions。
即時聊天 訊息需要在多個聊天室元件間共用,同時要支援 WebSocket 推送。 建立 chatStore,使用 socket.io 事件在 actions 中更新 messages,利用 computed 產生未讀計數。
主題切換(Dark/Light) 全站樣式需要根據使用者選擇即時變更。 themeStore 保存 mode,在 watchEffect 中動態切換 document.body.classList
表單暫存 使用者在長表單填寫時,若不小心刷新頁面希望保留已填寫的內容。 formStore 中使用 watchformData 同步到 sessionStorage,離開頁面時自動恢復。

總結

Pinia 為 Vue 3 帶來了 更直觀、輕量且與 Composition API 完全相容 的全域狀態管理方案。透過 defineStore 的函式寫法,我們可以:

  • 直接使用 ref / reactive 定義 state,保持與 Vue 3 的一致性。
  • 使用 computed 建立 getter,讓衍生資料自動保持響應式。
  • 把所有變更邏輯集中於 action,支援同步與非同步操作,提升可測試性。

在實務開發中,將功能切割成多個專屬 Store、善用持久化插件、遵守單向資料流原則,能讓大型專案的維護成本大幅降低。希望本篇文章能幫助你快速上手 Pinia,並在 Vue3 專案中建立穩定、可擴充的狀態管理架構。祝開發順利! 🚀