想像一個很常見的電商畫面:右上角的 header 有一個購物車圖示,上面顯示目前有幾件商品;商品列表裡每張卡片都有「加入購物車」按鈕;另外還有一個購物車頁,列出所有品項和總金額。
這三個地方在組件樹上離得很遠。header 在 Layout 裡,商品卡在列表頁的好幾層底下,購物車頁又是另一個路由。它們需要的卻是同一份購物車資料:卡片按下加入,header 的數字要馬上跟著變。
前面幾天介紹過的工具,在這裡都有點勉強:
// npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
createPinia() 建立的是整個 app 共用的容器,之後定義的每個 store 都會放在這個容器裡。
Pinia 定義 store 有兩種寫法。今天的主題原本是「state、getters、actions」,這三個詞來自 Option Store 的寫法:
// Option Store:用物件分成三個區塊
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
totalCount: (state) => state.items.reduce((sum, item) => sum + item.qty, 0)
},
actions: {
addItem(product) {
// 用 this 存取 state
}
}
})
但這個系列從頭到尾都在寫 Composition API,所以今天改用另一種寫法:Setup Store。defineStore 的第二個參數改成傳一個函式,裡面的寫法跟 <script setup> 或 composable 幾乎一樣:
// stores/cart.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', () => {
// state:用 ref
const items = ref([])
// getters:用 computed
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.qty, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
// actions:一般的 function
function addItem(product) {
const target = items.value.find(item => item.id === product.id)
if (target) target.qty++
else items.value.push({ ...product, qty: 1 })
}
function removeItem(id) {
items.value = items.value.filter(item => item.id !== id)
}
return { items, totalCount, totalPrice, addItem, removeItem }
})
對照規則只有三條:
| Option Store | Setup Store |
|---|---|
state |
ref() / reactive() |
getters |
computed() |
actions |
function |
也就是說,state、getters、actions 這三個概念沒有消失,只是不需要再記一套新的寫法。你本來就會的 ref、computed、function,放進 defineStore 裡就自動變成這三種角色。
defineStore 的第一個參數 'cart' 是這個 store 的 id,整個 app 裡不能重複,devtools 也是靠它來顯示是哪個 store。回傳的函式命名習慣是 use + 名稱 + Store,跟 composable 的 useXxx 是同一套慣例。
// components/AppHeader.vue 的 <script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
const { totalCount } = storeToRefs(cartStore)
// template:
// <span class="badge">{{ totalCount }}</span>
// components/ProductCard.vue 的 <script setup>
import { useCartStore } from '@/stores/cart'
const props = defineProps({ product: Object })
const { addItem } = useCartStore()
// template:
// <button @click="addItem(props.product)">加入購物車</button>
ProductCard 按下按鈕,AppHeader 的數字就會跟著變。兩個組件之間沒有任何 props 或 emit,它們各自呼叫 useCartStore(),拿到的是同一個 store。
這裡有兩個解構的寫法要特別看:
storeToRefs() 解構。reactive 一樣。store 本身是一個 reactive 物件,直接解構會把當下的值複製出來,之後 store 再怎麼變,解構出來的變數都不會更新:const cartStore = useCartStore()
const { totalCount } = cartStore // ❌ 只是當下的數字,之後不會更新
const { totalCount } = storeToRefs(cartStore) // ✅ 變成 ref,會跟著 store 更新
const { addItem } = cartStore // ✅ action 是函式,不需要響應性
我實際測試過:先加入 3 件商品,用兩種方式解構 totalCount,再加入第 4 件。直接解構的變數停在 3,storeToRefs 解構的變成 4。
如果覺得解構麻煩,也可以不解構,直接在 template 裡寫 cartStore.totalCount。store 是 reactive 物件,所以存取 getters 時不需要加 .value。
把 Setup Store 跟 Day 21 寫的 composable 擺在一起看,函式內容幾乎一模一樣。差別在於呼叫一次會得到什麼。
composable 每次被呼叫,函式就重新執行一次,裡面的 ref 也重新建立一次:
function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
const a = useCounter()
const b = useCounter()
a.increment()
console.log(a.count.value) // 1
console.log(b.count.value) // 0,b 是另一份
Pinia store 的 setup 函式在整個 app 裡只會執行一次。第一次呼叫 useCartStore() 時建立 store,之後不管在哪個組件呼叫,拿到的都是同一個:
const a = useCartStore()
const b = useCartStore()
console.log(a === b) // true
寫到這裡可能會有人想到一個捷徑:把 ref 宣告在 composable 函式的外面,不就能讓大家共用了嗎?
// 宣告在函式外面,所有呼叫共用同一個 ref
const count = ref(0)
export function useSharedCounter() {
const increment = () => count.value++
return { count, increment }
}
這樣寫確實能共用,小型專案也有人這樣用。但它就只是一個模組層級的變數,少了 Pinia 額外提供的東西:
講到「什麼時候該用 Pinia」,最常見的想法是:有共用的邏輯就放進 Pinia。這句話混在一起的其實是兩件事:
用 Day 22 的 useFetch 來試試看。假設首頁有「熱門商品」和「最新商品」兩個列表,覺得打 API 的邏輯是共用的,就把它寫成 store:
// ❌ 把「共用寫法」放進 store
export const useFetchStore = defineStore('fetch', () => {
const data = ref(null)
const loading = ref(false)
async function fetchData(url) {
loading.value = true
const res = await fetch(url)
data.value = await res.json()
loading.value = false
}
return { data, loading, fetchData }
})
// 熱門商品列表
const hotStore = useFetchStore()
hotStore.fetchData('/api/products?sort=hot')
// 最新商品列表
const latestStore = useFetchStore()
latestStore.fetchData('/api/products?sort=new')
hotStore 和 latestStore 其實是同一個 store,所以兩個列表共用同一個 data 和 loading。我用一個假的 API 測試,讓熱門商品 50ms 回來、最新商品 150ms 回來,在第 80ms 時檢查狀態:
loading 被設成 false,但最新商品其實還在載入,它的 loading 畫面也一起消失了。data 被覆蓋,熱門商品列表也跟著變成最新商品的資料。useFetch() 都有自己的 data 和 loading,在第 80ms 時,熱門商品已經載入完成,最新商品仍然顯示載入中,兩個列表互不干擾。所以判斷的關鍵不是「這段程式碼有沒有被重複使用」,而是**「大家要不要看同一份資料」**。
把這個系列前面介紹過的工具排在一起,可以用一個順序來判斷資料該放在哪裡:
ref
defineModel
拿幾個情境來判斷看看:
電商網站右上角購物車圖示的數量:header、商品卡、購物車頁都要看同一份購物車,換頁也不能消失。放 Pinia。
商品列表的「價格由低到高」排序,點進商品頁再按上一頁時希望還保留:列表頁組件在換頁時會被卸載,所以資料不能只放在組件裡。放 Pinia 可以解決。不過如果還希望使用者把網址分享給別人時,對方打開也是同樣的排序,把條件放在網址的 query(例如 ?sort=price_asc)會更適合,這種資料其實屬於網址。
會員資料頁裡,編輯表單還沒送出的暫存內容:只有這個表單在用,離開頁面時通常也希望放棄修改。放在組件的 ref 就好,放進 Pinia 反而會讓使用者下次打開表單時,看到上次沒送出的內容。
登入的使用者資訊:header 要顯示名字、很多頁面要判斷權限、打 API 要帶 token。放 Pinia。
某個 Modal 是否打開:大部分情況只跟觸發它的組件有關,放組件裡就好。
在 Setup Store 裡,沒有 return 出去的 ref 不會被 Pinia 當成 state:
export const useExampleStore = defineStore('example', () => {
const secret = ref(1)
const shown = ref(2)
return { shown } // secret 沒有 return
})
const store = useExampleStore()
console.log(Object.keys(store.$state)) // ['shown']
看起來好像能做出「私有 state」,但官方文件明確說不要這樣做,沒有 return 的 state 會讓 SSR、devtools 和 plugin 出問題。所有 state 都 return 出去就對了。
$reset()Option Store 可以呼叫 store.$reset() 把 state 恢復成初始值,因為 Pinia 可以重新執行 state() 函式。Setup Store 沒有辦法知道「初始值」是什麼,呼叫 $reset() 時會直接拋出錯誤:
🍍: Store "cart" is built using the setup syntax and does not implement $reset().
需要重置的話,自己寫一個 action:
export const useCartStore = defineStore('cart', () => {
const items = ref([])
function clearCart() {
items.value = []
}
return { items, clearCart }
})
Setup Store 比 Option Store 彈性的地方在於,裡面可以使用 watch、其他 composable、useRoute() 或 inject()。不過有一個陷阱:不要在 store 裡使用 onMounted、onUnmounted 這類生命週期 hook。
store 不屬於任何組件,但它是在第一個呼叫 useXxxStore() 的組件的 setup 過程中被建立的。這時候 Vue 會把 store 裡的 onUnmounted 註冊到那個組件上,而且不會有任何警告。我實際測試過:A、B 兩個組件都使用同一個 store,A 先建立 store,接著把 A 卸載。B 還在畫面上、store 也還在使用,store 裡的 onUnmounted 卻已經執行了。
如果 store 裡真的需要清理工作(例如計時器或事件監聽),要用 onScopeDispose。它會綁定在 store 自己的作用域上,只有在 store 被 $dispose() 時才會執行,不會跟任何組件的生命週期綁在一起。
store 裡如果用了 useRoute() 或 inject(),拿到的 route 或注入值只在 store 內部使用就好,不要 return 出去。組件需要時,自己呼叫 useRoute() 或 inject() 就能拿到。
ref、computed、function 分別對應 state、getters、actions,跟 Composition API 的寫法一致。storeToRefs() 解構,actions 直接解構。onScopeDispose 而不是生命週期 hook。