為什麼需要狀態管理?
幾個彼此沒有父子關係的元件要共享同一份資料時,麻煩就來了。看個例子:
App
├── Header ← 需要 user
├── Sidebar ← 需要 user
└── Main
└── Settings ← 需要修改 user
這三個元件不在同一條父子鏈上。用 props 得一層層往下傳;用 provide/inject 也得在每個地方分別注入,要改的時候牽連一大片。專案越大,這個成本越高。
狀態管理的思路是:把跨元件共享的資料集中放在一個地方。哪個元件要用就直接拿、要改就直接改,改完所有用到的地方自動更新。
Pinia 是什麼?
Pinia 是 Vue 官方的狀態管理套件,Vuex 的後繼者。名稱源自西班牙文「鳳梨」(piña)。
npm install pinia
掛載到 app
// 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')
建立 Store
Store 就是集中管理狀態的單位。Pinia 有兩種定義寫法。
Option Store(選項式)
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Jeremy'
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
}
}
})
Setup Store(組合式)— 推薦
寫起來跟 Composition API 一模一樣:
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// state → ref
const count = ref(0)
const name = ref('Jeremy')
// getters → computed
const doubleCount = computed(() => count.value * 2)
// actions → function
function increment() {
count.value++
}
return { count, name, doubleCount, increment }
})
兩種寫法效果相同。Setup Store 的對應關係很好記:ref 就是 state、computed 就是 getter、function 就是 action。
State — 狀態
State 是 store 裡存的資料。Setup Store 裡就是 ref,Option Store 則是 state() 回傳的東西。
在元件裡使用
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<p>{{ counter.count }}</p>
<p>{{ counter.name }}</p>
</template>
直接修改 state
const counter = useCounterStore()
// 直接改
counter.count++
// 用 $patch 一次改多個
counter.$patch({
count: counter.count + 1,
name: 'Aira'
})
// $patch 也可以傳函式(適合修改陣列)
counter.$patch((state) => {
state.items.push({ id: 1, name: 'item' })
state.count++
})
解構要用 storeToRefs
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
// ❌ 直接解構會失去響應式!
const { count, name } = counter
// ✅ 用 storeToRefs
const { count, name } = storeToRefs(counter)
// 注意:actions(函式)直接解構就好,不需要 storeToRefs
const { increment } = counter
為什麼?counter.count 本質上是個 ref,直接解構拿到的只是當下的原始值,不是 ref 本身。storeToRefs 會幫你保住 ref 的連結。
Getters — 計算屬性
Getter 就是 store 版的 computed:從 state 算出衍生資料,一樣有快取。
export const useCartStore = defineStore('cart', () => {
const items = ref([])
// getter:算出總金額
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
// getter:算出商品數量
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
return { items, totalPrice, itemCount }
})
元件中使用:
<template>
<p>購物車有 {{ cart.itemCount }} 件商品</p>
<p>總計 ${{ cart.totalPrice }}</p>
</template>
Getter 存取其他 store 的 state
import { useAuthStore } from './auth'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const discountedTotal = computed(() => {
const auth = useAuthStore()
const total = items.value.reduce((sum, item) => sum + item.price, 0)
// VIP 打 8 折
return auth.isVip ? total * 0.8 : total
})
return { items, discountedTotal }
})
Actions — 操作
Action 就是修改 state 的函式。跟 Vuex 最大的不同:Pinia 沒有 mutation,想改 state 就直接在函式裡改。
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref('')
const isLoggedIn = computed(() => !!token.value)
async function login(email, password) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
const data = await res.json()
user.value = data.user
token.value = data.token
localStorage.setItem('token', data.token)
}
function logout() {
user.value = null
token.value = ''
localStorage.removeItem('token')
}
return { user, token, isLoggedIn, login, logout }
})
同步、非同步(async/await)都可以,沒有額外限制。
跟 Vuex 的差別
| Vuex | Pinia | |
|---|---|---|
| Mutation | 需要,同步修改 state 一定要用 | 沒有,直接改 |
| Action | 處理非同步,再 commit mutation | 直接改 state,同步非同步都行 |
| Module | 需要,而且有 namespace 的坑 | 每個 store 獨立,不需要 module |
| TypeScript | 支援差,很多地方要手動標型別 | 原生支援,推導很好 |
| 體積 | ~10kb | ~1.5kb |
| DevTools | 支援 | 支援 |
Vuex 最大的痛點就是 mutation。你想改一個 state,得先寫一個 mutation,然後在 action 裡 commit 這個 mutation。一個簡單的修改要經過三個步驟:
// Vuex:改一個 count 要三步
// 1. 定義 mutation
mutations: { INCREMENT(state) { state.count++ } }
// 2. 定義 action
actions: { increment({ commit }) { commit('INCREMENT') } }
// 3. 元件裡呼叫
store.dispatch('increment')
// Pinia:一步搞定
counter.count++
// 或者
counter.increment()
Vuex 的 mutation 設計初衷是為了讓 DevTools 能追蹤每次修改。但 Pinia 在沒有 mutation 的情況下一樣做到了。所以 mutation 就變成了純粹的 boilerplate(樣板程式碼)。
結論:新專案直接用 Pinia,不要再用 Vuex 了。 Vue 官方也這麼建議。
實際使用情境
情境 1:使用者認證
// stores/auth.js
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('token') || '')
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(credentials) {
const res = await api.post('/login', credentials)
token.value = res.data.token
user.value = res.data.user
localStorage.setItem('token', res.data.token)
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
}
async function fetchUser() {
if (!token.value) return
const res = await api.get('/me')
user.value = res.data
}
return { user, token, isLoggedIn, isAdmin, login, logout, fetchUser }
})
情境 2:購物車
// stores/cart.js
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
function addItem(product) {
const existing = items.value.find(item => item.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(productId) {
items.value = items.value.filter(item => item.id !== productId)
}
function clearCart() {
items.value = []
}
return { items, totalPrice, totalItems, addItem, removeItem, clearCart }
})
情境 3:主題切換
// stores/theme.js
export const useThemeStore = defineStore('theme', () => {
const isDark = ref(localStorage.getItem('theme') === 'dark')
const theme = computed(() => isDark.value ? 'dark' : 'light')
function toggle() {
isDark.value = !isDark.value
localStorage.setItem('theme', theme.value)
document.documentElement.classList.toggle('dark', isDark.value)
}
return { isDark, theme, toggle }
})
什麼時候該用 Pinia?
不是所有狀態都該丟進 Pinia。可以這樣判斷:
適合放進 Pinia 的:
- 多個不相關的元件都會用到的資料(使用者資訊、購物車、主題)
- 頁面切換後還要保留的狀態
- 業務邏輯複雜、需要集中管理的狀態
不需要放進 Pinia 的:
- 只有一個元件自己用的狀態 → 用元件的
ref - 只有父子之間傳的資料 → 用
props+emit - 只有某棵子樹要用的資料 → 用
provide/inject - 表單的暫時狀態 → 用元件的
ref或reactive
其實就是一個原則:能用簡單的方式解決就用簡單的方式,不要動不動就上 Pinia。先用 props,不夠再用 provide/inject,真的需要全域共享再用 Pinia。
小結
| 概念 | 一句話 |
|---|---|
| Store | 集中管理狀態的倉庫 |
| State | 就是 ref,存放資料 |
| Getter | 就是 computed,衍生資料 |
| Action | 就是函式,修改資料的操作 |
| storeToRefs | 解構 store 時保留響應式 |
Pinia 真的沒有想像中那麼可怕。如果你已經會用 Composition API 的 ref、computed、普通函式,那你其實已經會用 Pinia 了 — 它們用的是完全一樣的概念,只是多了一層「集中管理」的外殼而已。
Latest Updates
- 2026.06.11 Content updated
