跳到主要內容
前端開發

Pinia / Vuex 狀態管理及差異

紀錄 Vuex、Pinia 的狀態管理概念、差異與用法。

W

元件之間要共用資料時,用 props 一層一層傳很快就會受不了。狀態管理就是把這些資料抽出來放在一個共用的地方。

Vue 生態裡先有 Vuex,後來有了 Pinia。這篇整理兩者的差異,以及 Pinia 實際怎麼寫。

Vuex 概要

Vuex 是 Vue 官方早期提供的狀態管理庫,核心是集中式存儲:整個應用共用一棵狀態樹(store),各個元件都能存取或修改同一份狀態。

四個核心概念:

  1. State(狀態):響應式的物件,放應用層級的所有資料。元件透過 getter 讀取。
  2. Mutation(突變):唯一允許修改狀態的地方,而且必須是同步的。接收 state 當第一個參數,加上可選的 payload。
  3. Action(動作):處理非同步操作或批次呼叫多個 mutation。接收 context 物件。
  4. Module(模組):把 store 拆成模組,各自有自己的 state、mutations、actions、getters。

「非同步的事情放 action、真正改狀態的動作放 mutation」這個設計是為了讓每一次狀態變更都可追蹤——Vue DevTools 能列出每一個 mutation,出問題時倒著看就知道狀態是被誰改的。

遇到的瓶頸

規模上來之後,這套設計的代價開始浮現:

  • 模組結構讓狀態分散在多個模組裡,追蹤與管理變得困難。
  • API 冗長。改一個值要先定義 mutation、再定義 action、再在元件裡 dispatch,中間隔了兩層。
  • TypeScript 支援不好。commit('someMutation', payload) 是字串,型別推不出來,打錯字要到執行期才會發現。

Pinia 概要

為了解決這些問題,Vue 團隊開發了 Pinia,用來取代 Vuex 的全域資料管理函式庫。

主要特點:

  1. 與 Composition API 緊密整合:可以用組合式的方式組織與重用邏輯。
  2. 扁平的 store 結構:每個 store 都是獨立的實例,有自己的 state、getters、actions,不用巢狀模組。
  3. TypeScript 支援良好:大量使用型別推斷,不需要額外寫型別包裝。
  4. 輕量:壓縮後約 1KB,並支援自訂 plugin。

兩者的差異

項目VuexPinia
Mutations必須有移除,直接在 action 改 state
模組 namespace要自己設定 namespaced自動,每個 store 天生獨立
TypeScript需要額外的型別包裝型別推斷
取得 state要傳 state 參數直接用 this
SSR支援但設定繁瑣原生支援
Nuxt 3不支援完整支援
體積較大約 1KB
  • 最大的差異是移除了 Mutations。
  • 少了一層之後寫法簡潔很多,代價是從程式碼上看不出它是 Flux 架構——但實務上,多數人本來就覺得 mutation 這層是多餘的儀式。

安裝

Vue 專案

npm install pinia

main.js:

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

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

Nuxt 專案

npm install pinia @pinia/nuxt

nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})

Nuxt 模組會自動處理 SSR 的狀態注水(hydration),不用自己搬狀態。

寫法一:Options 風格

跟 Vuex 比較像,適合從 Vuex 搬過來:

// src/stores/cart.js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),
  getters: {
    totalCost(state) {
      return state.items.reduce(
        (total, item) => total + item.price * item.quantity,
        0
      )
    }
  },
  actions: {
    addItem(item) {
      const existingItem = this.items.find((i) => i.id === item.id)
      if (existingItem) {
        const newItem = {
          ...existingItem,
          quantity: existingItem.quantity + item.quantity
        }
        this.items = this.items.map((i) => (i.id === item.id ? newItem : i))
      } else {
        this.items.push(item)
      }
    },
    removeItem(itemId) {
      this.items = this.items.filter((item) => item.id !== itemId)
    }
  }
})

defineStore 的第一個參數 'cart' 是這個 store 的唯一 ID,DevTools 會用它來標示。

在元件裡使用:

<!-- src/components/Cart.vue -->
<template>
  <div>
    <h2>你的購物車</h2>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.name }} - ${{ item.price }} x {{ item.quantity }}
        <button @click="removeItem(item.id)">刪除</button>
      </li>
    </ul>
    <div>總費用: ${{ totalCost }}</div>
  </div>
</template>

<script setup>
import { storeToRefs } from "pinia";
import { useCartStore } from "../stores/cart";

const cartStore = useCartStore();

const { items, totalCost } = storeToRefs(cartStore);

function removeItem(id) {
  cartStore.removeItem(id);
}
</script>

為什麼要用 storeToRefs

這是最常踩的坑。直接解構會失去響應性:

// 壞掉:items 變成一般變數,store 更新時畫面不會動
const { items, totalCost } = useCartStore();

// 正確:storeToRefs 把 state 與 getters 包成 ref
const { items, totalCost } = storeToRefs(cartStore);

但 action 不要用 storeToRefs 取,它們是函式、本來就不需要響應性,直接從 store 取即可:

const { addItem, removeItem } = cartStore;

寫法二:setup 風格

用 Composition API 的方式寫,跟平常寫元件邏輯一模一樣——ref 就是 state、computed 就是 getter、function 就是 action。

import { getElectionGroups, getCounties } from '~/assets/api/election'

interface Candidate {
  id: string
  society: string
  societyEng: string
  name: string
  subName: string
}

export const useCandidateAndCountyStore = defineStore('candidatesAndCounty', () => {
  // state
  const candidates = ref<Candidate[]>([])
  const counties = ref<string[]>([])

  // getters
  const candidatesGetter = computed(() => candidates.value || [])
  const countiesGetter = computed(() => counties.value || [])

  // actions
  const fetchCandidatesAndCounties = async (): Promise<void> => {
    const [candidatesData, countiesData] = await Promise.all([
      getElectionGroups(),
      getCounties()
    ])
    candidates.value = candidatesData as Candidate[]
    counties.value = countiesData as string[]
  }

  return {
    candidates,
    candidatesGetter,
    counties,
    countiesGetter,
    fetchCandidatesAndCounties
  }
})

要記得 return——沒有回傳的東西外面拿不到,這是 setup 寫法最常見的錯誤。

在元件裡用起來完全一樣:

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCandidateAndCountyStore } from '~/store/candidatesAndCounty'

const store = useCandidateAndCountyStore()
const { candidatesGetter, countiesGetter } = storeToRefs(store)

// 進頁面就抓資料
await store.fetchCandidatesAndCounties()
</script>

<template>
  <ul>
    <li v-for="c in candidatesGetter" :key="c.id">
      {{ c.name }}({{ c.society }})
    </li>
  </ul>
</template>

兩種寫法可以混用,同一個專案裡不同 store 用不同寫法沒問題。setup 風格對 TypeScript 比較友善,型別直接從 ref 推導出來。

store 之間互相呼叫

Pinia 的 store 就是函式,要用別的 store 直接呼叫它就好,不需要像 Vuex 那樣處理 namespace。

stores/user.ts:

export const useUserStore = defineStore('user', () => {
  const token = ref<string | null>(null)
  const isLoggedIn = computed(() => !!token.value)

  const login = async (email: string, password: string) => {
    const res = await api.login(email, password)
    token.value = res.token
  }

  return { token, isLoggedIn, login }
})

stores/cart.ts:

import { useUserStore } from './user'

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

  const checkout = async () => {
    // 在 action 裡呼叫,不要放在 store 的最外層
    const userStore = useUserStore()

    if (!userStore.isLoggedIn) {
      throw new Error('請先登入')
    }

    await api.checkout(items.value, userStore.token)
    items.value = []
  }

  return { items, checkout }
})

關鍵是 useUserStore() 要寫在 action 裡面,不要寫在 store 的最外層。 寫在外層時,那行程式會在模組載入當下就執行,而那時候 Pinia 實例可能還沒建立好(尤其在 SSR 環境),會拿到 getActivePinia was called with no active Pinia 這個錯誤。

要注意兩個 store 互相引用會形成循環相依。就 JavaScript 模組來說這是可行的,但如果 A 跟 B 頻繁互相呼叫,通常代表它們該合併成一個 store,或該把共用的邏輯抽成一般的 composable。

什麼時候該用狀態管理

不是所有共用資料都需要 store。判斷標準大致是:

  • 父子元件之間 → props 與 emit 就夠了
  • 跨多層、多個分支的元件 → 用 store
  • 只是伺服器資料的快取(列表、詳情) → Nuxt 的 useFetch/useAsyncData 通常就夠,不必進 store

過早引入狀態管理,只會讓資料流變得更難追。

2026 年後記

  • 本文保留 Vuex 與 Pinia 的比較筆記。
  • 不同 Vue、Nuxt 與 Pinia 版本的支援範圍可能不同,新專案請以各套件目前文件確認。

相關連結

分享這篇