iT邦幫忙

2026 iThome 鐵人賽

0
Modern Web

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

Vue 走過路過不要錯過 Day25 - Pinia 基礎:用 Setup Store 寫 state、getters、actions,什麼時候該把東西放進 Pinia?

  • 分享至 

  • xImage
  •  

想像一個很常見的電商畫面:右上角的 header 有一個購物車圖示,上面顯示目前有幾件商品;商品列表裡每張卡片都有「加入購物車」按鈕;另外還有一個購物車頁,列出所有品項和總金額。

這三個地方在組件樹上離得很遠。header 在 Layout 裡,商品卡在列表頁的好幾層底下,購物車頁又是另一個路由。它們需要的卻是同一份購物車資料:卡片按下加入,header 的數字要馬上跟著變。

前面幾天介紹過的工具,在這裡都有點勉強:

  • props / emit(Day 15、Day 18):要從最上層一路傳下去,再一路 emit 上來。
  • provide / inject(Day 20):可以跳過中間層,但資料仍然綁在某個祖先組件身上,換頁時那個組件被卸載,資料也就跟著消失。
  • composable(Day 21):每次呼叫都會建立一份新的狀態,header 和商品卡會各自拿到一台「自己的購物車」。
    這種「很多地方要看同一份資料,而且資料不能跟著某個組件消失」的情境,就是 Pinia 要處理的問題。

安裝與掛載

// 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 都會放在這個容器裡。

用 Setup 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。

這裡有兩個解構的寫法要特別看:

  • state 和 getters 要用 storeToRefs() 解構。
  • actions 可以直接從 store 解構。
    原因跟 Day 3、Day 4 講過的 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。

這不就是一個 composable 嗎?

把 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 額外提供的東西:

  • Vue Devtools 的支援:可以看到每個 store 目前的 state、追蹤是哪個 action 改了資料。
  • Plugin 機制:例如把購物車存進 localStorage 的持久化套件。
  • SSR 的安全性:模組層級的變數在伺服器上會被所有使用者的請求共用,A 使用者的購物車可能被 B 看到。Pinia 會替每個請求建立獨立的 store。
    所以 Pinia 不只是「全域變數」,它是一個有工具支援的共用狀態容器。

共用邏輯 ≠ 共用狀態

講到「什麼時候該用 Pinia」,最常見的想法是:有共用的邏輯就放進 Pinia。這句話混在一起的其實是兩件事:

  • 共用寫法:很多地方都需要同一套流程,但每個地方要的是自己的那一份資料。
  • 共用資料:很多地方要看同一份資料,A 改了,B 要馬上看到。
    第一種是 composable 的工作,第二種才是 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 畫面也一起消失了。
  • 這時兩個列表顯示的都是熱門商品的資料。
  • 150ms 最新商品回來後,data 被覆蓋,熱門商品列表也跟著變成最新商品的資料。
    同樣的情境用 Day 22 的 composable 寫,每次呼叫 useFetch() 都有自己的 data 和 loading,在第 80ms 時,熱門商品已經載入完成,最新商品仍然顯示載入中,兩個列表互不干擾。

所以判斷的關鍵不是「這段程式碼有沒有被重複使用」,而是**「大家要不要看同一份資料」**。

什麼時候該放進 Pinia

把這個系列前面介紹過的工具排在一起,可以用一個順序來判斷資料該放在哪裡:

  1. 只有這個組件自己用 → 組件內的 ref
  2. 父子之間傳遞 → props / emit、defineModel
  3. 一整個子樹都要用,而且資料跟著某個祖先組件存在就好 → provide / inject
  4. 邏輯要重複使用,但每個地方各自一份資料 → composable
  5. 整個 app 要看同一份資料,或組件卸載後資料還要留著 → Pinia
    越往下,資料能被存取的範圍越大,但也越難追蹤是誰改了它。所以從上面開始問,能用前面的方式解決,就不需要往下走。

拿幾個情境來判斷看看:

電商網站右上角購物車圖示的數量:header、商品卡、購物車頁都要看同一份購物車,換頁也不能消失。放 Pinia。

商品列表的「價格由低到高」排序,點進商品頁再按上一頁時希望還保留:列表頁組件在換頁時會被卸載,所以資料不能只放在組件裡。放 Pinia 可以解決。不過如果還希望使用者把網址分享給別人時,對方打開也是同樣的排序,把條件放在網址的 query(例如 ?sort=price_asc)會更適合,這種資料其實屬於網址。

會員資料頁裡,編輯表單還沒送出的暫存內容:只有這個表單在用,離開頁面時通常也希望放棄修改。放在組件的 ref 就好,放進 Pinia 反而會讓使用者下次打開表單時,看到上次沒送出的內容。

登入的使用者資訊:header 要顯示名字、很多頁面要判斷權限、打 API 要帶 token。放 Pinia。

某個 Modal 是否打開:大部分情況只跟觸發它的組件有關,放組件裡就好。

Setup Store 要注意的地方

所有 state 都要 return

在 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 出去就對了。

Setup Store 沒有 $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 }
})

store 裡可以用 composable,但不要用生命週期 hook

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 的東西不要 return

store 裡如果用了 useRoute() 或 inject(),拿到的 route 或注入值只在 store 內部使用就好,不要 return 出去。組件需要時,自己呼叫 useRoute() 或 inject() 就能拿到。

小結

  • Setup Store 用 ref、computed、function 分別對應 state、getters、actions,跟 Composition API 的寫法一致。
  • composable 每次呼叫都是新的一份;store 整個 app 只有一份。
  • 判斷要不要放 Pinia,問的是「大家要不要看同一份資料」,而不是「這段邏輯有沒有被重複使用」。
  • 使用 store 時,state 和 getters 用 storeToRefs() 解構,actions 直接解構。
  • Setup Store 要 return 所有 state、需要重置時自己寫 action、清理工作用 onScopeDispose 而不是生命週期 hook。
    本文程式碼以 Vue 3.5.43、Pinia 4.0.3 測試。

上一篇
Vue 走過路過不要錯過 Day24 - Vue Router 進階實作,導航守衛與路由懶載入
系列文
重新認識Vue 走過路過不要錯過 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言