本文 AI 產出,尚未審核

Vue3 元件通信:全域狀態管理(Pinia / Vuex)

簡介

在單頁應用(SPA)中,元件之間的資料交換是不可避免的。隨著功能逐漸複雜,僅靠 props、emit 或 provide/inject 會讓資料流變得難以追蹤、維護成本急速升高。此時 全域狀態管理 就成為解決方案的關鍵。Vue 官方在 Vue 3 時期推出了新一代的狀態管理庫 Pinia,而 Vuex 仍是許多既有專案的既定選擇。本文將說明兩者的核心概念、實作方式,以及在真實專案中如何選擇與使用。


核心概念

1. 為什麼需要全域狀態?

  • 單一真相來源(Single Source of Truth):所有元件都讀取同一個資料來源,避免資料不一致。
  • 可預測的變更流程:所有狀態變更必須透過明確的 actionmutation,讓除錯更容易。
  • 跨層級共享:不再受父子層級限制,任意元件皆可直接存取或修改狀態。

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,自動追蹤依賴,減少不必要的重新渲染 需要手動使用 mapStatemapGetters 等輔助函式

結論:若是新專案或想要利用 Vue 3 的新特性,建議直接採用 Pinia;若是已有大量 Vuex 程式碼且轉移成本高,仍可繼續使用 Vuex。

3. Pinia 基本使用流程

  1. 安裝
    npm install pinia
    
  2. 在根實例掛載 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')
    
  3. 定義 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
      }
    })
    
  4. 在元件中使用
    <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)

  1. 安裝
    npm install vuex@next
    
  2. 建立 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
      }
    })
    
  3. 掛載至根實例
    // 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')
    
  4. 在元件中使用
    <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:永遠使用 commitdispatch
把大量業務邏輯塞進 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']))。

最佳實踐小結

  1. 統一風格:全專案統一使用 Pinia 或 Vuex,避免混用。
  2. 分層設計api/services/store/components/
  3. 型別安全:若使用 TypeScript,盡量在 defineStore 時提供介面或 StoreGeneric
  4. 測試:將 store 的 actionsgetters 抽離成純函式,撰寫單元測試。
  5. 性能監控:利用 Vue Devtools 的 Pinia/Vuex 面板觀察 mutation/action 的頻率,避免不必要的重繪。

實際應用場景

場景 使用全域狀態的理由 推薦方案
使用者登入/權限管理 多個頁面、側邊欄、路由守衛都需要即時知道登入狀態 Pinia + pinia-plugin-persistedstate
購物車 商品列表、結帳頁、購物車彈窗都要共享同一筆資料 Pinia(因為 API 較簡潔)
即時聊天訊息 多個聊天視窗、通知列需要即時同步訊息 Vuex(若已有大型即時架構,且需要複雜的 mutation 追蹤)
多語系切換 整個應用的文字、日期格式必須同步變更 Pinia(簡單的 locale state + getter)
儀表板圖表資料 多個圖表共用同一筆 API 回傳的統計資料 Vuex(因為可以在 mutation 中做深層資料合併)

案例:一個電商網站的「商品收藏」功能。使用者在商品列表點擊「收藏」後,收藏狀態立即在「我的最愛」頁面同步顯示。此時,我們在 Pinia 中建立 useWishlistStore,在 addItem action 裡呼叫後端 API,成功回傳後即更新 items 陣列。所有使用 wishlist.items 的元件(商品卡、側邊欄、結帳頁)會自動重新渲染,無需額外事件傳遞。


總結

全域狀態管理是 Vue3 應用中不可或缺的基礎建設。透過 PiniaVuex,我們可以:

  • 集中管理資料、保證唯一來源,減少元件間的耦合。
  • 明確化變更流程,讓除錯與測試變得更直觀。
  • 提升開發效率:不再為傳遞 props、emit、provide/inject 耗費時間。

對於新專案,Pinia 的輕量、Composition‑API 風格與 TypeScript 友好度,使其成為首選;對於既有大型專案,Vuex 仍具備成熟的插件生態與穩定性。無論選擇哪一個,遵守「分層、模組化、持久化、測試」的最佳實踐,才能在日後的功能擴充與維護中保持代碼的可讀性與可維護性。

最後提醒:全域狀態雖好,但 過度使用 也是問題。應先從局部 state 開始,僅在跨多個元件或需要持久化的情況下才升級為全域狀態。這樣才能在保持程式簡潔的同時,發揮全域管理的最大效益。祝你在 Vue3 的開發旅程中玩得開心!