Skip to content

為什麼需要狀態管理

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

導讀:狀態該放在哪裡

前幾章的元件已經能用 props、emits、provideinject 和 composable 組成完整的功能。它們各自解決不同距離的溝通問題:父元件把資料往下傳,子元件把事件往上送;同一個功能範圍內的元件則可以由祖先提供資料。可是書店首頁的導覽列和書籍詳情頁若都要知道購物車內容,這些資料就不該由其中一個元件代替所有人保管。

本章要回答的是「多個元件如何共享同一份狀態」。我們會用 Pinia 建立一個有清楚擁有者的 store,讓詳情頁負責呼叫加入購物車的行為,導覽列讀取同一份 items。接著再把這個模式延伸到 Vue Router 的登入守衛和重新整理後的狀態恢復。

如果想先複習元件之間的基本溝通,可以回看第三章的 props 與自訂事件第四章的 provide/inject第五章的 composable則介紹了把可重用邏輯抽成函式的方法。本章會把這些邊界收斂成一個判斷:狀態只屬於一個元件或一個功能範圍時,留在元件或 composable;需要讓不同頁面共用同一份資料時,才考慮 store。

從元件溝通到共享狀態

先把幾種做法放在一起比較:

做法適合的範圍誰負責改資料
props/emits父子元件擁有狀態的父元件
provide/inject同一個功能樹中的多層元件provider,通常也會提供修改函式
composable可重用的元件邏輯,或單一功能自己的狀態使用 composable 的元件或功能
store跨頁面、跨元件的應用程式狀態store 的 action 或明確暴露的修改介面

props 和 emits 維持單向資料流,這是最容易追蹤的選擇。當資料只需要經過一層或兩層元件時,直接傳遞通常比引入全域工具更清楚;而自訂事件也不會像 DOM 事件一樣自動冒泡,所以多層溝通要回到元件樹的設計。細節可回看第三章的事件與單向資料流

provideinject 適合像表單、主題或某個頁面區域這樣的功能範圍。它可以跳過中間元件,但資料的生命週期仍然跟 provider 的樹狀範圍有關。provider 應該同時提供必要的操作函式,避免每個注入者各自修改同一份資料。這是第四章 provide/inject裡「誰擁有狀態,誰負責修改」的延伸。

第五章的 composable 則是把狀態與行為包成可重用函式。例如元件自己的搜尋條件、鍵盤操作或表單驗證,通常應該留在 composable。把 ref 放在模組頂層,確實能讓多個元件讀到同一份資料,但它也同時把生命週期、清除時機和修改規則藏在模組外,功能一大就很難追蹤。

常見錯誤

錯誤寫法是把所有跨元件資料都直接放成模組層級的 ref,讓任何 import 它的檔案都能任意修改。這樣做的問題是沒有清楚的狀態擁有者,也沒有集中記錄修改的入口。正確寫法是先判斷範圍:元件自己的邏輯使用 composable;確實要跨頁面共享的資料,再由 store 集中定義 state、讀取方式和修改 action。

所謂集中式狀態管理,就是為一份資料建立 single source of truth。書籍詳情頁和導覽列不各自維護一個購物車,而是讀寫同一個 cart store;其中一個元件呼叫 action 後,另一個元件自然會收到響應式更新。把層層傳遞和中央 store 兩種資料流放在同一張圖上對比:

層層傳遞每多一層,就多一個只負責轉交的檔案;中央 store 這邊的元件彼此互不認識,只認識同一個 store:讀的人跟著更新,改的人一律走 action。

如果你在 Vue 2 時代或本書第一版接觸過狀態管理,會記得這個位置原本屬於 Vuex。為什麼換成 Pinia,而不是繼續用 Vuex 就好?

Vuex 誕生在 Options API 時代,它把「可追蹤」建立在一套嚴格的儀式上:同步修改必須寫成 mutation、非同步流程再包成 action,元件透過 this.$store 和字串名稱去 commit 與 dispatch。這套設計在當年換來了 DevTools 的可追蹤性,代價是每個功能都要跨三層樣板、字串名稱讓型別推導無從著力,專案一大還得引入 modules 與 namespaced 的巢狀命名空間。Pinia 用更少的概念達成同樣的保證:沒有 mutation 這一層,修改 state 一樣會被 DevTools 記錄;store 就是普通的函式與回傳值,型別能自動推導;每個 store 天生獨立,不需要註冊、巢狀或命名空間字串。

這也不是社群另起爐灶把官方作品擠下去。Pinia 本來就是 Vue 核心團隊成員為「下一代 Vuex」所做的設計實驗,最後官方直接讓它以 Pinia 的名字接棒:新專案的預設狀態管理就是 Pinia,Vuex 進入維護模式(maintenance mode),不再發展新功能。換句話說,選 Pinia 不是放棄 Vuex 的路線,而是走上它的下一版。手上有既有 Vuex 專案要維護的讀者,可先參考本書附錄的遷移說明;本章新程式碼一律採用 Pinia。

安裝 Pinia

在既有的 Vue 專案中安裝 Pinia:

shell
pnpm add pinia

使用 create-vue 建立新專案時,也可以在互動提示選擇 Pinia。提示內容會隨 create-vue 版本改變,因此不要只依賴選項的順序;建立完成後,仍應檢查 package.json 的 Pinia 版本和產生的 src/stores/ 內容。本章以 Pinia 4 為版本基準。Pinia 4 的 Setup Store、Option Store 和 storeToRefs() 寫法與 Pinia 3 相同,主要差異是套件採 ESM-only,Vue DevTools API 也要另外安裝 @vue/devtools-api

應用程式要先建立 Pinia,再把它註冊到 app。這個順序讓稍後註冊的 router 守衛在初次導航時可以取得 active Pinia:

src/main.js

js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.use(router)
app.mount('#app')

現在先用最小的購物車 store 看共享狀態的效果。Setup Store 會用 ref 表示 state,並回傳它和 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) {
    items.value.push(book)
  }

  return { items, addItem }
})

書籍詳情頁不需要知道導覽列在哪裡,只要呼叫同一個 store 的 action:

src/views/BookDetailView.vue

vue
<script setup>
import { useCartStore } from '../stores/cart.js'

const cart = useCartStore()

function addToCart() {
  cart.addItem({ id: 1, title: '重新認識 Vue.js' })
}
</script>

<template>
  <button type="button" @click="addToCart">
    加入購物車
  </button>
</template>

導覽列讀取同一個 items,所以詳情頁按下按鈕後徽章會同步更新:

src/components/SiteHeader.vue

vue
<script setup>
import { useCartStore } from '../stores/cart.js'

const cart = useCartStore()
</script>

<template>
  <nav>
    購物車({{ cart.items.length }})
  </nav>
</template>

這個例子刻意只保留 itemsaddItem,讓資料流先變得可見。下一節會正式拆解 defineStore 的 setup store 形狀,再加入 getter 和更多 action。請接著閱讀用 defineStore 定義 store