Appearance
provide 與 inject:跨層溝通
props 與 emits 處理父子之間的溝通很稱職,但元件樹一深,問題就來了。這一節處理「資料要跨過好幾層」的場景。
props 一層一層傳的痛苦
想像書店應用長這樣:App 持有購物車資料,要顯示在最深處的 CartBadge(購物車角標)上:
用 props 走完這條路,TheLayout 與 TheHeader 都得宣告一個自己根本用不到的 prop,只為了往下轉交。這個模式俗稱 props drilling:中間層變成純粹的傳聲筒,每加一層就多改一個檔案。
provide 與 inject 就是圖上那條虛線:祖先元件把資料「提供」出去,任何一層子孫都能直接「注入」,中間層完全不用參與。
provide 與 inject
祖先元件用 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 元素本身。
