Skip to content

provide 與 inject:跨層溝通

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

props 與 emits 處理父子之間的溝通很稱職,但元件樹一深,問題就來了。這一節處理「資料要跨過好幾層」的場景。

props 一層一層傳的痛苦

想像書店應用長這樣:App 持有購物車資料,要顯示在最深處的 CartBadge(購物車角標)上:

用 props 走完這條路,TheLayoutTheHeader 都得宣告一個自己根本用不到的 prop,只為了往下轉交。這個模式俗稱 props drilling:中間層變成純粹的傳聲筒,每加一層就多改一個檔案。

provideinject 就是圖上那條虛線:祖先元件把資料「提供」出去,任何一層子孫都能直接「注入」,中間層完全不用參與。

provideinject

祖先元件用 provide(key, value) 提供資料,子孫元件用 inject(key) 取用:

src/App.vue

vue
<script setup>
import { provide, ref } from 'vue'
import TheLayout from './components/TheLayout.vue'

const cartCount = ref(0)

provide('cartCount', cartCount)   // key 是 'cartCount',值是這個 ref 本身
</script>

<template>
  <TheLayout />
</template>

src/components/TheLayout.vue(中間層,對 cart 一無所知):

vue
<script setup>
import CartBadge from './CartBadge.vue'
</script>

<template>
  <header class="layout-header">
    <h1>Kuro 書店</h1>
    <CartBadge />
  </header>
</template>

<style scoped>
.layout-header {
  display: flex;
  justify-content: space-between;
  padding: 1rem 2rem;
}
</style>

src/components/CartBadge.vue

vue
<script setup>
import { inject } from 'vue'

const cartCount = inject('cartCount')   // 拿到的就是 App 提供的那個 ref
</script>

<template>
  <span class="badge">購物車({{ cartCount }})</span>
</template>

<style scoped>
.badge {
  font-variant-numeric: tabular-nums;
}
</style>

注意 provide('cartCount', cartCount) 提供的是 ref 本身,不是 cartCount.value。這樣一來,App 更新數字時,CartBadge 會跟著更新,響應性沿著注入鏈保持連通。

另外,provide 與生命週期 hooks 一樣,必須在 <script setup> 的 setup 流程中同步呼叫。

注入預設值

inject 找不到對應的 provider 時會是 undefined,並在 console 提出警告。如果這個元件也可能被單獨使用,可以給第二個參數當預設值:

js
import { inject, ref } from 'vue'

const cartCount = inject('cartCount', ref(0))   // 沒有 provider 時用 ref(0)

預設值刻意也給一個 ref:provider 提供的是 ref,fallback 就該長一樣的形狀,後續程式碼不必分辨「這次拿到的要不要 .value」。

誰擁有狀態,誰負責修改

上面的 CartBadge 只讀不寫,相安無事。但如果某個深處的元件(例如加入購物車的按鈕)想「改」這筆資料呢?

先把資料流的運作講清楚。provide 傳出去的是同一個 ref:provider 與所有 injector 手上握著同一份,沒有複製、也沒有「傳回父元件再往下更新」這種步驟:

所以注入方直接改,是可以成功的。下面是反例,不要學:

js
// 反例:注入方直接修改共享狀態
const cartCount = inject('cartCount')

cartCount.value++   // 成功:大家共享同一個 ref,所有畫面照樣更新

問題不是「不會更新」,而是「任何深層元件都能更新」:出了狀況不知道是誰改的;修改規則散落在元件樹各處;「庫存不足不能再加」這類商業規則沒有一個守門的地方。這跟上一章「直接修改 props」是同一類問題,只是這次連追蹤的線索(props 宣告)都沒有。

官方建議的模式,跟單向資料流一脈相承:狀態由 provider 擁有;對外提供唯讀的狀態,加上明確的修改函式

src/App.vue

vue
<script setup>
import { provide, readonly, ref } from 'vue'
import AddToCartButton from './components/AddToCartButton.vue'

const cartCount = ref(0)

function addToCart() {
  cartCount.value++
}

// 狀態用 readonly() 包住再提供,修改一律走 addToCart
provide('cart', {
  cartCount: readonly(cartCount),
  addToCart
})
</script>

<template>
  <main class="app">
    <p>購物車共 {{ cartCount }} 件</p>
    <AddToCartButton />
  </main>
</template>

<style scoped>
.app {
  display: grid;
  gap: 0.75rem;
  padding: 2rem;
  justify-items: start;
}
</style>

(實務上按鈕會藏在更深的層裡,這裡直接放在 App 底下,讓範例可以直接執行;inject 不在乎隔了幾層。)

src/components/AddToCartButton.vue(任何深度的子孫):

vue
<script setup>
import { inject } from 'vue'

const { cartCount, addToCart } = inject('cart')
</script>

<template>
  <button type="button" @click="addToCart">
    加入購物車(目前 {{ cartCount }} 件)
  </button>
</template>

readonly() 回傳一個唯讀的響應式版本,把四種情況的行為整理起來:

  • provider 修改原始的 cartCount:成功,一切照舊。
  • injector 讀取 readonly 版本:正常,而且會持續跟著來源更新。
  • injector 嘗試直接改 readonly 版本:被阻止,開發模式下 console 警告。
  • injector 想更新狀態:呼叫 provider 給的 addToCart(),商業規則(例如庫存上限)也集中在這個函式裡把關。

一句話總結:readonly() 擋掉的是修改權,不會切斷響應性。誰擁有、誰修改、從哪裡改,一眼可辨。

大型專案使用 Symbol 作為 key

本節為了讓範例直覺,使用 'cart' 這類字串作為 key。大型專案通常會把 key 獨立放在共用檔案,並改用 Symbol,避免不同功能不小心使用相同字串而互相衝突:

js
// src/keys.js
export const cartKey = Symbol('cart')

// provider 與 injector 匯入同一個 cartKey
provide(cartKey, cartState)
const cart = inject(cartKey)

如果專案使用 TypeScript,還可以把 Symbol 宣告成 Vue 提供的 InjectionKey<T>,讓 provide()inject() 共用同一份值型別。

大量元件要共享狀態時

provide/inject 適合「一個功能範圍內」的跨層共享,例如表單群組、主題設定。當狀態要給整個應用程式用、修改邏輯也越來越多時,該登場的是專門的狀態管理工具,第七章的 Pinia 會完整處理這件事。順帶一提,Vue 2 時代常見的 EventBus(讓任意元件互發事件)在 Vue 3 已不再是建議做法,跨層溝通就用本節的模式或狀態管理取代。

最後補充一個範圍以外的用法:provide 也可以在應用程式層級做,app.provide('key', value) 提供給整棵元件樹,常見於外掛 (plugin) 的設計,先知道有這個入口即可。

跨層的資料通了。下一節處理另一種「穿透」:不是資料要跨層,而是我們的程式碼需要直接碰到 DOM 元素本身。