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 來同步到 localStorage、sessionStorage 或 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,只保存純資料結構。 |
最佳實踐
- 保持 Store 輕量:只放置與該功能相關的 state、getter、action。
- 使用 TypeScript:Pinia 天生支援型別推斷,能減少錯誤。
- 將非同步邏輯放在 actions,保持
state只負責資料。 - 模組化:每個功能領域一個 Store,避免單一 Store 變得龐大。
- 測試:利用 Jest 或 Vitest 撰寫 Store 的單元測試,確保 business logic 正確。
實際應用場景
| 場景 | 為何使用 Pinia | 實作要點 |
|---|---|---|
| 使用者登入與權限管理 | 需要在多個頁面共享 token、使用者資料,且要支援持久化。 | 建立 userStore,在 login 時寫入 localStorage,在 router.beforeEach 中檢查 isLoggedIn。 |
| 購物車 | 商品列表與結算頁面都需要即時同步的購物車資料。 | cartStore 中使用 ref([]) 保存 items,提供 addItem、removeItem、checkout 等 actions。 |
| 即時聊天 | 訊息需要在多個聊天室元件間共用,同時要支援 WebSocket 推送。 | 建立 chatStore,使用 socket.io 事件在 actions 中更新 messages,利用 computed 產生未讀計數。 |
| 主題切換(Dark/Light) | 全站樣式需要根據使用者選擇即時變更。 | themeStore 保存 mode,在 watchEffect 中動態切換 document.body.classList。 |
| 表單暫存 | 使用者在長表單填寫時,若不小心刷新頁面希望保留已填寫的內容。 | 在 formStore 中使用 watch 把 formData 同步到 sessionStorage,離開頁面時自動恢復。 |
總結
Pinia 為 Vue 3 帶來了 更直觀、輕量且與 Composition API 完全相容 的全域狀態管理方案。透過 defineStore 的函式寫法,我們可以:
- 直接使用
ref/reactive定義 state,保持與 Vue 3 的一致性。 - 使用
computed建立 getter,讓衍生資料自動保持響應式。 - 把所有變更邏輯集中於 action,支援同步與非同步操作,提升可測試性。
在實務開發中,將功能切割成多個專屬 Store、善用持久化插件、遵守單向資料流原則,能讓大型專案的維護成本大幅降低。希望本篇文章能幫助你快速上手 Pinia,並在 Vue3 專案中建立穩定、可擴充的狀態管理架構。祝開發順利! 🚀