Appearance
用 defineStore 定義 store
Setup Store 的三個角色
defineStore() 的第一個參數是 store id,第二個參數可以使用 Setup Store 寫法。它的形狀和第五章的 composable 很像:在函式裡建立響應式資料和衍生值,最後把需要公開的內容 return 出去。
Setup Store 裡有三個角色:
ref或reactive是 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: 1;removeItem 負責整筆移除。之後不管多少個頁面要操作購物車,規則都只有這一份。
接著是推導值。導覽列徽章要顯示總本數、購物車頁要顯示總金額,這些都能從 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,
)
})itemCount 和 total 不應該在元件裡各自重算;這樣導覽列、購物車頁和結帳頁讀到的永遠是同一套規則。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 名稱採 useXxxStore:stores/cart.js 對應 useCartStore。這和 composables/useQuantity.js 的 use 命名習慣相呼應,也讓讀取 store 的程式碼一眼就能辨認。專案目錄與命名的背景可以回看第一章的專案結構。
常見錯誤
Setup Store 最容易漏掉的步驟,是忘記把要公開的 state、getter 或 action 放進 return。函式內即使已經宣告 items 和 addItem,沒有 return 它們,元件取得的 store 就看不到這些內容。修正方式是把公開 API 集中寫在函式結尾,私有的輔助資料則不要 return。
Option Store 對照
Pinia 也支援 Option Store,寫法會把 state、getters 和 actions 分成三個選項:
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 推導出的元素型別,通常應先定義 Book 或 CartItem 型別,再寫成 const items = ref<CartItem[]>([])。Setup Store 的 return 會成為 store 的公開型別,getter 和 action 的參數也會從宣告中得到檢查。第九章會集中整理 Vue 與 TypeScript 的型別寫法。
store 的形狀到這裡完整了:state 保存資料、getter 集中推導、action 集中修改。不過 store 定義得再好,元件取用的方式不對,響應性照樣會斷線。請接著閱讀在元件裡使用 store:storeToRefs。
