Skip to content

用 defineStore 定義 store

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

Setup Store 的三個角色

defineStore() 的第一個參數是 store id,第二個參數可以使用 Setup Store 寫法。它的形狀和第五章的 composable 很像:在函式裡建立響應式資料和衍生值,最後把需要公開的內容 return 出去。

Setup Store 裡有三個角色:

  • refreactive 是 state,代表要被保存和共享的原始資料。
  • computed 是 getter,代表從 state 推導出的唯讀結果。
  • 一般函式是 action,代表修改 state 或執行一個完整的業務行為。

這個分類不是要求每一行都放進不同區塊,而是幫助讀者看出資料流。畫面讀 getter,互動呼叫 action,action 再修改 state。既然形狀和 composable 這麼像,值得先把兩者放在一起,看清楚差別到底在哪裡。

和 composable 只差一層包裝

假設我們沒有 Pinia,用第五章的做法把購物車邏輯抽成 composable:

src/composables/useCart.js

js
import { ref } from 'vue'

export function useCart() {
  const items = ref([])

  function addItem(book) {
    items.value.push(book)
  }

  return { items, addItem }
}

問題出在第五章講過的「每次呼叫,各自獨立」:詳情頁呼叫一次 useCart()、導覽列再呼叫一次,會得到兩份互不相干的 items。詳情頁加了商品,導覽列的徽章永遠是 0。第五章末尾用模組層級 ref 撐起最簡單的共享,也講明了它的極限defineStore() 就是那個極限的正式解法。

同一段邏輯交給 Pinia,只需要換一層包裝:

src/stores/cart.js

js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  function addItem(book) {
    items.value.push(book)
  }

  return { items, addItem }
})

標亮的三行就是全部差異:import defineStore、把原本的函式當第二個參數傳進去、給一個 id。函式主體一個字都沒改。

差別發生在呼叫的時候。useCart() 每次呼叫都執行一次函式主體、建出新的一份狀態;useCartStore() 只有第一次呼叫時執行函式主體建立 store 實例,之後不管哪個元件、在哪個頁面呼叫,拿到的都是同一份。這就是上一節說的 single source of truth:詳情頁 addItem,導覽列的徽章自然跟著動。

id 'cart' 則是這個 store 在整個應用程式裡的身分:它必須唯一,Vue DevTools 的 Pinia 面板也用它來顯示 state 與 action 紀錄。可以把 Setup Store 記成一句話:被 Pinia 接管的單例 composable

讓購物車 store 長大

購物車不會只有「把書塞進陣列」。同一本書加兩次應該變成數量 2,而不是兩筆重複資料;這條規則屬於購物車本身,不該讓每個元件自己實作,所以收進 action:

src/stores/cart.js

js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  function addItem(book) {
    const existing = items.value.find((item) => item.id === book.id)

    if (existing) {
      existing.quantity++
      return
    }

    items.value.push({ ...book, quantity: 1 })
  }

  function removeItem(bookId) {
    const index = items.value.findIndex((item) => item.id === bookId)

    if (index !== -1) items.value.splice(index, 1)
  }

  return { items, addItem, removeItem }
})

addItem 先找有沒有同一本書,有就累加數量,沒有才新增一筆並帶上 quantity: 1removeItem 負責整筆移除。之後不管多少個頁面要操作購物車,規則都只有這一份。

接著是推導值。導覽列徽章要顯示總本數、購物車頁要顯示總金額,這些都能從 items 算出來,用 computed 寫成 getter(記得把 computed 加進最上方的 import,並把兩個 getter 加進 return):

src/stores/cart.js(接在 items 宣告之後):

js
const itemCount = computed(() => {
  return items.value.reduce((count, item) => count + item.quantity, 0)
})

const total = computed(() => {
  return items.value.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0,
  )
})

itemCounttotal 不應該在元件裡各自重算;這樣導覽列、購物車頁和結帳頁讀到的永遠是同一套規則。getter 需要參數時,可以回傳一個函式:

js
// getter 需要參數時,可以回傳一個函式
const findItem = computed(() => {
  return (bookId) => items.value.find((item) => item.id === bookId)
})

在元件的 <script setup> 裡使用起來像這樣:

js
import { useCartStore } from '../stores/cart.js'

const cart = useCartStore()
const currentItem = cart.findItem(1)

cart.addItem({ id: 1, title: '重新認識 Vue.js', price: 680 })
cart.removeItem(1)

這段只示範 store 的 API;實際元件裡按鈕事件應該呼叫 addItem,不要在各個元件內重複實作「找相同書籍、增加數量」的規則。這也回扣第一章 AI 協作的購物車情境:請 AI 產生 store 時,先指定 state、getter、action 和命名,再由人檢查每個行為是否符合需求;現在我們已經有能力逐行看懂它產出的每個角色。

組合起來的完整檔案收在下方,可以對照確認每個角色的位置:

目前的完整 cart store
js
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  const itemCount = computed(() => {
    return items.value.reduce((count, item) => count + item.quantity, 0)
  })

  const total = computed(() => {
    return items.value.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    )
  })

  // getter 需要參數時,可以回傳一個函式
  const findItem = computed(() => {
    return (bookId) => items.value.find((item) => item.id === bookId)
  })

  function addItem(book) {
    const existing = items.value.find((item) => item.id === book.id)

    if (existing) {
      existing.quantity++
      return
    }

    items.value.push({ ...book, quantity: 1 })
  }

  function removeItem(bookId) {
    const index = items.value.findIndex((item) => item.id === bookId)

    if (index !== -1) items.value.splice(index, 1)
  }

  return { items, itemCount, total, findItem, addItem, removeItem }
})

檔案放在 src/stores/,store 名稱採 useXxxStorestores/cart.js 對應 useCartStore。這和 composables/useQuantity.jsuse 命名習慣相呼應,也讓讀取 store 的程式碼一眼就能辨認。專案目錄與命名的背景可以回看第一章的專案結構

常見錯誤

Setup Store 最容易漏掉的步驟,是忘記把要公開的 state、getter 或 action 放進 return。函式內即使已經宣告 itemsaddItem,沒有 return 它們,元件取得的 store 就看不到這些內容。修正方式是把公開 API 集中寫在函式結尾,私有的輔助資料則不要 return。

Option Store 對照

Pinia 也支援 Option Store,寫法會把 stategettersactions 分成三個選項:

js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
  }),
  getters: {
    itemCount: (state) => state.items.length,
  },
  actions: {
    addItem(book) {
      this.items.push(book)
    },
  },
})

Option Store 對熟悉 Vuex 或偏好物件設定的人很直觀,但本章主線選 Setup Store,因為它和 Composition API、第五章的 composable 形狀一致,也方便逐步加入 watch、其他 composable 或需要明確型別的邏輯。兩種寫法請在同一個專案裡擇一,不要把一個 store 的 Setup Store 寫法和 Option Store 的 this 規則混在一起;想從 Vuex 逐步搬遷時,再參考附錄的對照說明。

TypeScript 裡的 Pinia store

本章範例使用 JavaScript。若改用 TypeScript,ref([]) 會影響 items 推導出的元素型別,通常應先定義 BookCartItem 型別,再寫成 const items = ref<CartItem[]>([])。Setup Store 的 return 會成為 store 的公開型別,getter 和 action 的參數也會從宣告中得到檢查。第九章會集中整理 Vue 與 TypeScript 的型別寫法。

store 的形狀到這裡完整了:state 保存資料、getter 集中推導、action 集中修改。不過 store 定義得再好,元件取用的方式不對,響應性照樣會斷線。請接著閱讀在元件裡使用 store:storeToRefs