本文 AI 產出,尚未審核

Vue3 組合式函式(Composables)— provide / inject 結合 composable


簡介

在 Vue 3 中,組合式 API(Composition API) 讓我們可以把「邏輯」抽離成可重用的函式(即 composable),進一步提升程式碼的可讀性與維護性。
然而,當多個元件之間需要共享同一份狀態或方法時,僅靠單純的 composable 仍不足以解決「跨層級傳遞」的問題。這時,Vue 內建的 provide / inject 機制就派上用場:父層透過 provide 暴露資料,子層(甚至更深層的孫子元件)則用 inject 取得。

把 provide / inject 與 composable 結合後,我們可以:

  • 在根層或任意父元件一次性注入 複雜的業務邏輯(例如:表單驗證、國際化、權限控制)。
  • 子元件只需要呼叫 useXxx() 便能取得完整功能,保持了 composable 的「可插拔」特性。
  • 避免層層傳遞 props,減少不必要的重新渲染與耦合度。

本篇文章將從核心概念說明開始,示範多個實務範例,最後列出常見陷阱與最佳實踐,幫助你在 Vue3 專案中熟練運用 provide / inject + composable 的寫法。


核心概念

1. 為什麼要把 provide / inject 包裝成 composable?

  • 封裝性:直接在元件裡寫 provide、inject 會讓程式碼散落在不同檔案;把它們包在一個 composable(例如 useUserProvider())內,讓所有相關邏輯集中管理。
  • 可重用:同一套提供/注入機制可以在多個父子組件樹中重複使用,只要在父層呼叫一次 composable,即可在任意子層取得。
  • 型別安全(若使用 TypeScript):在 composable 中定義介面與預設值,可讓 IDE 提供自動完成與錯誤提示。

2. 基本語法回顧

方法 位置 用途
provide(key, value) 父層 (setup) 把 value 以 key 暴露給子孫
inject(key, defaultValue?) 任意子層 (setup) 取得先前 provide 的 value,若不存在則回傳 defaultValue

註:key 可以是字串、Symbol,建議使用 Symbol 以避免命名衝突。

3. 建立 Provide Composable

以下示範一個 全域主題(Theme) 的 composable,讓任何子元件都能透過 useTheme() 取得與切換主題。

// src/composables/useThemeProvider.js
import { ref, provide, inject } from 'vue'

// 使用 Symbol 作為唯一 key
const ThemeSymbol = Symbol('Theme')

export function provideTheme() {
  const theme = ref('light') // 預設主題

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }

  // 把需要共享的資料與方法一起 provide
  provide(ThemeSymbol, {
    theme,
    toggleTheme,
  })
}

// 子層使用的 composable
export function useTheme() {
  const ctx = inject(ThemeSymbol)
  if (!ctx) {
    throw new Error('useTheme must be called after provideTheme')
  }
  return ctx
}

重點:provideTheme() 只需要在根元件或任意想要「提供」主題的父層呼叫一次;之後的子元件只要 useTheme() 就能取得 theme 與 toggleTheme。

4. 範例一:表單驗證服務

// src/composables/useFormValidator.js
import { reactive, provide, inject } from 'vue'

const FormValidatorSymbol = Symbol('FormValidator')

export function provideFormValidator(rules) {
  const state = reactive({
    errors: {},
  })

  function validate(field, value) {
    const rule = rules[field]
    if (!rule) return true // 沒有規則直接通過

    const result = rule(value)
    state.errors[field] = result ? '' : `Invalid ${field}`
    return result
  }

  provide(FormValidatorSymbol, {
    errors: state.errors,
    validate,
  })
}

export function useFormValidator() {
  const ctx = inject(FormValidatorSymbol)
  if (!ctx) {
    throw new Error('useFormValidator must be used after provideFormValidator')
  }
  return ctx
}

使用方式:

<!-- ParentForm.vue -->
<script setup>
import { provideFormValidator } from '@/composables/useFormValidator'

const rules = {
  email: v => /\S+@\S+\.\S+/.test(v),
  password: v => v.length >= 6,
}
provideFormValidator(rules)
</script>

<template>
  <slot />
</template>
<!-- ChildInput.vue -->
<script setup>
import { useFormValidator } from '@/composables/useFormValidator'
import { ref } from 'vue'

const { validate, errors } = useFormValidator()
const email = ref('')

function onBlur() {
  validate('email', email.value)
}
</script>

<template>
  <input v-model="email" @blur="onBlur" />
  <p class="error">{{ errors.email }}</p>
</template>

說明:表單規則只在父層定義一次,所有子層的輸入框都能共享同一套驗證邏輯與錯誤狀態。

5. 範例二:權限管理(Permission)

// src/composables/usePermissionProvider.js
import { ref, provide, inject, computed } from 'vue'

const PermissionSymbol = Symbol('Permission')

export function providePermission(userRoles = []) {
  const roles = ref(userRoles)

  const hasPermission = (required) => {
    if (Array.isArray(required)) {
      return required.some(r => roles.value.includes(r))
    }
    return roles.value.includes(required)
  }

  // 讓子層可以直接用 computed 判斷
  const can = (required) => computed(() => hasPermission(required))

  provide(PermissionSymbol, {
    roles,
    hasPermission,
    can,
  })
}

export function usePermission() {
  const ctx = inject(PermissionSymbol)
  if (!ctx) {
    throw new Error('usePermission must be called after providePermission')
  }
  return ctx
}

子元件使用:

<!-- AdminButton.vue -->
<script setup>
import { usePermission } from '@/composables/usePermissionProvider'

const { can } = usePermission()
const canDelete = can(['admin', 'moderator'])
</script>

<template>
  <button v-if="canDelete" @click="deleteItem">刪除</button>
</template>

重點:can() 回傳 computed,讓 Vue 能自動追蹤權限變化,UI 會即時更新。

6. 範例三:多語系(i18n)輕量實作

// src/composables/useI18nProvider.js
import { ref, provide, inject, computed } from 'vue'

const I18nSymbol = Symbol('I18n')

export function provideI18n(messages, defaultLocale = 'en') {
  const locale = ref(defaultLocale)

  function t(key) {
    return messages[locale.value][key] ?? key
  }

  const translate = (key) => computed(() => t(key))

  provide(I18nSymbol, {
    locale,
    t,
    translate,
    setLocale: (l) => (locale.value = l),
  })
}

export function useI18n() {
  const ctx = inject(I18nSymbol)
  if (!ctx) {
    throw new Error('useI18n must be used after provideI18n')
  }
  return ctx
}

在子元件中:

<!-- Greeting.vue -->
<script setup>
import { useI18n } from '@/composables/useI18nProvider'

const { t, locale, setLocale } = useI18n()
</script>

<template>
  <p>{{ t('welcome') }}</p>
  <select v-model="locale">
    <option value="en">English</option>
    <option value="zh">中文</option>
  </select>
</template>

說明:切換 locale 後,所有使用 t() 或 translate() 的文字會即時重新計算,達到「全局多語」的效果。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記在父層呼叫 provide composable 子層 inject 會得到 undefined,導致程式錯誤。 在 composable 中加入 防呆檢查(如上例的 if (!ctx) throw new Error(...)),並在文件說明「必須先呼叫 provideXxx()」。
使用字串作為 key,產生衝突 多個不同的 provide 可能意外使用相同字串,導致資料被覆寫。 建議使用 Symbol 作為唯一的 key。
provide 的值是原始資料,未使用 ref/reactive 子層取得後不會自動響應變化。 把需要響應的狀態包在 ref、reactive 或 computed 中再提供。
過度依賴 provide / inject,導致耦合 當層級結構改變時,子層仍依賴舊的提供者,維護成本升高。 保持 composable 的獨立性:在同一層級或同一功能模組內使用,若跨模組使用,可考慮使用全局 store(Pinia)或插件。
Inject 的預設值不是同型別 若預設值與真正提供的類型不一致,會在 TypeScript 中產生錯誤。 為 inject 指定正確的型別或使用 as 斷言,或在提供者缺失時拋出錯誤。

最佳實踐

  1. 封裝成 composable:provideXxx() 與 useXxx() 成對出現,讓 API 明確。
  2. 使用 Symbol 作為 key,避免命名衝突。
  3. 把所有可變狀態包在 ref/reactive,確保子層可以自動追蹤。
  4. 在提供者內部加入錯誤檢查,讓開發者在開發階段即發現問題。
  5. 盡量保持單一職責:一個 composable 只負責一件事(如 Theme、Permission),避免「巨型」provide。

實際應用場景

場景 為什麼適合使用 provide / inject + composable
大型表單(多個子欄位、跨頁) 透過 provideFormValidator 集中驗證規則與錯誤狀態,子欄位不必傳遞大量 props。
主題切換(全站暗色/亮色) provideTheme 只在根元件呼叫一次,所有子元件都能即時取得 theme,且不需要每層都傳遞 prop。
權限控制(功能按鈕、路由守衛) providePermission 把角色資訊注入全局,子元件只要 usePermission 判斷即可,減少重複的 store 查詢。
多語系(i18n) provideI18n 把語系字典與切換函式提供給整個應用,子元件只需要 t('key'),不必再自行 import 文字檔。
插件式功能(如拖曳、動畫) 需要在多個元件之間共享同一個控制器(例如 dragController),可透過 composable 包裝後提供,保持插件的可插拔性。

總結

  • provide / inject 為 Vue 3 的跨層級資料傳遞提供了原生支援,結合 composable 後,能把「提供」與「使用」的邏輯完整封裝,提升程式碼的可讀性與可維護性。
  • 使用 Symbol 作為鑑別鍵、把狀態包在 ref / reactive、以及在 composable 中加入 防呆檢查,是避免常見陷阱的關鍵。
  • 透過本篇示範的 主題、表單驗證、權限、i18n 四個實務範例,你可以快速在自己的專案中導入「provide + composable」的模式,減少 props 傳遞、提升 UI 的即時反應能力。

只要遵循 封裝、單一職責、型別安全 的原則,provide / inject + composable 將成為你在 Vue 3 中打造大型、可擴充應用的利器。祝開發順利,玩得開心!