Vue Provide / Inject
provide 和 inject 是 Vue 的依賴注入(Dependency Injection)API,用於跨層級的元件通訊。它可以讓祖先元件向所有後代元件提供資料,而不需要透過層層傳遞 props。
基本概念
Props 鑽取問題(Props Drilling)
假設有這樣的元件結構:
Root
└── Parent
└── Child
└── DeepChild ← 需要用到 Root 的資料
如果使用 props 傳遞,每一層都需要接收並傳遞:
<!-- Root -->
<Parent :user="user" />
<!-- Parent -->
<Child :user="user" />
<!-- Child -->
<DeepChild :user="user" />
<!-- DeepChild -->
{{ user.name }}
這稱為「Props 鑽取」(Props Drilling),當層級很深時會很麻煩。
使用 Provide / Inject
provide / inject 可以直接從祖先傳到後代:
Root (provide)
└── Parent
└── Child
└── DeepChild (inject)
基本用法
Provide(提供)
在祖先元件中使用 provide() 提供資料:
<!-- 祖先元件 -->
<script setup>
import { provide, ref } from 'vue'
const message = ref('Hello from ancestor')
const user = ref({ name: 'John', age: 30 })
// provide(key, value)
provide('message', message)
provide('user', user)
</script>
Inject(注入)
在後代元件中使用 inject() 注入資料:
<!-- 後代元件(任何層級) -->
<script setup>
import { inject } from 'vue'
// inject(key, defaultValue?)
const message = inject('message')
const user = inject('user')
// 提供預設值
const theme = inject('theme', 'light')
</script>
<template>
<p>{{ message }}</p>
<p>{{ user.name }}</p>
</template>
響應式
預設情況下,如果 provide 的是 ref 或 reactive,則 inject 接收到的也是響應式的:
<!-- 祖先元件 -->
<script setup>
import { provide, ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
provide('count', count)
provide('increment', increment)
</script>
<template>
<button @click="increment">{{ count }}</button>
<Child />
</template>
<!-- 後代元件 -->
<script setup>
import { inject } from 'vue'
const count = inject('count')
const increment = inject('increment')
</script>
<template>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
</template>
只讀(readonly)
為了避免後代元件意外修改資料,可以使用 readonly() 包裝:
<script setup>
import { provide, ref, readonly } from 'vue'
const count = ref(0)
// 提供只讀版本
provide('count', readonly(count))
// 如果需要修改,提供方法
provide('increment', () => {
count.value++
})
</script>
使用 Symbol 作為 Key
為了避免命名衝突,建議使用 Symbol 作為 provide/inject 的 key:
// keys.js
export const userKey = Symbol('user')
export const themeKey = Symbol('theme')
<!-- 祖先元件 -->
<script setup>
import { provide, ref } from 'vue'
import { userKey, themeKey } from './keys'
const user = ref({ name: 'John' })
const theme = ref('dark')
provide(userKey, user)
provide(themeKey, theme)
</script>
<!-- 後代元件 -->
<script setup>
import { inject } from 'vue'
import { userKey, themeKey } from './keys'
const user = inject(userKey)
const theme = inject(themeKey)
</script>
搭配 TypeScript
使用 TypeScript 時,可以透過 InjectionKey 來確保型別安全:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
interface User {
name: string
age: number
}
export const userKey: InjectionKey<Ref<User>> = Symbol('user')
export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
<script setup lang="ts">
import { inject } from 'vue'
import { userKey } from './keys'
// user 會被推斷為 Ref<User> | undefined
const user = inject(userKey)
// 提供預設值後,不會是 undefined
const theme = inject(themeKey, ref('light'))
</script>
應用程式層級的 Provide
可以在建立應用程式時提供全域資料:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 應用程式層級的 provide
app.provide('appName', 'My App')
app.provide('version', '1.0.0')
app.mount('#app')
這樣所有元件都可以 inject 這些資料。
實際範例
主題切換
<!-- App.vue(提供者) -->
<script setup>
import { provide, ref, readonly } from 'vue'
const theme = ref('light')
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// 提供主題狀態和切換方法
provide('theme', readonly(theme))
provide('toggleTheme', toggleTheme)
</script>
<template>
<div :class="['app', theme]">
<Header />
<Main />
<Footer />
</div>
</template>
<style>
.app.light {
background: white;
color: black;
}
.app.dark {
background: #1a1a1a;
color: white;
}
</style>
<!-- ThemeToggle.vue(任意後代元件) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>
<template>
<button @click="toggleTheme">
{{ theme === 'light' ? '🌙' : '☀️' }} 切換主題
</button>
</template>
表單上下文
<!-- Form.vue -->
<script setup>
import { provide, reactive, readonly } from 'vue'
const props = defineProps({
disabled: Boolean
})
const formState = reactive({
isSubmitting: false,
errors: {}
})
function setError(field, message) {
formState.errors[field] = message
}
function clearError(field) {
delete formState.errors[field]
}
async function submit(handler) {
formState.isSubmitting = true
formState.errors = {}
try {
await handler()
} catch (e) {
console.error(e)
} finally {
formState.isSubmitting = false
}
}
provide('form', {
disabled: readonly(toRef(props, 'disabled')),
state: readonly(formState),
setError,
clearError,
submit
})
</script>
<template>
<form @submit.prevent>
<slot></slot>
</form>
</template>
<!-- FormInput.vue -->
<script setup>
import { inject, computed } from 'vue'
const props = defineProps({
name: String,
label: String,
modelValue: String
})
const emit = defineEmits(['update:modelValue'])
const form = inject('form')
const error = computed(() => form?.state.errors[props.name])
const isDisabled = computed(() => form?.disabled || form?.state.isSubmitting)
</script>
<template>
<div class="form-group">
<label :for="name">{{ label }}</label>
<input
:id="name"
:value="modelValue"
:disabled="isDisabled"
@input="emit('update:modelValue', $event.target.value)"
>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
使用:
<template>
<Form :disabled="false">
<FormInput v-model="form.email" name="email" label="Email" />
<FormInput v-model="form.password" name="password" label="密碼" />
<FormButton type="submit">送出</FormButton>
</Form>
</template>
多語系(i18n)
<!-- App.vue -->
<script setup>
import { provide, ref, readonly } from 'vue'
const locale = ref('zh-TW')
const messages = {
'zh-TW': {
hello: '你好',
welcome: '歡迎'
},
'en': {
hello: 'Hello',
welcome: 'Welcome'
}
}
function t(key) {
return messages[locale.value]?.[key] || key
}
function setLocale(newLocale) {
locale.value = newLocale
}
provide('i18n', {
locale: readonly(locale),
t,
setLocale
})
</script>
<!-- 任意後代元件 -->
<script setup>
import { inject } from 'vue'
const { t, locale, setLocale } = inject('i18n')
</script>
<template>
<p>{{ t('hello') }}!</p>
<select :value="locale" @change="setLocale($event.target.value)">
<option value="zh-TW">繁體中文</option>
<option value="en">English</option>
</select>
</template>
Toast 通知
<!-- ToastProvider.vue -->
<script setup>
import { provide, ref } from 'vue'
const toasts = ref([])
let id = 0
function addToast(message, type = 'info', duration = 3000) {
const toast = { id: id++, message, type }
toasts.value.push(toast)
if (duration > 0) {
setTimeout(() => {
removeToast(toast.id)
}, duration)
}
}
function removeToast(id) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
provide('toast', {
success: (msg) => addToast(msg, 'success'),
error: (msg) => addToast(msg, 'error'),
warning: (msg) => addToast(msg, 'warning'),
info: (msg) => addToast(msg, 'info')
})
</script>
<template>
<slot></slot>
<!-- Toast 容器 -->
<Teleport to="body">
<div class="toast-container">
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast', toast.type]"
@click="removeToast(toast.id)"
>
{{ toast.message }}
</div>
</div>
</Teleport>
</template>
<style scoped>
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
}
.toast {
padding: 12px 20px;
margin-bottom: 10px;
border-radius: 4px;
cursor: pointer;
animation: slideIn 0.3s ease;
}
.toast.success { background: #d4edda; color: #155724; }
.toast.error { background: #f8d7da; color: #721c24; }
.toast.warning { background: #fff3cd; color: #856404; }
.toast.info { background: #d1ecf1; color: #0c5460; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
使用:
<!-- App.vue -->
<template>
<ToastProvider>
<RouterView />
</ToastProvider>
</template>
<!-- 任意後代元件 -->
<script setup>
import { inject } from 'vue'
const toast = inject('toast')
function handleSave() {
// ... 儲存邏輯
toast.success('儲存成功!')
}
function handleError() {
toast.error('發生錯誤,請稍後再試')
}
</script>
Provide / Inject vs Props vs Pinia
| 特性 | Props | Provide/Inject | Pinia |
|---|---|---|---|
| 適用範圍 | 父子元件 | 祖先到後代 | 全域 |
| 響應式 | ✅ | ✅ | ✅ |
| 資料流向 | 單向(父→子) | 單向(祖先→後代) | 任意 |
| DevTools 支援 | ✅ | 有限 | ✅ |
| 適用場景 | 直接父子通訊 | 跨層級、主題、設定 | 全域狀態管理 |
使用建議
- Props:父子直接傳遞資料
- Provide/Inject:跨多層傳遞、元件庫內部狀態、主題/配置
- Pinia:全域狀態、需要 DevTools 偵錯、複雜狀態邏輯