iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

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

Vue 走過路過不要錯過 Day20 - provide / inject:不想再一層一層傳 props

  • 分享至 

  • xImage
  •  

Day 19 做了一顆共用按鈕 BaseButton。實際專案裡,按鈕很少單獨出現:表單底部有「取消/儲存」,列表上方有一整排工具列按鈕。設計稿通常會要求同一組按鈕的尺寸一致。

最直覺的寫法是每顆都寫一次 size:

<div class="toolbar">
  <BaseButton size="small">新增</BaseButton>
  <BaseButton size="small">匯出</BaseButton>
  <BaseButton size="small">刪除</BaseButton>
</div>

三顆還好,十顆就很煩,而且哪天設計改成 medium,要一顆一顆改。比較理想的寫法是讓外層決定,裡面的按鈕自己跟著變:

<ButtonGroup size="small">
  <BaseButton>新增</BaseButton>
  <BaseButton>匯出</BaseButton>
  <BaseButton>刪除</BaseButton>
</ButtonGroup>

問題來了:ButtonGroup 要怎麼把 size 交給裡面的按鈕?

還記得 Day 19 說的嗎?這幾顆 BaseButton 是父組件寫在 slot 裡的,ButtonGroup 只負責呼叫 slot 函式,沒辦法替它們加上 props。就算不用 slot,如果按鈕被包在更深的地方,例如外面又套了一層 ToolbarSection,props 也得一層一層往下傳。


一層一層傳 props 會怎樣

假設結構長這樣:

Page
 └─ Toolbar          ← 決定 size
     └─ ToolbarSection
         └─ ActionArea
             └─ BaseButton   ← 真正要用 size

只靠 props 的話,ToolbarSection 和 ActionArea 明明用不到 size,卻都要寫 defineProps 接住,再傳給下一層。這種情況叫 prop drilling(props 鑽洞)。

麻煩不只是寫起來囉嗦:

  • 中間層被迫認識跟自己無關的資料,ActionArea 的 props 清單裡多了一個它根本不在乎的 size。
  • 改一個名字要改一整串,size 想改成 buttonSize,路徑上每一層都要動。
  • 組件變得不好重用,換個地方用 ActionArea,還得記得幫它傳一個跟它無關的 prop。
    provide / inject 就是用來跳過中間層的:上層提供(provide),下層不管隔幾層,直接拿(inject)。

基本用法

ButtonGroup 提供尺寸:

<!-- ButtonGroup.vue -->
<script setup>
import { provide } from 'vue'
 
const props = defineProps({
  size: { type: String, default: 'medium' }
})
 
provide('buttonSize', props.size)
</script>
 
<template>
  <div class="btn-group">
    <slot />
  </div>
</template>

BaseButton 往上拿:

<!-- BaseButton.vue -->
<script setup>
import { inject, computed } from 'vue'
 
const props = defineProps({
  size: String
})
 
// 第二個參數是預設值:沒被任何 ButtonGroup 包住時用它
const groupSize = inject('buttonSize', 'medium')
 
// 自己有傳 size 就用自己的,沒有就跟著 group
const finalSize = computed(() => props.size ?? groupSize)
</script>
 
<template>
  <button :class="['btn', `btn-${finalSize}`]">
    <slot />
  </button>
</template>

這樣同一頁放兩組也沒問題,各自的按鈕會跟著各自的 group:

<ButtonGroup size="small">
  <BaseButton>新增</BaseButton>
  <BaseButton>匯出</BaseButton>
</ButtonGroup>
 
<ButtonGroup size="large">
  <BaseButton>取消</BaseButton>
  <BaseButton size="medium">儲存</BaseButton> <!-- 自己指定的優先 -->
</ButtonGroup>

provide(key, value) 只做一件事:在「這個組件底下」放一個鍵值。inject(key) 則是往上找,找到最近一個提供這個 key 的祖先。


陷阱:傳出去的是值,還是盒子?

上面的寫法有個問題。假設 size 會變,例如手機版要自動切成 small:

<ButtonGroup :size="isMobile ? 'small' : 'large'">
  ...
</ButtonGroup>

縮放視窗之後你會發現:按鈕尺寸沒有跟著變。

原因跟 Vue 無關,是 JS 本身的特性。provide('buttonSize', props.size) 執行的當下,props.size 是一個字串,例如 'large'。字串是原始型別,放進去的就是一份複製出來的值,之後 props.size 怎麼變,都跟已經放進去的那個 'large' 沒關係。

跟這段 JS 是一樣的道理:

let size = 'large'
const box = { buttonSize: size }
 
size = 'small'
console.log(box.buttonSize) // 'large',不會跟著變

要讓下層跟著變,就要傳一個會被追蹤的東西,也就是 ref 或 computed:

<!-- ButtonGroup.vue -->
<script setup>
import { provide, toRef } from 'vue'
 
const props = defineProps({
  size: { type: String, default: 'medium' }
})
 
// 傳的是「盒子」,不是盒子裡的值
provide('buttonSize', toRef(props, 'size'))
</script>

BaseButton 那邊拿到的就是一個 ref,用 unref 統一處理(沒被包住時拿到的預設值是字串,被包住時是 ref):

<!-- BaseButton.vue -->
<script setup>
import { inject, computed, unref } from 'vue'
 
const props = defineProps({ size: String })
const groupSize = inject('buttonSize', 'medium')
 
const finalSize = computed(() => props.size ?? unref(groupSize))
</script>

記一個原則就好:provide 一個 ref,不要 provide ref.value。Day 4 講 ref 的時候提過,.value 一取出來就只是個普通的值,響應式就斷了,這裡是同一件事。


子孫可以直接改嗎?

換一個常見的例子:整個 App 共用的主題色。

<!-- App.vue -->
<script setup>
import { ref, provide } from 'vue'
 
const theme = ref('light')
provide('theme', theme)
</script>

這樣寫,任何一個子孫組件拿到 theme 之後都能直接 theme.value = 'dark'。看起來很方便,但專案一大,主題突然被改掉時,你會找不到是誰改的,因為每一個 inject 它的組件都有可能。

Vue 官方建議的做法是:資料放在哪裡,就在哪裡改。往下傳的時候給一份唯讀的,再另外提供一個修改用的函式:

<!-- App.vue -->
<script setup>
import { ref, readonly, provide } from 'vue'
 
const theme = ref('light')
 
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
 
provide('theme', {
  theme: readonly(theme),
  toggleTheme
})
</script>
<!-- 很深的某個 ThemeSwitch.vue -->
<script setup>
import { inject } from 'vue'
 
const { theme, toggleTheme } = inject('theme')
</script>
 
<template>
  <button @click="toggleTheme">目前:{{ theme }}</button>
</template>

子孫如果硬要寫 theme.value = 'dark',Vue 會在開發模式下跳警告,而且不會生效。要改,只能透過 toggleTheme,所有修改都集中在 App.vue 一個地方。

這跟 props 的單向資料流是同一個精神:資料往下流,修改的請求往上送。


用 Symbol 當 key

目前的 key 都是字串,例如 'buttonSize'、'theme'。專案小的時候沒事,但如果你用的某個 UI 套件剛好也 provide 了一個 'theme',兩邊就會撞名,近的會把遠的蓋掉。

比較保險的做法是用 Symbol,因為每一個 Symbol 都是獨一無二的,就算描述文字一樣也不會相等:

console.log(Symbol('theme') === Symbol('theme')) // false

把 key 集中放在一個檔案:

// injectionKeys.js
export const buttonGroupKey = Symbol('buttonGroup')
export const themeKey = Symbol('theme')

provide 和 inject 都 import 同一個 key:

// ButtonGroup.vue
import { buttonGroupKey } from '@/injectionKeys'
provide(buttonGroupKey, toRef(props, 'size'))
 
// BaseButton.vue
import { buttonGroupKey } from '@/injectionKeys'
const groupSize = inject(buttonGroupKey, 'medium')

這樣還有一個好處:想知道「誰提供、誰在用」,直接在編輯器搜尋 buttonGroupKey 就找得到。有用 TypeScript 的話,Vue 也提供 InjectionKey<T> 型別,可以讓 inject 拿到的值自動帶型別。


inject 是怎麼「往上找」的?

講到這裡,你可能會好奇:inject 說「往上找最近的祖先」,難道每次都要一層一層 parent 往上爬嗎?

翻 Vue 的原始碼(runtime-core/src/apiInject.ts),provide 的核心只有幾行:

// 簡化過的 provide
function provide(key, value) {
  let provides = currentInstance.provides
  const parentProvides = currentInstance.parent && currentInstance.parent.provides
 
  // 預設情況下,組件直接沿用父組件的 provides 物件
  // 第一次自己呼叫 provide 時,才建立一個「以父組件 provides 為原型」的新物件
  if (parentProvides === provides) {
    provides = currentInstance.provides = Object.create(parentProvides)
  }
 
  provides[key] = value
}

inject 則是:

// 簡化過的 inject
function inject(key, defaultValue) {
  const provides = instance.parent.provides
 
  if (key in provides) {
    return provides[key]
  }
  return defaultValue
}

看到 Object.create 和 in 了嗎?inject 的「往上找」,其實就是 JS 的原型鏈。

Object.create(parent) 會建立一個新物件,並把 parent 設成它的原型。讀取屬性時,自己身上沒有,JS 就會自動沿著原型往上找。in 運算子也一樣,會連原型鏈上的屬性一起檢查。

用純 JS 模擬一下:

// App 層
const appProvides = { theme: 'light' }
 
// Toolbar 呼叫了 provide → 以 App 的 provides 為原型
const toolbarProvides = Object.create(appProvides)
toolbarProvides.buttonSize = 'large'
 
// 裡面又包了一層 ButtonGroup,也呼叫了 provide
const groupProvides = Object.create(toolbarProvides)
groupProvides.buttonSize = 'small'
 
console.log(groupProvides.buttonSize) // 'small',自己就有
console.log(groupProvides.theme)      // 'light',沿原型鏈往上找到 App 的
console.log('theme' in groupProvides) // true
console.log('theme' in {})            // false

這個設計解釋了 provide / inject 的幾個行為:

  • 近的優先:巢狀的 ButtonGroup 會蓋掉外層的 buttonSize,因為原型鏈上先找到誰就用誰,跟原型鏈的屬性遮蔽(shadowing)一樣。
  • 沒呼叫 provide 的組件幾乎沒有成本:它們直接共用父組件的 provides 物件,不會額外建立新物件。
  • 自己 provide 的東西,自己 inject 不到:注意 inject 讀的是 instance.parent.provides,是從父組件開始找,不包含自己。
  • 找不到才用預設值:原型鏈走到底都沒有,就回傳第二個參數;連預設值都沒給,開發模式會跳 injection "xxx" not found 的警告。

都有 Pinia 了,為什麼還要 provide / inject?

這大概是看到這裡最常冒出來的問題。Pinia 也能讓任何組件直接拿資料、不用傳 props,那 ButtonGroup 改用 Pinia 會怎樣?

// stores/button.js
export const useButtonStore = defineStore('button', () => {
  const size = ref('medium')
  return { size }
})
<!-- ButtonGroup.vue -->
<script setup>
const props = defineProps({ size: String })
const buttonStore = useButtonStore()
buttonStore.size = props.size
</script>

只放一組的時候看起來沒問題。但回到前面那個例子,同一頁放一組 small、一組 large:

<ButtonGroup size="small">...</ButtonGroup>
<ButtonGroup size="large">...</ButtonGroup>

兩組會變成一樣大。因為 useButtonStore() 在整個 App 裡只有一份,後面那組把 size 改成 large,前面那組也跟著變。

差別就在這裡:

provide / inject Pinia
範圍 只有提供者底下那棵子樹看得到 整個 App 都看得到
有幾份 每個提供者實例各一份 同一個 store 全 App 只有一份
活多久 跟著提供者組件,組件卸載就沒了 跟著 App,重新整理前都在
依賴 Vue 內建 要另外安裝
除錯 Vue Devtools 看得到,但沒有時間軸 有專屬面板,可以看 state 變化

所以判斷方式很簡單,問自己:這份資料是「整個 App 共享一份」,還是「每一組各自一份」?

  • 登入使用者、購物車、全站設定:整個 App 一份,用 Pinia。
  • ButtonGroup 的尺寸、Tabs 目前選中哪一頁、Form 底下每個欄位的驗證狀態:每一組各自一份,用 provide / inject。
    還有兩個情境也偏向 provide / inject:

資料應該跟著組件一起消失。 例如一個多步驟表單精靈,使用者填到一半離開頁面,再回來時應該是空白的。放 Pinia 的話,資料會一直留著,你得記得手動 $reset();放在精靈組件裡 provide 給每個步驟,組件一卸載,資料自然就沒了。

你在寫給別人用的組件。 如果 ButtonGroup 要包成套件給其他專案用,你不能要求使用者一定要裝 Pinia。很多 UI 套件的 Form / FormItem、Tabs / TabPane 這類「父子成對」的組件,內部都是用 provide / inject 溝通的。

其實 Pinia 自己就在用 provide

最後來看一個有趣的地方。app.use(pinia) 之後,為什麼任何組件呼叫 useStore() 都拿得到同一個 pinia?

打開 Pinia 的原始碼 createPinia.ts,install 裡有這一行:

install(app) {
  setActivePinia(pinia)
  pinia._a = app
  app.provide(piniaSymbol, pinia)   // ← 就是它
  app.config.globalProperties.$pinia = pinia
  // ...
}

app.provide 是掛在 App 最上層的 provide,也就是前面原型鏈模擬裡的 appProvides,所有組件的原型鏈最後都會走到這裡。而 useStore() 裡面做的事,就是用 inject(piniaSymbol) 把這個 pinia 實例拿出來,再從裡面找對應 id 的 store。

所以 Pinia 和 provide / inject 不是二選一的競爭關係。Pinia 是蓋在 provide / inject 上面的一層:它在 App 最頂端 provide 一個 pinia,再用 store id 讓全 App 共用同一份資料。Vue Router 的 useRoute()、useRouter() 也是同樣的做法。

會用 Pinia,其實早就在用 provide / inject 了,只是它幫你包起來了。


幾個使用上的注意事項

inject 要在 setup 期間呼叫。 跟 Day 15 的生命週期 hook 一樣,inject() 要知道「現在是哪個組件」才能往上找,所以要寫在 <script setup> 的頂層,不能放在點擊事件或 setTimeout 裡。

// ✅
const groupSize = inject(buttonGroupKey, 'medium')
 
// ❌ 點擊時已經不在 setup 期間了
function onClick() {
  const groupSize = inject(buttonGroupKey)
}

預設值是物件時,用工廠函式。 第三個參數傳 true,代表第二個參數是一個「產生預設值的函式」,每次需要預設值時才建立,避免多個組件共用同一個物件:

const config = inject(configKey, () => ({ size: 'medium', rounded: true }), true)

只能往下,不能往上。 provide 只對子孫有效,父組件和兄弟組件拿不到。如果需要「旁邊的組件也知道」,代表提供者應該往上移一層,或是這份資料其實該放 Pinia。

不要什麼都用 provide。 它跳過了中間層,也代表在子組件的 props 上看不出它依賴了什麼。一般父子之間的資料傳遞還是用 props;只有在真的要跨好幾層、或是像 ButtonGroup 這種「父子成對」的組件,才拿出 provide / inject。


小結

  • provide / inject 讓上層組件提供資料,子孫組件不管隔幾層都能直接拿,解決 prop drilling。
  • 要保留響應式,就 provide ref 或 computed,不要 provide .value。
  • 不想讓子孫亂改,就 provide readonly 的資料加上修改函式。
  • key 用 Symbol 集中管理,避免撞名。
  • inject 往上找的方式,就是 JS 的原型鏈:Object.create 加上 in。
  • 跟 Pinia 的差別在「範圍、份數、壽命」:整個 App 一份用 Pinia,每一組各自一份用 provide / inject。而 Pinia 本身就是用 app.provide 實作的。


上一篇
Vue 走過路過不要錯過 Day19 - slots:插槽其實是一個函式
系列文
重新認識Vue 走過路過不要錯過 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言