Vue Provide / Inject

provideinject 是 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

特性PropsProvide/InjectPinia
適用範圍父子元件祖先到後代全域
響應式
資料流向單向(父→子)單向(祖先→後代)任意
DevTools 支援有限
適用場景直接父子通訊跨層級、主題、設定全域狀態管理

使用建議

  • Props:父子直接傳遞資料
  • Provide/Inject:跨多層傳遞、元件庫內部狀態、主題/配置
  • Pinia:全域狀態、需要 DevTools 偵錯、複雜狀態邏輯