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 也得一層一層往下傳。
假設結構長這樣:
Page
└─ Toolbar ← 決定 size
└─ ToolbarSection
└─ ActionArea
└─ BaseButton ← 真正要用 size
只靠 props 的話,ToolbarSection 和 ActionArea 明明用不到 size,卻都要寫 defineProps 接住,再傳給下一層。這種情況叫 prop drilling(props 鑽洞)。
麻煩不只是寫起來囉嗦:
ActionArea 的 props 清單裡多了一個它根本不在乎的 size。size 想改成 buttonSize,路徑上每一層都要動。ActionArea,還得記得幫它傳一個跟它無關的 prop。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 的單向資料流是同一個精神:資料往下流,修改的請求往上送。
目前的 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 說「往上找最近的祖先」,難道每次都要一層一層 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)一樣。instance.parent.provides,是從父組件開始找,不包含自己。injection "xxx" not found 的警告。這大概是看到這裡最常冒出來的問題。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 共享一份」,還是「每一組各自一份」?
資料應該跟著組件一起消失。 例如一個多步驟表單精靈,使用者填到一半離開頁面,再回來時應該是空白的。放 Pinia 的話,資料會一直留著,你得記得手動 $reset();放在精靈組件裡 provide 給每個步驟,組件一卸載,資料自然就沒了。
你在寫給別人用的組件。 如果 ButtonGroup 要包成套件給其他專案用,你不能要求使用者一定要裝 Pinia。很多 UI 套件的 Form / FormItem、Tabs / TabPane 這類「父子成對」的組件,內部都是用 provide / inject 溝通的。
最後來看一個有趣的地方。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。
.value。readonly 的資料加上修改函式。Object.create 加上 in。app.provide 實作的。