上一篇用 Setup Store 的寫法把 Pinia 的 state、getters、actions 走過一遍。實務上用 Pinia 寫久了,應該很多人都背過這條規則:
從 store 解構 state 要用
storeToRefs,actions 直接解構就好。
我自己也是照著寫了很久,但如果被問「為什麼直接解構會壞?為什麼 action 又不會?」,其實一時答不太上來。今天就把這件事拆清楚,順便看看為什麼兩個組件呼叫同一個 useXxxStore(),拿到的會是同一份資料。
準備一個購物車 store:
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const total = computed(() => items.value.length)
function addItem(name) {
items.value.push(name)
}
function clearCart() {
items.value = []
}
return { items, total, addItem, clearCart }
})
在組件裡直接解構:
<script setup>
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { items, total, addItem, clearCart } = cart
</script>
<template>
<p>件數:{{ total }}</p>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
<button @click="addItem('安全帽' + Date.now())">加入</button>
<button @click="clearCart">清空</button>
</template>
按幾次「加入」會看到:
清單 有 長出來
件數 一直是 0
再按「清空」:
清單 沒有 清掉
同樣是解構出來的東西,有的會動、有的不會,有的一開始會動、後來又不會。這就是我一直說不清楚的原因:它不是「全壞」,而是「看情況壞」。要搞懂,得先回到 store 本身是什麼。
注意一個細節:在 Setup Store 裡 items 明明是 ref,但在組件裡讀 cart.items 卻不用 .value。
回想 Day 4 講 reactive 的時候提過:被 reactive() 包住的物件,如果屬性是 ref,讀取時會自動把 .value 拆開。
const count = ref(0)
const obj = reactive({ count })
console.log(obj.count) // 0,而不是 ref 物件
Pinia 做的事情就是這樣:把 Setup Store return 出來的 { items, total, addItem, clearCart } 放進一個 reactive() 物件裡,這個物件就是我們拿到的 store。
所以:
cart 是一個 Proxycart.items 會經過 Proxy 的 get,裡面的 ref 被自動拆開解構這行:
const { items, total } = cart
等同於:
const items = cart.items
const total = cart.total
也就是 在這個當下讀一次 Proxy,把讀到的值存進一般變數。從這一刻起,items、total 只是普通變數,跟 store 上的屬性已經沒有連線了。
那為什麼實驗裡清單會動、件數不會動?差別在「讀到的值」是什麼:
| 解構出來的 | 讀到的是 | 結果 |
|---|---|---|
total(computed) |
0 這個數字 |
原始值直接複製一份,永遠是 0 |
items(ref 包陣列) |
那個陣列本身(reactive Proxy) | push 改的是同一個陣列,所以看起來正常 |
items 之所以「看起來沒壞」,是因為我們跟 store 剛好抓著同一個陣列,push 的時候大家一起變。
但 clearCart 寫的是 items.value = [],整個陣列被換掉了。store 裡的 items 指向新的空陣列,我們解構出來的變數卻還抓著舊陣列,畫面當然不會清空。
所以真正斷掉的,是「store 上這個屬性」和「我們的變數」之間的連結:
既然解構會斷掉連結,那 addItem、clearCart 為什麼照樣能用?
因為 action 需要的不是「響應式」,而是「被呼叫時能改到正確的 state」。在 Setup Store 裡,action 是透過 閉包 抓住 store 內部的那個 ref:
function addItem(name) {
items.value.push(name) // 這個 items 是 defineStore 裡宣告的那個 ref
}
不管這個函式被誰呼叫、有沒有被解構出來,它改的永遠是 store 裡那個 items。函式本身也不會變,沒有「連結斷掉」的問題,所以 action 可以放心直接解構。
(如果用 Option Store 的寫法,action 裡用的是 this,Pinia 也有幫 action 把 this 綁回 store,一樣不會壞。)
修法就是大家熟悉的寫法:
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { items, total } = storeToRefs(cart) // state、getters
const { addItem, clearCart } = cart // actions
</script>
storeToRefs 會把 store 上的 state 和 getters 各自轉成一個 ref,這個 ref 讀寫時都會回頭找 store,概念上類似:
const items = toRef(cart, 'items')
解構出來的不再是「當下的值」,而是「指向 store 屬性的 ref」,連結就接回來了。
要注意的是,既然拿到的是 ref:
<script> 裡要寫 items.value、total.value
<template> 裡會自動拆開,直接寫 {{ total }}
Vue 本身就有 toRefs,看起來也能做到一樣的事。問題是 toRefs 會把物件上 所有屬性 都轉成 ref,而 store 身上除了我們定義的 state,還有:
$patch、$reset、$subscribe、$id 等等storeToRefs 只挑響應式的 state 和 getters,函式直接跳過,這就是它存在的理由。如果只是在模板裡用,最簡單的寫法是根本不解構:
<script setup>
const cart = useCartStore()
</script>
<template>
<p>件數:{{ cart.total }}</p>
</template>
每次都透過 cart. 去讀,永遠經過 Proxy,不會有斷線問題。解構只是為了寫起來短一點,不是必要的。
再看另一個問題。Header 顯示購物車件數,商品頁負責加入:
// Header.vue
const cart = useCartStore()
// ProductPage.vue
const cart = useCartStore()
cart.addItem('安全帽')
商品頁加入後,Header 的件數馬上更新。兩邊各呼叫一次 useCartStore(),卻拿到同一份資料。
對照一下 Day 21 寫的 composable:
export function useCounter() {
const count = ref(0)
return { count }
}
兩個組件各呼叫一次 useCounter(),就會各自建立一個新的 count,彼此不相干。
同樣是 useXxx(),差別在哪?
// main.js
const app = createApp(App)
app.use(createPinia())
app.use(pinia) 會把這個 pinia 實例掛到整個 app 上(底層用的就是 Day 20 提到的 provide / inject 機制),任何組件都找得到它。
pinia 實例裡面有一個用來存 store 的容器,以 defineStore 的第一個參數(這裡是 'cart')當作 key。呼叫 useCartStore() 時,流程大概是:
'cart' 的 store可以,把 ref 移到函式外面就好:
const count = ref(0) // 移到外面,整個模組只有一份
export function useSharedCounter() {
return { count }
}
這樣所有組件也會拿到同一個 count。那為什麼還需要 Pinia?我自己的理解是:
stores/ 裡,不用到處找哪個 composable 偷偷共享了狀態reactive 物件,所以讀 cart.items 不用 .value
storeToRefs 只把 state、getters 轉成 ref,跳過函式,所以不用 Vue 的 toRefs
cart.xxx 不解構也完全可以app.use(pinia) 和 store 的 id:第一次呼叫建立,之後都拿同一個