本文 AI 產出,尚未審核

Vue3 – Pinia 狀態管理:mapState、mapGetters 的替代方案


簡介

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

本篇文章將說明:

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

核心概念

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,直接使用普通值即可。

最佳實踐

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

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