本文 AI 產出,尚未審核

Vue3 – Pinia 狀態管理:mapStatemapGetters 的替代方案


簡介

在 Vue 2 時代,我們常使用 Vuex 搭配 mapStatemapGetters 這類輔助函式,把 store 的資料快速映射到元件的 computed 屬性。進入 Vue 3 後,官方推薦的狀態管理工具已經換成 Pinia,而 Pinia 本身就提供了更直觀的 API,讓 mapStatemapGetters 等「映射」工具變得不再必要。

本篇文章將說明:

  1. 為什麼在 Pinia 中可以拋棄 mapStatemapGetters
  2. storeToRefscomputeduseStore 為核心的替代寫法;
  3. 實作範例、常見陷阱與最佳實踐,幫助你在真實專案中更有效率地管理狀態。

核心概念

1. Pinia 的設計哲學

Pinia 採用了 Composition API 的思路,讓 store 本身就是一個 可被直接 import可被解構 的普通物件。相比 Vuex,Pinia:

  • 自動支援 TypeScript,寫起來更有型別安全。
  • 不需要 mapStatemapGetters,直接在 setup() 中使用 storeToRefs 即可取得響應式的屬性。
  • 支援插件(如持久化、偵錯)但不會干擾基本使用方式。

2. storeToRefs:最常見的替代方案

storeToRefs(store) 會把 store 中的 stategetters 轉成 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 時,不要再使用 mapStatemapGetters,會產生重複的響應式包裝,導致效能下降。

5. 完整範例:Todo List

以下示範一個完整的 Todo List,從 store 定義到元件使用,全部採用 storeToRefs 取代 mapStatemapGetters

// 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 內部使用,或在全局插件中一次性注入。
混用 mapStatestoreToRefs 兩者同時使用會產生兩套相同的 ref,導致效能浪費與不一致的狀態。 統一使用 storeToRefs,或在 Options API 中直接使用 computed 包裝。
Getter 內部副作用 Getter 應該是純函式,若在 getter 中直接呼叫 actions,會破壞 Pinia 的快取機制。 保持 Getter 純粹,將副作用搬到 actions 或 watch 中。
忘記在 store 中返回 ref state 中使用 ref 會導致兩層響應式(Pinia 已自動包裝)。 不要在 state 裡自行使用 ref,直接使用普通值即可。

最佳實踐

  1. 使用 storeToRefs:在 setup() 中解構時一定要走 storeToRefs,保證所有屬性都是 ref
  2. 保持 Store 純粹:只在 actions 裡執行非同步或副作用邏輯,getters 只做計算。
  3. 型別安全:若使用 TypeScript,defineStore 會自動推斷型別,配合 storeToRefs 可得到完整的型別提示。
  4. 分層管理:大型專案可以把 domain store(如 userStoreproductStore)與 ui store(如 modalStore)分開,避免單一 store 變得過於龐大。
  5. 持久化:對於需要跨頁面保留的狀態(如登入資訊),使用 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 讓 mapStatemapGetters 成為過時的概念,只要掌握 storeToRefscomputeduseStore,就能在 setup() 中以最清晰、最具型別安全的方式取得狀態與衍生值。
  • 使用 storeToRefs 可以避免解構後失去響應性的問題,同時保持 getter 的快取機制。
  • 在 Options API 中仍可使用 storeToRefs,但建議逐步遷移至 Composition API,以享受更好的可組合性與可測試性。
  • 常見的陷阱(如直接解構、混用映射函式)只要遵守「所有狀態、所有 getter 必須走 storeToRefs」這一原則,就能有效避免。
  • 在實務上,從 多頁表單暫存權限控制圖表資料合併,Pinia 的新寫法皆能提供更簡潔、可維護的程式碼結構。

把握 Pinia 的這套 「解構即是 ref」 思維,你的 Vue3 專案將更具可讀性、可測試性,同時享受到 Vue3 生態系統最新的開發體驗。祝開發順利,玩得開心! 🚀