iT邦幫忙

2026 iThome 鐵人賽

0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 26 篇

Vue 走過路過不要錯過 Day26 - Pinia 實務:storeToRefs 與跨組件共享

  • 分享至 

  • xImage
  •  

上一篇用 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 本身是什麼。


store 本身是一個 reactive 物件

注意一個細節:在 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 是一個 Proxy
  • 讀 cart.items 會經過 Proxy 的 get,裡面的 ref 被自動拆開
  • 這跟 Day 3 自己寫的迷你 reactive,是同一套機制

解構其實就是「讀一次,存起來」

解構這行:

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 上這個屬性」和「我們的變數」之間的連結:

  • 原始值(數字、字串、布林):一解構就斷,很容易發現
  • 物件、陣列:因為還抓著同一個物件,平常看不出來,等到整個被換掉才出問題
    這也是這個 bug 討厭的地方,它常常不是寫的當下壞,而是之後有人加了一個「重設」的 action 才爆出來。

為什麼 action 解構沒事?

既然解構會斷掉連結,那 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,一樣不會壞。)


storeToRefs:把連結接回來

修法就是大家熟悉的寫法:

<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?

Vue 本身就有 toRefs,看起來也能做到一樣的事。問題是 toRefs 會把物件上 所有屬性 都轉成 ref,而 store 身上除了我們定義的 state,還有:

  • 我們寫的 actions
  • Pinia 內建的 $patch、$reset、$subscribe、$id 等等
    這些全被包成 ref 既沒意義也很亂。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(),差別在哪?

關鍵在 app.use(pinia) 和 store 的 id

// main.js
const app = createApp(App)
app.use(createPinia())

app.use(pinia) 會把這個 pinia 實例掛到整個 app 上(底層用的就是 Day 20 提到的 provide / inject 機制),任何組件都找得到它。

pinia 實例裡面有一個用來存 store 的容器,以 defineStore 的第一個參數(這裡是 'cart')當作 key。呼叫 useCartStore() 時,流程大概是:

  1. 找到這個 app 的 pinia 實例
  2. 看看裡面有沒有 id 為 'cart' 的 store
  3. 有的話直接回傳;沒有的話執行 setup 函式建立一個,存起來再回傳
    所以只有第一次呼叫會真的建立 store,之後不管在哪個組件呼叫,拿到的都是同一個。這也是為什麼 store 的 id 不能重複,重複的話兩個 store 會互相蓋掉。

那 composable 能不能也共享?

可以,把 ref 移到函式外面就好:

const count = ref(0) // 移到外面,整個模組只有一份
 
export function useSharedCounter() {
  return { count }
}

這樣所有組件也會拿到同一個 count。那為什麼還需要 Pinia?我自己的理解是:

  • Devtools:Pinia 可以在 Vue Devtools 裡看到每個 store 的狀態、追蹤每次修改
  • SSR 安全:模組層級的變數在伺服器端會被所有使用者共用,Pinia 是跟著 app 實例走,每個請求各自一份
  • 一致的寫法:團隊裡大家都知道全域狀態放在 stores/ 裡,不用到處找哪個 composable 偷偷共享了狀態
    小專案用模組層級的 ref 其實也夠用,但只要狀態是「很多地方都要讀、要改」,放進 Pinia 會比較好管理。

小結

  • Pinia 的 store 是一個 reactive 物件,所以讀 cart.items 不用 .value
  • 解構等於「讀一次存起來」,會斷掉跟 store 屬性的連結
    • 原始值一解構就壞
    • 物件、陣列平常看起來沒事,被整個換掉時才出問題
  • action 透過閉包抓著 store 內部的 state,解構也不會壞
  • storeToRefs 只把 state、getters 轉成 ref,跳過函式,所以不用 Vue 的 toRefs
  • 不想處理這些,直接用 cart.xxx 不解構也完全可以
  • 跨組件共享靠的是 app.use(pinia) 和 store 的 id:第一次呼叫建立,之後都拿同一個

上一篇
Vue 走過路過不要錯過 Day25 - Pinia 基礎:用 Setup Store 寫 state、getters、actions,什麼時候該把東西放進 Pinia?
系列文
重新認識Vue 走過路過不要錯過 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言