Pinia 與 Vuex:差在哪、怎麼選、怎麼寫
整理 Vuex 的核心概念與它遇到的瓶頸,說明 Pinia 為什麼移除 Mutations、對 TypeScript 好在哪,並實際比較 Options 與 setup 兩種 store 寫法,含 storeToRefs 的用途與 store 之間互相呼叫的做法。
W
元件之間要共用資料時,用 props 一層一層傳很快就會受不了。狀態管理就是把這些資料抽出來放在一個共用的地方。
Vue 生態裡先有 Vuex,後來有了 Pinia。這篇整理兩者的差異,以及 Pinia 實際怎麼寫。
Vuex 概要
Vuex 是 Vue 官方早期提供的狀態管理庫,核心是集中式存儲:整個應用共用一棵狀態樹(store),各個元件都能存取或修改同一份狀態。
四個核心概念:
- State(狀態):響應式的物件,放應用層級的所有資料。元件透過 getter 讀取。
- Mutation(突變):唯一允許修改狀態的地方,而且必須是同步的。接收
state當第一個參數,加上可選的 payload。 - Action(動作):處理非同步操作或批次呼叫多個 mutation。接收
context物件。 - Module(模組):把 store 拆成模組,各自有自己的 state、mutations、actions、getters。
「非同步的事情放 action、真正改狀態的動作放 mutation」這個設計是為了讓每一次狀態變更都可追蹤——Vue DevTools 能列出每一個 mutation,出問題時倒著看就知道狀態是被誰改的。
遇到的瓶頸
規模上來之後,這套設計的代價開始浮現:
- 模組結構讓狀態分散在多個模組裡,追蹤與管理變得困難。
- API 冗長。改一個值要先定義 mutation、再定義 action、再在元件裡 dispatch,中間隔了兩層。
- TypeScript 支援不好。
commit('someMutation', payload)是字串,型別推不出來,打錯字要到執行期才會發現。
Pinia 概要
為了解決這些問題,Vue 團隊開發了 Pinia,用來取代 Vuex 的全域資料管理函式庫。
主要特點:
- 與 Composition API 緊密整合:可以用組合式的方式組織與重用邏輯。
- 扁平的 store 結構:每個 store 都是獨立的實例,有自己的 state、getters、actions,不用巢狀模組。
- TypeScript 支援良好:大量使用型別推斷,不需要額外寫型別包裝。
- 輕量:壓縮後約 1KB,並支援自訂 plugin。
兩者的差異
| 項目 | Vuex | Pinia |
|---|---|---|
| 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
過早引入狀態管理,只會讓資料流變得更難追。
後記
這篇的內容到現在基本上還適用——Pinia 沒有像其他工具那樣被取代,反而成了 Vue 官方推薦的預設方案,Vuex 進入維護模式。要補充的只有一點:Pinia 3 之後不再支援 Vue 2,只跑 Vue 3;還在 Vue 2 專案上的話要留在 Pinia 2。上面所有的寫法都沒有變動。