Vue3 元件通信:全域狀態管理(Pinia / Vuex)
簡介
在單頁應用(SPA)中,元件之間的資料交換是不可避免的。隨著功能逐漸複雜,僅靠 props、emit 或 provide/inject 會讓資料流變得難以追蹤、維護成本急速升高。此時 全域狀態管理 就成為解決方案的關鍵。Vue 官方在 Vue 3 時期推出了新一代的狀態管理庫 Pinia,而 Vuex 仍是許多既有專案的既定選擇。本文將說明兩者的核心概念、實作方式,以及在真實專案中如何選擇與使用。
核心概念
1. 為什麼需要全域狀態?
- 單一真相來源(Single Source of Truth):所有元件都讀取同一個資料來源,避免資料不一致。
- 可預測的變更流程:所有狀態變更必須透過明確的 action 或 mutation,讓除錯更容易。
- 跨層級共享:不再受父子層級限制,任意元件皆可直接存取或修改狀態。
2. Pinia 與 Vuex 的差異
| 項目 | Pinia | Vuex (4.x) |
|---|---|---|
| API 設計 | 基於 Composition API,使用 defineStore,更貼合 Vue 3 思維 |
仍採 Option API(state、mutations、actions、getters) |
| 模組化 | 每個 store 本身就是一個獨立模組,無需 modules 設定 |
需要手動在 modules 中掛載子模組 |
| 型別支援 | 內建 TypeScript 支援,推斷更完整 | 需要自行撰寫型別或使用輔助函式 |
| 開發體驗 | 支援熱更新(HMR)與插件生態,開發者工具更直觀 | 開發者工具功能較成熟,但在 Vue 3 中稍顯笨重 |
| 效能 | 內部使用 Proxy,自動追蹤依賴,減少不必要的重新渲染 | 需要手動使用 mapState、mapGetters 等輔助函式 |
結論:若是新專案或想要利用 Vue 3 的新特性,建議直接採用 Pinia;若是已有大量 Vuex 程式碼且轉移成本高,仍可繼續使用 Vuex。
3. Pinia 基本使用流程
- 安裝
npm install pinia - 在根實例掛載 Pinia
// main.js import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) const pinia = createPinia() app.use(pinia) app.mount('#app') - 定義 Store
// stores/counter.js import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', { // state 必須是函式,返回一個物件 state: () => ({ count: 0 }), // 直接改變 state 的方法稱為 actions actions: { increment(step = 1) { this.count += step }, decrement(step = 1) { this.count -= step } }, // getters 類似 Vuex 的 computed getters: { doubleCount: (state) => state.count * 2 } }) - 在元件中使用
<template> <div> <p>目前計數:{{ counter.count }}</p> <p>雙倍結果:{{ counter.doubleCount }}</p> <button @click="counter.increment()">+</button> <button @click="counter.decrement()">-</button> </div> </template> <script setup> import { useCounterStore } from '@/stores/counter' const counter = useCounterStore() </script>
4. Vuex 基本使用流程(Vuex 4)
- 安裝
npm install vuex@next - 建立 Store
// store/index.js import { createStore } from 'vuex' export default createStore({ state: { count: 0 }, mutations: { INCREMENT(state, payload) { state.count += payload ?? 1 }, DECREMENT(state, payload) { state.count -= payload ?? 1 } }, actions: { increment({ commit }, payload) { commit('INCREMENT', payload) }, decrement({ commit }, payload) { commit('DECREMENT', payload) } }, getters: { doubleCount: (state) => state.count * 2 } }) - 掛載至根實例
// main.js import { createApp } from 'vue' import store from './store' import App from './App.vue' const app = createApp(App) app.use(store) app.mount('#app') - 在元件中使用
<template> <div> <p>計數:{{ $store.state.count }}</p> <p>雙倍:{{ $store.getters.doubleCount }}</p> <button @click="increment">+</button> <button @click="decrement">-</button> </div> </template> <script> export default { methods: { increment() { this.$store.dispatch('increment') }, decrement() { this.$store.dispatch('decrement') } } } </script>
5. 進階範例:使用 Pinia 管理使用者驗證資訊
// stores/auth.js
import { defineStore } from 'pinia'
import axios from 'axios'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('token') || ''
}),
persist: true, // 若使用 pinia-plugin-persistedstate,可自動持久化
getters: {
isLoggedIn: (state) => !!state.token,
userName: (state) => state.user?.name ?? 'Guest'
},
actions: {
async login(credentials) {
const response = await axios.post('/api/login', credentials)
this.token = response.data.token
this.user = response.data.user
localStorage.setItem('token', this.token)
},
logout() {
this.token = ''
this.user = null
localStorage.removeItem('token')
},
async fetchProfile() {
if (!this.token) return
const res = await axios.get('/api/me', {
headers: { Authorization: `Bearer ${this.token}` }
})
this.user = res.data
}
}
})
<!-- components/Header.vue -->
<template>
<nav>
<span>Hi, {{ auth.userName }}</span>
<button v-if="!auth.isLoggedIn" @click="showLogin = true">登入</button>
<button v-else @click="auth.logout()">登出</button>
<LoginModal v-if="showLogin" @close="showLogin = false" />
</nav>
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LoginModal from './LoginModal.vue'
const auth = useAuthStore()
const showLogin = ref(false)
</script>
6. 進階範例:Vuex 與模組化的 Todo List
// store/modules/todo.js
const state = () => ({
items: []
})
const mutations = {
SET_ITEMS(state, items) {
state.items = items
},
ADD_ITEM(state, item) {
state.items.push(item)
},
TOGGLE_DONE(state, id) {
const todo = state.items.find((i) => i.id === id)
if (todo) todo.done = !todo.done
}
}
const actions = {
async fetchTodos({ commit }) {
const res = await fetch('/api/todos').then((r) => r.json())
commit('SET_ITEMS', res)
},
async addTodo({ commit }, payload) {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }
}).then((r) => r.json())
commit('ADD_ITEM', res)
},
toggleDone({ commit }, id) {
commit('TOGGLE_DONE', id)
}
}
const getters = {
undoneCount: (state) => state.items.filter((i) => !i.done).length
}
export default {
namespaced: true,
state,
mutations,
actions,
getters
}
// store/index.js
import { createStore } from 'vuex'
import todo from './modules/todo'
export default createStore({
modules: {
todo
}
})
<!-- components/TodoList.vue -->
<template>
<div>
<h3>待完成 ({{ $store.getters['todo/undoneCount'] }})</h3>
<ul>
<li v-for="item in $store.state.todo.items" :key="item.id">
<label>
<input type="checkbox" :checked="item.done" @change="toggle(item.id)" />
<span :style="{ textDecoration: item.done ? 'line-through' : 'none' }">
{{ item.title }}
</span>
</label>
</li>
</ul>
<input v-model="newTitle" @keyup.enter="add" placeholder="新增待辦" />
</div>
</template>
<script>
export default {
data() {
return {
newTitle: ''
}
},
created() {
this.$store.dispatch('todo/fetchTodos')
},
methods: {
add() {
if (!this.newTitle.trim()) return
this.$store.dispatch('todo/addTodo', { title: this.newTitle })
this.newTitle = ''
},
toggle(id) {
this.$store.dispatch('todo/toggleDone', id)
}
}
}
</script>
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的做法 |
|---|---|---|
| 直接修改 state | 在 Pinia 中 this.someProp = … 是允許的,但在 Vuex 必須透過 mutation,直接改變 store.state 會失去響應式追蹤 |
Pinia:仍建議把變更封裝在 actions,保持可測試性。Vuex:永遠使用 commit 或 dispatch。 |
| 把大量業務邏輯塞進 store | Store 只負責狀態管理,過多的 API 呼叫或繁雜演算會讓它變成「服務層」的混合,難以維護 | 把 API 放在 service 檔案(如 api/*.js),store 只負責呼叫 service 並更新 state。 |
| 未使用持久化 | 當使用者重新整理頁面或關閉瀏覽器,所有 store 會被重置,導致登入資訊、購物車等遺失 | 使用 pinia-plugin-persistedstate 或 Vuex 的 vuex-persistedstate 來自動同步到 localStorage。 |
| 過度拆分 store | 把每個小功能都獨立成一個 store,會產生大量的 import 與管理成本 | 依照 業務領域(Domain)劃分,例如 auth, cart, product,而不是每個元件一個 store。 |
忘記在模板中使用 storeToRefs(Pinia) |
直接解構 store 會失去響應式,導致 UI 不會更新 |
javascript\nimport { storeToRefs } from 'pinia'\nconst { count } = storeToRefs(counter)\n |
在 Vuex 中直接使用 mapState 的同名屬性 |
可能會和元件本身的 data、props 發生衝突 | 為映射的屬性加上前綴或使用 命名空間 (mapState('module', ['items']))。 |
最佳實踐小結
- 統一風格:全專案統一使用 Pinia 或 Vuex,避免混用。
- 分層設計:
api/→services/→store/→components/。 - 型別安全:若使用 TypeScript,盡量在
defineStore時提供介面或StoreGeneric。 - 測試:將 store 的
actions與getters抽離成純函式,撰寫單元測試。 - 性能監控:利用 Vue Devtools 的 Pinia/Vuex 面板觀察 mutation/action 的頻率,避免不必要的重繪。
實際應用場景
| 場景 | 使用全域狀態的理由 | 推薦方案 |
|---|---|---|
| 使用者登入/權限管理 | 多個頁面、側邊欄、路由守衛都需要即時知道登入狀態 | Pinia + pinia-plugin-persistedstate |
| 購物車 | 商品列表、結帳頁、購物車彈窗都要共享同一筆資料 | Pinia(因為 API 較簡潔) |
| 即時聊天訊息 | 多個聊天視窗、通知列需要即時同步訊息 | Vuex(若已有大型即時架構,且需要複雜的 mutation 追蹤) |
| 多語系切換 | 整個應用的文字、日期格式必須同步變更 | Pinia(簡單的 locale state + getter) |
| 儀表板圖表資料 | 多個圖表共用同一筆 API 回傳的統計資料 | Vuex(因為可以在 mutation 中做深層資料合併) |
案例:一個電商網站的「商品收藏」功能。使用者在商品列表點擊「收藏」後,收藏狀態立即在「我的最愛」頁面同步顯示。此時,我們在 Pinia 中建立
useWishlistStore,在addItemaction 裡呼叫後端 API,成功回傳後即更新items陣列。所有使用wishlist.items的元件(商品卡、側邊欄、結帳頁)會自動重新渲染,無需額外事件傳遞。
總結
全域狀態管理是 Vue3 應用中不可或缺的基礎建設。透過 Pinia 或 Vuex,我們可以:
- 集中管理資料、保證唯一來源,減少元件間的耦合。
- 明確化變更流程,讓除錯與測試變得更直觀。
- 提升開發效率:不再為傳遞 props、emit、provide/inject 耗費時間。
對於新專案,Pinia 的輕量、Composition‑API 風格與 TypeScript 友好度,使其成為首選;對於既有大型專案,Vuex 仍具備成熟的插件生態與穩定性。無論選擇哪一個,遵守「分層、模組化、持久化、測試」的最佳實踐,才能在日後的功能擴充與維護中保持代碼的可讀性與可維護性。
最後提醒:全域狀態雖好,但 過度使用 也是問題。應先從局部 state 開始,僅在跨多個元件或需要持久化的情況下才升級為全域狀態。這樣才能在保持程式簡潔的同時,發揮全域管理的最大效益。祝你在 Vue3 的開發旅程中玩得開心!