Vue3 依赖注入深入:provide/inject 的架构设计与最佳实践

4305 字
22 分钟
Vue3 依赖注入深入:provide/inject 的架构设计与最佳实践

Vue3 依赖注入深入:provide/inject 的架构设计与最佳实践#

在 Vue 组件树中,数据传递是一个核心问题。props 是最基础的方式,但当组件层级变深时,中间组件被迫传递它们并不关心的 props——这就是臭名昭著的 Props Drilling(属性穿透) 问题。Vue3 的 provide/inject 提供了一种优雅的跨层级通信机制,但很多开发者只停留在基础用法层面。

本文将深入 provide/inject 的实现原理,探讨 Symbol key、类型安全、响应式注入、以及在大型项目中的架构设计模式。

一、Props Drilling 的困境#

1.1 一个典型的场景#

假设我们有一个主题系统,主题配置需要从 App 根组件传递到深层嵌套的 Button 组件:

App (theme)
└── Layout
└── Page
└── Section
└── Card
└── CardBody
└── Button (需要 theme)

用 props 传递:

App.vue
<template>
<Layout :theme="theme" />
</template>
<!-- Layout.vue -->
<template>
<Page :theme="theme" />
</template>
<script setup>
defineProps(['theme']) // Layout 自己不用 theme
</script>
<!-- Page.vue -->
<template>
<Section :theme="theme" />
</template>
<script setup>
defineProps(['theme']) // Page 也不用 theme
</script>
<!-- ... 每一层都要透传 ... -->
<!-- Button.vue -->
<template>
<button :class="theme.buttonClass">{{ label }}</button>
</template>
<script setup>
defineProps(['theme', 'label']) // 终于用到了 theme
</script>

这种方式的问题:

  1. 中间组件污染:Layout、Page、Section 等组件被迫声明和传递 theme prop
  2. 维护困难:新增一个共享状态需要修改所有中间组件
  3. 耦合度高:重构组件层级时容易遗漏传递
  4. 类型冗余:每个中间组件都要定义 prop 类型

1.2 provide/inject 的解决方案#

App.vue
<script setup>
import { provide, reactive } from 'vue'
const theme = reactive({
primary: '#1976D2',
secondary: '#424242',
buttonClass: 'btn-primary'
})
provide('theme', theme)
</script>
<!-- Button.vue —— 直接注入,无需中间组件传递 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
</script>
<template>
<button :class="theme.buttonClass">
<slot />
</button>
</template>

中间的 Layout、Page、Section、Card 等组件完全不需要感知 theme 的存在。

二、provide/inject 的实现原理#

2.1 原型链机制#

Vue3 的 provide/inject 底层使用了 JavaScript 原型链

// packages/runtime-core/src/apiInject.ts(简化)
export function provide<T>(key: InjectionKey<T> | string, value: T) {
let provides = currentInstance!.provides
// 如果当前组件的 provides 和父组件的相同(初始状态)
// 则创建一个以父组件 provides 为原型的新对象
const parentProvides = currentInstance!.parent?.provides
if (parentProvides === provides) {
provides = currentInstance!.provides = Object.create(parentProvides)
}
provides[key as string] = value
}
export function inject<T>(
key: InjectionKey<T> | string,
defaultValue?: T,
treatDefaultAsFactory = false
): T | undefined {
const instance = currentInstance
if (instance) {
// 从当前实例的 provides 链上查找
const provides = instance.parent?.provides
if (provides && (key as string) in provides) {
return provides[key as string]
} else if (arguments.length > 1) {
// 有默认值
return treatDefaultAsFactory && typeof defaultValue === 'function'
? defaultValue()
: defaultValue
} else {
// 没有找到且没有默认值
console.warn(`injection "${String(key)}" not found.`)
}
}
}

2.2 原型链的妙用#

// 组件层级:App -> Parent -> Child
// App 的 provides
App.provides = { theme: 'dark', locale: 'zh-CN' }
// Parent 调用 provide('appName', 'MyApp')
// 创建新对象,以 App.provides 为原型
Parent.provides = Object.create(App.provides)
Parent.provides.appName = 'MyApp'
// Parent.provides: { appName: 'MyApp' }
// Parent.provides.__proto__: { theme: 'dark', locale: 'zh-CN' }
// Child 调用 inject('theme')
// 在 Parent.provides 中没找到 theme
// 沿着原型链找到 App.provides 中的 theme
// 返回 'dark'
// Child 调用 inject('appName')
// 在 Parent.provides 中找到了
// 返回 'MyApp'

这个设计非常巧妙:

  • 就近原则:子组件可以覆盖祖先的 provide 值
  • 高效查找:利用 JS 引擎优化的原型链查找
  • 内存友好:没有 provide 的组件不会创建新对象

2.3 覆盖机制#

Grandparent.vue
<script setup>
provide('color', 'red')
</script>
<!-- Parent.vue -->
<script setup>
// 覆盖祖先的 provide
provide('color', 'blue')
</script>
<!-- Child.vue -->
<script setup>
const color = inject('color') // 'blue'(就近原则)
</script>

三、Symbol Key 与类型安全#

3.1 字符串 Key 的问题#

使用字符串作为 key 存在两个问题:

  1. 命名冲突:不同的库或模块可能使用相同的字符串 key
  2. 类型不安全:inject 返回 unknown,需要手动断言
// ❌ 字符串 key —— 不安全
provide('user', userObj)
// inject 返回 unknown
const user = inject('user') // unknown 类型
// 需要手动断言
const user = inject('user') as User // 不安全

3.2 InjectionKey 类型#

Vue3 提供了 InjectionKey<T> 类型,配合 Symbol 使用:

import { type InjectionKey } from 'vue'
// 定义类型安全的注入 Key
export const UserKey: InjectionKey<User> = Symbol('user')
export const ThemeKey: InjectionKey<ThemeConfig> = Symbol('theme')
export const ApiClientKey: InjectionKey<ApiClient> = Symbol('apiClient')
import { provide, inject } from 'vue'
import { UserKey } from './injection-keys'
// provide 时有类型检查
provide(UserKey, { id: 1, name: 'Alice' }) // ✅
provide(UserKey, 'not a user') // ❌ 类型错误
// inject 时自动推导类型
const user = inject(UserKey) // User | undefined
const user = inject(UserKey, { id: 0, name: 'Guest' }) // User(有默认值时不会是 undefined)

3.3 统一管理 Injection Keys#

在大型项目中,建议统一管理所有的 Injection Key:

injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
// ===== 类型定义 =====
export interface ThemeConfig {
mode: 'light' | 'dark'
primary: string
secondary: string
fontFamily: string
}
export interface AuthContext {
user: Ref<User | null>
isAuthenticated: Ref<boolean>
login: (credentials: Credentials) => Promise<void>
logout: () => Promise<void>
token: Ref<string | null>
}
export interface I18nContext {
locale: Ref<string>
t: (key: string, params?: Record<string, any>) => string
setLocale: (locale: string) => Promise<void>
availableLocales: string[]
}
export interface ToastContext {
success: (message: string) => void
error: (message: string) => void
warning: (message: string) => void
info: (message: string) => void
}
// ===== Injection Keys =====
export const ThemeKey: InjectionKey<ThemeConfig> = Symbol('theme')
export const AuthKey: InjectionKey<AuthContext> = Symbol('auth')
export const I18nKey: InjectionKey<I18nContext> = Symbol('i18n')
export const ToastKey: InjectionKey<ToastContext> = Symbol('toast')
export const ApiKey: InjectionKey<AxiosInstance> = Symbol('api')

四、响应式注入#

4.1 provide 响应式数据#

provide 注入的值不会自动变成响应式的。你需要显式地提供响应式数据:

<script setup>
import { ref, reactive, provide, readonly } from 'vue'
// ✅ 提供 ref
const count = ref(0)
provide('count', count) // 子组件可以读取到最新值
// ✅ 提供 reactive
const state = reactive({ name: 'Alice', age: 25 })
provide('state', state) // 子组件可以读取到最新值
// ⚠️ 提供普通值 —— 不具备响应性
provide('staticValue', 42) // 子组件始终获取到 42
// ✅ 推荐:提供 readonly 版本
provide('readonlyCount', readonly(count))
provide('readonlyState', readonly(state))
</script>

4.2 单向数据流原则#

为了维护数据流的清晰性,注入的数据应该由提供者修改,而非消费者

// ❌ 不推荐:消费者直接修改注入的数据
const count = inject(CountKey)!
count.value++ // 破坏了数据流向
// ✅ 推荐:提供者提供修改方法
// Provider
const count = ref(0)
function increment() {
count.value++
}
provide(CountKey, {
count: readonly(count),
increment
})
// Consumer
const { count, increment } = inject(CountKey)!
increment() // 通过提供者的方法修改

4.3 完整的响应式注入模式#

composables/useTheme.ts
import { ref, reactive, readonly, provide, inject, computed, watch } from 'vue'
import type { InjectionKey } from 'vue'
interface Theme {
mode: 'light' | 'dark'
colors: {
primary: string
secondary: string
background: string
text: string
border: string
}
}
interface ThemeContext {
theme: Readonly<Theme>
isDark: Readonly<Ref<boolean>>
toggleTheme: () => void
setTheme: (mode: 'light' | 'dark') => void
setPrimary: (color: string) => void
}
const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
const lightColors = {
primary: '#1976D2',
secondary: '#424242',
background: '#ffffff',
text: '#333333',
border: '#e0e0e0'
}
const darkColors = {
primary: '#90CAF9',
secondary: '#B0BEC5',
background: '#121212',
text: '#E0E0E0',
border: '#333333'
}
export function provideTheme(initialMode: 'light' | 'dark' = 'light') {
const theme = reactive<Theme>({
mode: initialMode,
colors: initialMode === 'light' ? { ...lightColors } : { ...darkColors }
})
const isDark = computed(() => theme.mode === 'dark')
function setTheme(mode: 'light' | 'dark') {
theme.mode = mode
Object.assign(theme.colors, mode === 'light' ? lightColors : darkColors)
}
function toggleTheme() {
setTheme(theme.mode === 'light' ? 'dark' : 'light')
}
function setPrimary(color: string) {
theme.colors.primary = color
}
// 同步到 CSS 变量
watch(() => theme.colors, (colors) => {
const root = document.documentElement
Object.entries(colors).forEach(([key, value]) => {
root.style.setProperty(`--color-${key}`, value)
})
}, { immediate: true, deep: true })
// 同步 dark class
watch(isDark, (dark) => {
document.documentElement.classList.toggle('dark', dark)
}, { immediate: true })
const context: ThemeContext = {
theme: readonly(theme) as Readonly<Theme>,
isDark: readonly(isDark),
toggleTheme,
setTheme,
setPrimary
}
provide(ThemeKey, context)
return context
}
export function useTheme(): ThemeContext {
const context = inject(ThemeKey)
if (!context) {
throw new Error(
'useTheme() is called without provideTheme(). ' +
'Did you forget to call provideTheme() in a parent component?'
)
}
return context
}

使用:

App.vue
<script setup>
import { provideTheme } from '@/composables/useTheme'
const { isDark, toggleTheme } = provideTheme('light')
</script>
<template>
<div :class="{ dark: isDark }">
<button @click="toggleTheme">
{{ isDark ? '🌙' : '☀️' }} 切换主题
</button>
<router-view />
</div>
</template>
<!-- 任何子组件 -->
<script setup>
import { useTheme } from '@/composables/useTheme'
const { theme, isDark } = useTheme()
</script>
<template>
<div :style="{ color: theme.colors.text, backgroundColor: theme.colors.background }">
当前主题: {{ isDark ? '暗色' : '亮色' }}
</div>
</template>

五、高级架构模式#

5.1 插件式注入#

利用 Vue 的 app.provide 在应用级别注入:

plugins/api.ts
import type { App } from 'vue'
import axios from 'axios'
import { ApiKey } from '@/injection-keys'
export function createApiPlugin(baseURL: string) {
return {
install(app: App) {
const apiClient = axios.create({
baseURL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 处理认证过期
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// 应用级别的 provide
app.provide(ApiKey, apiClient)
}
}
}
main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createApiPlugin } from './plugins/api'
const app = createApp(App)
app.use(createApiPlugin('https://api.example.com'))
app.mount('#app')
<!-- 任何组件中 -->
<script setup>
import { inject } from 'vue'
import { ApiKey } from '@/injection-keys'
const api = inject(ApiKey)!
const users = ref([])
onMounted(async () => {
const { data } = await api.get('/users')
users.value = data
})
</script>

5.2 服务定位器模式#

services/ServiceContainer.ts
import { type InjectionKey, provide, inject } from 'vue'
// 通用服务容器
const ServiceContainerKey: InjectionKey<Map<symbol, any>> = Symbol('ServiceContainer')
export function provideServiceContainer() {
const container = new Map<symbol, any>()
provide(ServiceContainerKey, container)
return container
}
export function registerService<T>(key: InjectionKey<T>, factory: () => T) {
const container = inject(ServiceContainerKey)
if (!container) {
throw new Error('ServiceContainer not provided')
}
// 懒加载:只在第一次获取时创建
let instance: T | null = null
const proxy = new Proxy({} as any, {
get(_, prop) {
if (!instance) instance = factory()
return (instance as any)[prop]
}
})
container.set(key as unknown as symbol, { factory, instance: null })
provide(key, proxy)
}
export function getService<T>(key: InjectionKey<T>): T {
const service = inject(key)
if (!service) {
throw new Error(`Service not registered: ${String(key)}`)
}
return service
}

5.3 组合式 Store 模式#

provide/inject 可以用来实现一个轻量级的状态管理方案,无需引入 Pinia 或 Vuex:

stores/useCartStore.ts
import { ref, computed, readonly, provide, inject, type InjectionKey } from 'vue'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
interface CartStore {
items: Readonly<Ref<CartItem[]>>
totalItems: Readonly<Ref<number>>
totalPrice: Readonly<Ref<number>>
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
}
const CartStoreKey: InjectionKey<CartStore> = Symbol('CartStore')
export function provideCartStore(): CartStore {
const items = ref<CartItem[]>([])
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(newItem: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(item => item.id === newItem.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...newItem, quantity: 1 })
}
}
function removeItem(id: string) {
const index = items.value.findIndex(item => item.id === id)
if (index !== -1) {
items.value.splice(index, 1)
}
}
function updateQuantity(id: string, quantity: number) {
const item = items.value.find(item => item.id === id)
if (item) {
if (quantity <= 0) {
removeItem(id)
} else {
item.quantity = quantity
}
}
}
function clearCart() {
items.value = []
}
const store: CartStore = {
items: readonly(items),
totalItems,
totalPrice,
addItem,
removeItem,
updateQuantity,
clearCart
}
provide(CartStoreKey, store)
return store
}
export function useCartStore(): CartStore {
const store = inject(CartStoreKey)
if (!store) {
throw new Error('useCartStore() requires provideCartStore() in a parent component')
}
return store
}

使用:

App.vue
<script setup>
import { provideCartStore } from '@/stores/useCartStore'
provideCartStore()
</script>
<!-- ProductCard.vue -->
<script setup>
import { useCartStore } from '@/stores/useCartStore'
const { addItem } = useCartStore()
const props = defineProps<{
product: { id: string; name: string; price: number }
}>()
function handleAddToCart() {
addItem({
id: props.product.id,
name: props.product.name,
price: props.product.price
})
}
</script>
<template>
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>¥{{ product.price }}</p>
<button @click="handleAddToCart">加入购物车</button>
</div>
</template>
<!-- CartWidget.vue -->
<script setup>
import { useCartStore } from '@/stores/useCartStore'
const { items, totalItems, totalPrice, removeItem, updateQuantity } = useCartStore()
</script>
<template>
<div class="cart-widget">
<h3>购物车 ({{ totalItems }})</h3>
<div v-for="item in items" :key="item.id" class="cart-item">
<span>{{ item.name }}</span>
<input
type="number"
:value="item.quantity"
@input="updateQuantity(item.id, +$event.target.value)"
min="0"
/>
<span>¥{{ item.price * item.quantity }}</span>
<button @click="removeItem(item.id)">✕</button>
</div>
<div class="cart-total">
总计: ¥{{ totalPrice.toFixed(2) }}
</div>
</div>
</template>

六、provide/inject vs 其他方案对比#

6.1 与 Props Drilling 对比#

特性Props Drillingprovide/inject
数据流向显式、逐层隐式、跨层
中间组件需要参与传递完全不感知
可追溯性容易追踪需要约定
重构难度高(需改每层)
类型安全天然支持需要 InjectionKey
适用场景1-2 层3+ 层

6.2 与全局状态(Pinia)对比#

// Pinia Store —— 全局单例
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
return { user }
})
// provide/inject Store —— 组件树作用域
export function provideUserStore() {
const user = ref<User | null>(null)
provide(UserStoreKey, { user: readonly(user) })
}
特性Piniaprovide/inject
作用域全局组件树
持久化插件支持手动实现
DevTools完整支持有限
SSR内置支持需要注意
复杂度适合大型项目适合中小型场景
测试需要 mock store直接 provide mock

6.3 何时选择 provide/inject#

适合用 provide/inject 的场景:

  • 主题/国际化等”环境配置”的传递
  • 表单组件库中表单与表单项的通信
  • 布局组件与子组件的协调
  • 有限范围内的状态共享
  • 组件库内部的跨层通信

适合用 Pinia 的场景:

  • 全局用户认证状态
  • 购物车等需要跨页面持久化的状态
  • 需要 DevTools 调试的复杂状态
  • 多个不相关的组件树需要共享的状态

七、常见陷阱与注意事项#

7.1 避免循环依赖#

// ❌ 循环依赖
// ComponentA provide → inject from ComponentB
// ComponentB provide → inject from ComponentA
// 这会导致其中一个 inject 得到 undefined

7.2 注意 inject 的调用时机#

// ❌ 在 setup 外调用
function someHelper() {
const theme = inject(ThemeKey) // 无法工作!
}
// ✅ 在 setup 中调用,传递给 helper
function someHelper(theme: ThemeConfig) {
// 使用 theme
}
// setup 中
const theme = inject(ThemeKey)!
someHelper(theme)

inject 必须在 setup()<script setup> 的同步执行阶段调用,因为它依赖 currentInstance 来确定组件上下文。

7.3 提供有意义的默认值#

// ✅ 提供安全的默认值
const theme = inject(ThemeKey, {
mode: 'light',
colors: { primary: '#333', background: '#fff', text: '#000' }
} as ThemeConfig)
// ✅ 使用工厂函数避免共享引用
const state = inject(StateKey, () => ({
items: [],
loading: false
}), true) // 第三个参数 true 表示默认值是工厂函数

7.4 SSR 注意事项#

// 在 SSR 环境中,每个请求应该有独立的 provide 上下文
// ❌ 使用模块级别的单例
const globalState = reactive({ user: null })
// ✅ 在 createApp 时创建新实例
export function createApp() {
const app = createSSRApp(App)
const state = reactive({ user: null })
app.provide(StateKey, state)
return { app, state }
}

八、实战:表单组件库中的 provide/inject#

表单组件是 provide/inject 的经典应用场景。让我们实现一个类型安全的表单系统:

components/form/types.ts
import type { InjectionKey, Ref } from 'vue'
interface FormContext {
model: Record<string, any>
rules: Record<string, ValidationRule[]>
addField: (field: FormFieldContext) => void
removeField: (field: FormFieldContext) => void
validate: () => Promise<boolean>
resetFields: () => void
}
interface FormFieldContext {
prop: string
validate: () => Promise<string | null>
resetField: () => void
clearValidate: () => void
}
interface ValidationRule {
required?: boolean
message?: string
min?: number
max?: number
pattern?: RegExp
validator?: (value: any) => boolean | string | Promise<boolean | string>
}
export const FormContextKey: InjectionKey<FormContext> = Symbol('FormContext')
export const FormFieldContextKey: InjectionKey<FormFieldContext> = Symbol('FormFieldContext')
Form.vue
<script setup lang="ts">
import { provide, reactive, toRaw } from 'vue'
import { FormContextKey, type FormFieldContext, type ValidationRule } from './types'
const props = defineProps<{
model: Record<string, any>
rules?: Record<string, ValidationRule[]>
}>()
const emit = defineEmits<{
submit: [values: Record<string, any>]
'validation-error': [errors: Record<string, string>]
}>()
const fields: FormFieldContext[] = []
function addField(field: FormFieldContext) {
fields.push(field)
}
function removeField(field: FormFieldContext) {
const index = fields.indexOf(field)
if (index !== -1) fields.splice(index, 1)
}
async function validate(): Promise<boolean> {
const results = await Promise.all(
fields.map(field => field.validate())
)
const errors: Record<string, string> = {}
results.forEach((error, index) => {
if (error) {
errors[fields[index].prop] = error
}
})
if (Object.keys(errors).length > 0) {
emit('validation-error', errors)
return false
}
return true
}
function resetFields() {
fields.forEach(field => field.resetField())
}
// 向子组件提供表单上下文
provide(FormContextKey, {
model: props.model,
rules: props.rules || {},
addField,
removeField,
validate,
resetFields
})
async function handleSubmit() {
const valid = await validate()
if (valid) {
emit('submit', toRaw(props.model))
}
}
defineExpose({ validate, resetFields })
</script>
<template>
<form @submit.prevent="handleSubmit">
<slot />
</form>
</template>
FormItem.vue
<script setup lang="ts">
import { inject, ref, onMounted, onUnmounted, provide, computed, watch } from 'vue'
import { FormContextKey, FormFieldContextKey } from './types'
const props = defineProps<{
prop: string
label?: string
}>()
const formContext = inject(FormContextKey)
if (!formContext) {
throw new Error('FormItem must be used inside Form component')
}
const errorMessage = ref<string | null>(null)
const isValidating = ref(false)
let initialValue: any
onMounted(() => {
initialValue = structuredClone(formContext.model[props.prop])
})
async function validate(): Promise<string | null> {
const rules = formContext.rules[props.prop]
if (!rules || rules.length === 0) return null
isValidating.value = true
const value = formContext.model[props.prop]
for (const rule of rules) {
if (rule.required && (value === '' || value === null || value === undefined)) {
errorMessage.value = rule.message || `${props.label || props.prop} 是必填项`
isValidating.value = false
return errorMessage.value
}
if (rule.min !== undefined && typeof value === 'string' && value.length < rule.min) {
errorMessage.value = rule.message || `最少 ${rule.min} 个字符`
isValidating.value = false
return errorMessage.value
}
if (rule.pattern && !rule.pattern.test(value)) {
errorMessage.value = rule.message || '格式不正确'
isValidating.value = false
return errorMessage.value
}
if (rule.validator) {
const result = await rule.validator(value)
if (result !== true) {
errorMessage.value = typeof result === 'string' ? result : (rule.message || '验证失败')
isValidating.value = false
return errorMessage.value
}
}
}
errorMessage.value = null
isValidating.value = false
return null
}
function resetField() {
formContext.model[props.prop] = structuredClone(initialValue)
errorMessage.value = null
}
function clearValidate() {
errorMessage.value = null
}
const fieldContext = { prop: props.prop, validate, resetField, clearValidate }
// 注册到 Form
formContext.addField(fieldContext)
onUnmounted(() => formContext.removeField(fieldContext))
// 也可以提供给更深层的组件
provide(FormFieldContextKey, fieldContext)
</script>
<template>
<div class="form-item" :class="{ 'has-error': errorMessage }">
<label v-if="label" class="form-item-label">{{ label }}</label>
<div class="form-item-content">
<slot />
</div>
<transition name="fade">
<p v-if="errorMessage" class="form-item-error">{{ errorMessage }}</p>
</transition>
</div>
</template>

使用:

<script setup>
import { reactive } from 'vue'
import Form from './Form.vue'
import FormItem from './FormItem.vue'
const formRef = ref()
const model = reactive({
username: '',
email: '',
password: ''
})
const rules = {
username: [
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少 3 个字符' }
],
email: [
{ required: true, message: '请输入邮箱' },
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' }
],
password: [
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少 6 位' }
]
}
function handleSubmit(values) {
console.log('提交成功:', values)
}
</script>
<template>
<Form ref="formRef" :model="model" :rules="rules" @submit="handleSubmit">
<FormItem prop="username" label="用户名">
<input v-model="model.username" />
</FormItem>
<FormItem prop="email" label="邮箱">
<input v-model="model.email" type="email" />
</FormItem>
<FormItem prop="password" label="密码">
<input v-model="model.password" type="password" />
</FormItem>
<button type="submit">提交</button>
<button type="button" @click="formRef?.resetFields()">重置</button>
</Form>
</template>

九、总结#

Vue3 的 provide/inject 是一个被低估的强大工具。正确使用它可以:

  1. 消除 Props Drilling:跨层级传递数据,中间组件无需感知
  2. 实现依赖倒置:消费者不关心数据来源,只关心接口
  3. 构建组件库:表单、布局等复合组件的内部通信
  4. 轻量级状态管理:在不需要 Pinia 的场景下共享状态
  5. 插件系统:通过 app.provide 实现应用级服务注入

关键实践要点:

  • 始终使用 Symbol + InjectionKey 保证类型安全和避免命名冲突
  • 提供 readonly 数据 + 修改方法,维护单向数据流
  • 封装为 ComposableprovideXxx + useXxx 模式),提升开发体验
  • 提供有意义的错误信息,当 inject 失败时抛出清晰的异常
  • 注意 SSR 环境,避免全局单例污染

掌握这些模式,你就能在 Vue3 项目中构建出清晰、灵活、可维护的组件架构。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Vue3 依赖注入深入:provide/inject 的架构设计与最佳实践
https://boke.hackerdream.xyz/posts/vue3-provide-inject-di/
作者
晴天
发布于
2026-01-21
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录