跳到主要內容
前端開發

Pinia 與 Vuex:差在哪、怎麼選、怎麼寫

整理 Vuex 的核心概念與它遇到的瓶頸,說明 Pinia 為什麼移除 Mutations、對 TypeScript 好在哪,並實際比較 Options 與 setup 兩種 store 寫法,含 storeToRefs 的用途與 store 之間互相呼叫的做法。

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 的 useFetchuseAsyncData 通常就夠,不必進 store

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

後記

這篇的內容到現在基本上還適用——Pinia 沒有像其他工具那樣被取代,反而成了 Vue 官方推薦的預設方案,Vuex 進入維護模式。要補充的只有一點:Pinia 3 之後不再支援 Vue 2,只跑 Vue 3;還在 Vue 2 專案上的話要留在 Pinia 2。上面所有的寫法都沒有變動。

相關連結

分享這篇