Vue3 – Pinia 狀態管理:mapState、mapGetters 的替代方案
簡介
在 Vue 2 時代,我們常使用 Vuex 搭配 mapState、mapGetters 這類輔助函式,把 store 的資料快速映射到元件的 computed 屬性。進入 Vue 3 後,官方推薦的狀態管理工具已經換成 Pinia,而 Pinia 本身就提供了更直觀的 API,讓 mapState、mapGetters 等「映射」工具變得不再必要。
本篇文章將說明:
- 為什麼在 Pinia 中可以拋棄
mapState、mapGetters; - 以
storeToRefs、computed、useStore為核心的替代寫法; - 實作範例、常見陷阱與最佳實踐,幫助你在真實專案中更有效率地管理狀態。
核心概念
1. Pinia 的設計哲學
Pinia 採用了 Composition API 的思路,讓 store 本身就是一個 可被直接 import、可被解構 的普通物件。相比 Vuex,Pinia:
- 自動支援 TypeScript,寫起來更有型別安全。
- 不需要
mapState、mapGetters,直接在setup()中使用storeToRefs即可取得響應式的屬性。 - 支援插件(如持久化、偵錯)但不會干擾基本使用方式。
2. storeToRefs:最常見的替代方案
storeToRefs(store) 會把 store 中的 state、getters 轉成 ref,讓它們在 setup() 中可以直接解構而不失去響應性。
import { defineComponent } from 'vue'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
export default defineComponent({
setup() {
const userStore = useUserStore()
// 把 state、getter 轉成 ref
const { name, age, isAdult } = storeToRefs(userStore)
// name、age 仍是 ref,直接在模板中使用即可
// isAdult 為 getter,也會保持響應式
return { name, age, isAdult }
}
})
重點:使用
storeToRefs後,即使在setup()中解構,也不會失去 Pinia 為 getter 自動建立的依賴追蹤。
3. 直接使用 computed 包裝 getter
如果你只想取用單一 getter,或想在 computed 中加入額外的運算,可自行使用 computed 包裝:
import { computed } from 'vue'
import { useCartStore } from '@/stores/cart'
export default {
setup() {
const cartStore = useCartStore()
// 直接使用 getter
const totalPrice = computed(() => cartStore.totalPrice)
// 加上自訂邏輯
const discountedTotal = computed(() => {
const price = cartStore.totalPrice
return price > 1000 ? price * 0.9 : price
})
return { totalPrice, discountedTotal }
}
}
4. 在 Options API 中仍可使用 storeToRefs
如果你仍習慣 Options API,Pinia 也提供了兼容的寫法:
import { defineComponent } from 'vue'
import { useTodoStore } from '@/stores/todo'
import { storeToRefs } from 'pinia'
export default defineComponent({
computed: {
// 先把 store 轉成 refs,再解構
...storeToRefs(useTodoStore())
},
methods: {
addTodo(text) {
this.add(text) // 直接呼叫 actions
}
}
})
提示:在 Options API 中使用
storeToRefs時,不要再使用mapState、mapGetters,會產生重複的響應式包裝,導致效能下降。
5. 完整範例:Todo List
以下示範一個完整的 Todo List,從 store 定義到元件使用,全部採用 storeToRefs 取代 mapState、mapGetters。
// src/stores/todo.js
import { defineStore } from 'pinia'
export const useTodoStore = defineStore('todo', {
state: () => ({
todos: [] // [{ id, text, done }]
}),
getters: {
// 只回傳已完成的項目
completed: (state) => state.todos.filter(t => t.done),
// 未完成的數量
remainingCount: (state) => state.todos.filter(t => !t.done).length
},
actions: {
add(text) {
this.todos.push({ id: Date.now(), text, done: false })
},
toggle(id) {
const todo = this.todos.find(t => t.id === id)
if (todo) todo.done = !todo.done
},
clearCompleted() {
this.todos = this.todos.filter(t => !t.done)
}
}
})
<!-- src/components/TodoApp.vue -->
<template>
<div>
<input v-model="newText" @keyup.enter="addTodo" placeholder="新增待辦" />
<ul>
<li v-for="item in todos" :key="item.id">
<input type="checkbox" v-model="item.done" @change="toggle(item.id)" />
<span :class="{ done: item.done }">{{ item.text }}</span>
</li>
</ul>
<p>剩餘 {{ remainingCount }} 項</p>
<button @click="clearCompleted">清除已完成</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'
import { storeToRefs } from 'pinia'
const todoStore = useTodoStore()
const { todos, remainingCount } = storeToRefs(todoStore) // 直接解構 refs
const newText = ref('')
// 呼叫 actions 時直接使用 store 本身
function addTodo() {
if (newText.value.trim()) {
todoStore.add(newText.value.trim())
newText.value = ''
}
}
function toggle(id) {
todoStore.toggle(id)
}
function clearCompleted() {
todoStore.clearCompleted()
}
</script>
<style scoped>
.done { text-decoration: line-through; color: #777; }
</style>
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 解構後失去響應性 | 直接 const { count } = store 會把 count 變成普通值,失去自動追蹤。 |
使用 storeToRefs(store) 或 computed(() => store.count)。 |
在同一元件重複呼叫 useStore() |
每次呼叫都會返回同一個實例,但若在 setup 外部呼叫,可能會產生 非響應式 的結果。 |
只在 setup() 或 setup 內部使用,或在全局插件中一次性注入。 |
混用 mapState 與 storeToRefs |
兩者同時使用會產生兩套相同的 ref,導致效能浪費與不一致的狀態。 |
統一使用 storeToRefs,或在 Options API 中直接使用 computed 包裝。 |
| Getter 內部副作用 | Getter 應該是純函式,若在 getter 中直接呼叫 actions,會破壞 Pinia 的快取機制。 | 保持 Getter 純粹,將副作用搬到 actions 或 watch 中。 |
忘記在 store 中返回 ref |
在 state 中使用 ref 會導致兩層響應式(Pinia 已自動包裝)。 |
不要在 state 裡自行使用 ref,直接使用普通值即可。 |
最佳實踐
- 使用
storeToRefs:在setup()中解構時一定要走storeToRefs,保證所有屬性都是ref。 - 保持 Store 純粹:只在
actions裡執行非同步或副作用邏輯,getters只做計算。 - 型別安全:若使用 TypeScript,
defineStore會自動推斷型別,配合storeToRefs可得到完整的型別提示。 - 分層管理:大型專案可以把 domain store(如
userStore、productStore)與 ui store(如modalStore)分開,避免單一 store 變得過於龐大。 - 持久化:對於需要跨頁面保留的狀態(如登入資訊),使用
pinia-plugin-persistedstate,但仍保持storeToRefs的使用方式不變。
實際應用場景
1. 多頁面表單的暫存
在一個需要跨多個路由的表單(如訂票流程),可以建立一個 wizardStore,使用 storeToRefs 讓每個子頁面即時取得目前填寫的資料,而不必每次都 dispatch。
// wizardStore.js
export const useWizardStore = defineStore('wizard', {
state: () => ({
step1: { name: '', email: '' },
step2: { seat: null, price: 0 }
}),
getters: {
isReady: (state) => !!state.step1.name && !!state.step2.seat
},
actions: {
updateStep1(payload) { this.step1 = { ...this.step1, ...payload } },
updateStep2(payload) { this.step2 = { ...this.step2, ...payload } }
}
})
在每個步驟的元件:
setup() {
const wizard = useWizardStore()
const { step1, step2, isReady } = storeToRefs(wizard)
// 表單綁定直接使用 step1.name、step2.seat 等
return { step1, step2, isReady, saveStep1: wizard.updateStep1 }
}
2. 動態權限控制
權限資訊常放在 authStore,透過 getter 計算使用者是否有某功能。使用 storeToRefs 可以在任何元件裡即時取得權限結果,而不需 mapGetters。
// authStore.js
export const useAuthStore = defineStore('auth', {
state: () => ({
roles: [] // ['admin','editor']
}),
getters: {
hasRole: (state) => (role) => state.roles.includes(role)
}
})
setup() {
const auth = useAuthStore()
const { hasRole } = storeToRefs(auth) // hasRole 為 getter,保持響應式
const canEdit = computed(() => hasRole.value('editor'))
return { canEdit }
}
3. 複雜的圖表資料
圖表往往需要從多個 store 抓取資料,然後做計算。使用 computed 包裝多個 storeToRefs 的結果,可讓圖表自動重新渲染。
import { useSalesStore } from '@/stores/sales'
import { useProductStore } from '@/stores/product'
setup() {
const salesStore = useSalesStore()
const productStore = useProductStore()
const { sales } = storeToRefs(salesStore)
const { products } = storeToRefs(productStore)
const chartData = computed(() => {
// 依照 productId 合併銷售額
const map = {}
sales.value.forEach(s => {
const name = products.value.find(p => p.id === s.productId)?.name || '未知'
map[name] = (map[name] || 0) + s.amount
})
return Object.entries(map).map(([label, value]) => ({ label, value }))
})
return { chartData }
}
總結
- Pinia 讓
mapState、mapGetters成為過時的概念,只要掌握storeToRefs、computed、useStore,就能在setup()中以最清晰、最具型別安全的方式取得狀態與衍生值。 - 使用
storeToRefs可以避免解構後失去響應性的問題,同時保持 getter 的快取機制。 - 在 Options API 中仍可使用
storeToRefs,但建議逐步遷移至 Composition API,以享受更好的可組合性與可測試性。 - 常見的陷阱(如直接解構、混用映射函式)只要遵守「所有狀態、所有 getter 必須走
storeToRefs」這一原則,就能有效避免。 - 在實務上,從 多頁表單暫存、權限控制 到 圖表資料合併,Pinia 的新寫法皆能提供更簡潔、可維護的程式碼結構。
把握 Pinia 的這套 「解構即是 ref」 思維,你的 Vue3 專案將更具可讀性、可測試性,同時享受到 Vue3 生態系統最新的開發體驗。祝開發順利,玩得開心! 🚀