第 20 章

provide/inject:跨层级依赖注入的查找链与类型安全实践

Vue 3 的 provide/inject 机制并不依赖任何"依赖注入容器":它的查找链就是 JavaScript 原型链。当你调用 inject(key) 时,实际上是在做一次 Object.getPrototypeOf() 链式查找——这个机制快到几乎没有开销,同时天然实现了"就近覆盖"语义,而无需任何特殊代码。

Level 1 · 你需要知道的(1-3年经验)

provide 和 inject 的基本用法

在组件树中,祖先组件通过 provide() 提供数据,后代组件通过 inject() 消费数据,跳过中间所有层级。这解决了 props drilling 的问题——不需要把数据一层层往下传。

<!-- 祖先组件 Ancestor.vue -->
<script setup>
import { provide, ref } from 'vue'

const theme = ref('dark')
provide('theme', theme)
</script>
<!-- 深层后代组件 DeepChild.vue -->
<script setup>
import { inject } from 'vue'

// 第二个参数是默认值,当没有祖先 provide 时使用
const theme = inject('theme', 'light')
</script>

关键点inject() 拿到的 theme 就是祖先 provide 的同一个 ref 对象。修改这个 ref 的值会触发所有注入方的响应式更新。

字符串 key 的弊端

使用字符串作为 key,IDE 无法提供自动补全,类型也无从推断:

// 危险:key 拼错、类型不对,运行时才会发现
provide('user', { name: 'Alice', age: 28 })
const user = inject('user') // 类型是 unknown

InjectionKey:Symbol + 泛型的类型安全方案

InjectionKey<T> 是 Vue 提供的泛型接口,本质是 Symbol & { _type: T }——用 TypeScript 的结构类型系统,让 Symbol 携带类型信息:

// keys.ts — 集中管理所有注入 key
import type { InjectionKey, Ref } from 'vue'

interface User {
  id: number
  name: string
  role: 'admin' | 'editor' | 'viewer'
}

// 类型安全的 key:InjectionKey<Ref<User>>
export const userKey: InjectionKey<Ref<User>> = Symbol('user')
export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
// 提供方
import { provide, ref } from 'vue'
import { userKey } from './keys'

const user = ref<User>({ id: 1, name: 'Alice', role: 'admin' })
provide(userKey, user) // 编译器检查:必须是 Ref<User>
// 消费方
import { inject } from 'vue'
import { userKey } from './keys'

const user = inject(userKey) // 类型自动推断为 Ref<User> | undefined
const user2 = inject(userKey, ref({ id: 0, name: '', role: 'viewer' as const }))
// user2 类型为 Ref<User>(去掉了 undefined)

响应式 provide:传引用,不传值

provide 时传递响应式对象,注入方得到的是同一个响应式引用——修改会传播:

// 正确:provide ref,注入方得到响应式引用
const count = ref(0)
provide('count', count)

// 错误:provide .value,注入方得到的是普通数字,失去响应性
provide('count', count.value) // 静态值 0,不会更新

应用级 provide

app.provide() 在应用初始化时提供数据,对所有组件可见,等价于"全局注入":

// main.ts
const app = createApp(App)
app.provide('globalConfig', {
  apiUrl: 'https://api.example.com',
  version: '2.1.0'
})

这是第三方库(如 vue-i18n、vue-router)向组件注入能力的标准方式。


Level 2 · 它是怎么运行的(3-5年经验)

provide() 的内部实现:原型链继承

Vue 3 的 provide 实现只有几行代码,核心是 Object.create()。打开 packages/runtime-core/src/apiInject.ts

// packages/runtime-core/src/apiInject.ts(简化版)
export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
  if (!currentInstance) {
    // 在 setup() 外调用:开发模式下报警告
    if (__DEV__) {
      warn(`provide() can only be used inside setup().`)
    }
  } else {
    let provides = currentInstance.provides
    // 关键:如果当前实例的 provides 与父组件相同(初始状态),
    // 就创建一个以父 provides 为原型的新对象
    const parentProvides = currentInstance.parent?.provides
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides)
    }
    // 在新对象上设置键值对(不影响父 provides)
    provides[key as string] = value
  }
}

关键点Object.create(parentProvides) 让新对象的原型指向父组件的 provides 对象。这意味着:

  1. 读取一个 key 时,如果当前对象没有,会自动沿原型链向上找
  2. 在当前对象上写入同名 key,不会修改父对象(就近覆盖)

原型链结构示意图

app.context.provides (根节点,相当于原型链顶端)
  { globalKey: globalValue }
         ▲ [[Prototype]]
ComponentA.provides
  { themeKey: 'dark', userKey: userRef }
         ▲ [[Prototype]]
ComponentB.provides
  { themeKey: 'light' }  ← 覆盖了 ComponentA 的 themeKey
         ▲ [[Prototype]]
ComponentC.provides
  (与 ComponentB.provides 相同对象,尚未 provide 任何东西)

ComponentC 调用 inject('themeKey') 时:

inject() 的查找过程

// packages/runtime-core/src/apiInject.ts(简化版)
export function inject<T>(
  key: InjectionKey<T> | string,
  defaultValue?: unknown,
  treatDefaultAsFactory?: boolean,
) {
  // 确定查找起点:当前组件实例,或 appContext(应用级)
  const instance = currentInstance || currentApp
  if (instance) {
    // 从当前实例的父组件 provides 开始查找(不从自身,避免循环)
    const provides =
      instance === currentApp
        ? instance.context.provides  // 顶层 app,用 appContext
        : instance.parent == null
          ? instance.appContext.provides  // 根组件,跳到 appContext
          : instance.parent.provides       // 正常情况:从父开始

    if (provides && (key as string | symbol) in provides) {
      // `in` 运算符会沿原型链查找
      return provides[key as string]
    } else if (arguments.length > 1) {
      // 有默认值
      return treatDefaultAsFactory && isFunction(defaultValue)
        ? defaultValue.call(instance.proxy)
        : defaultValue
    }
  }
}

in 运算符是原型链查找的核心:它不只检查对象自身属性,而是沿整条原型链向上查找。

inject 查找流程图

inject('themeKey') 调用
        │
        ▼
确定 provides 起点
  当前实例是根组件? → appContext.provides
  否则 → instance.parent.provides
        │
        ▼
'themeKey' in provides?  ← in 运算符走原型链
   ├── YES → 返回 provides['themeKey']
   └── NO  → 有默认值? 
               ├── YES → 返回默认值
               └── NO  → 返回 undefined(开发模式警告)

为什么从 parent.provides 开始,而不是 instance.provides?

如果从自身的 provides 开始查找,那么一个组件 provide 了某个 key 之后,自己也能 inject 到它——这违反了"父传子"的语义。从 parent 开始,确保只有后代才能注入祖先提供的值。

provide/inject 的响应式传播路径

Ancestor: provide('user', userRef)
  userRef = ref({ name: 'Alice' })
  provides['user'] = userRef  ← 存的是 ref 对象本身

Descendant: const user = inject('user')
  user === userRef  ← 同一个引用

// 修改 userRef.value
userRef.value.name = 'Bob'
  → 触发 userRef 的 dep 通知
  → 所有依赖 user.value.name 的 effect 重新执行
  → 所有注入了 user 的组件重新渲染

关键:响应性不是 provide/inject 本身提供的,而是 Vue 响应式系统对 ref/reactive 的追踪。provide/inject 只是传递引用。

防止注入方篡改:readonly 包装

如果想让注入方读取数据但不能直接修改,只能通过祖先暴露的方法操作:

// Ancestor.vue
import { provide, ref, readonly } from 'vue'

const user = ref<User>({ id: 1, name: 'Alice', role: 'admin' })

// 暴露只读数据 + 修改方法(方法可以有权限校验)
provide(userKey, readonly(user))
provide(updateUserKey, (updates: Partial<User>) => {
  // 可以在这里做校验
  Object.assign(user.value, updates)
})
// Descendant.vue
const user = inject(userKey)!
const updateUser = inject(updateUserKey)!

// user.value.name = 'Bob' ← 运行时警告:只读!
updateUser({ name: 'Bob' }) // 正确做法

Level 3 · 设计文档与源码(资深开发者)

ComponentInternalInstance 中的 provides 字段

每个组件实例在 packages/runtime-core/src/component.ts 中定义,provides 字段的初始化方式:

// packages/runtime-core/src/component.ts
export function createComponentInstance(
  vnode: VNode,
  parent: ComponentInternalInstance | null,
  suspense: SuspenseBoundary | null,
) {
  const type = vnode.type as ConcreteComponent
  const appContext = (parent ? parent.appContext : vnode.appContext)!

  const instance: ComponentInternalInstance = {
    // ...其他字段
    provides: parent ? parent.provides : Object.create(appContext.provides),
    // ↑ 关键初始化:
    // - 有父组件:直接共享父组件的 provides 对象(不创建新对象)
    // - 根组件:以 appContext.provides 为原型创建新对象
  }
  return instance
}

初始化时共享父 provides 对象(不是复制,是直接赋值同一个引用)——这意味着:

这个惰性分裂策略节省了内存——大多数组件不提供任何依赖,无需创建额外对象。

InjectionKey 的类型实现

// packages/reactivity/src/ref.ts(实际在 vue 包的类型定义中)
// vue/types/index.d.ts
export interface InjectionKey<T> extends Symbol {}

这个接口继承自 Symbol,但携带了泛型参数 T。TypeScript 的结构类型系统会追踪这个泛型参数:

// provide 的类型签名
function provide<T, K = InjectionKey<T> | string | number>(
  key: K,
  value: K extends InjectionKey<infer V> ? V : T
): void

// inject 的类型签名
function inject<T>(key: InjectionKey<T> | string): T | undefined
function inject<T>(key: InjectionKey<T> | string, defaultValue: T, treatDefaultAsFactory?: false): T
function inject<T>(key: InjectionKey<T> | string, defaultValue: T | (() => T), treatDefaultAsFactory: true): T

当你使用 InjectionKey<User> 作为 key 时,TypeScript 从泛型参数 User 推断出 provide 的值必须是 User,inject 的返回值是 User | undefined

app.provide() 的实现

// packages/runtime-core/src/apiCreateApp.ts
const app = {
  // ...
  provide(key, value) {
    if (__DEV__ && (key as string | symbol) in context.provides) {
      warn(
        `App already provides property with key "${String(key)}". ` +
          `It will be overwritten with the new value.`,
      )
    }
    context.provides[key as string] = value
    return app
  },
}

app.provide() 直接写入 appContext.provides——这是所有组件 provides 链的终点。根组件的 provides 以 appContext.provides 为原型,因此所有组件都能通过原型链访问到应用级注入值。

effectScope 与 provide/inject 的配合

在 Composable 中使用 provide/inject 时,需要注意 effectScope 的边界。如果 Composable 创建了一个独立的 effectScope,其中的副作用与组件生命周期分离:

// 高级模式:在 effectScope 中管理响应式状态,通过 provide 共享
function createSharedStore<T>(factory: () => T): T {
  let store: T
  let scope: EffectScope

  return {
    install(app: App) {
      scope = effectScope(true) // detached scope
      store = scope.run(factory)!
      app.provide(storeKey, store)
    },
    dispose() {
      scope.stop()
    }
  }
}

这正是 Pinia 的核心思路:用 effectScope 管理 store 的响应式生命周期,通过 provide/inject 分发 store 引用。

源码:inject 对 suspense 的特殊处理

// packages/runtime-core/src/apiInject.ts
export function inject<T>(key: InjectionKey<T> | string, ...args: any[]) {
  const instance = currentInstance || currentRenderingInstance

  // 对 <Suspense> 的特殊处理:
  // Suspense 内部的异步组件在解析时,currentInstance 可能是 Suspense 本身
  // 需要找到真正的"逻辑父组件"
  if (instance || currentApp) {
    // 走正常查找逻辑
  } else if (__DEV__) {
    warn(
      `inject() can only be used inside setup() or functional components.`,
    )
  }
}

provide/inject 的性能特性

原型链查找的时间复杂度是 O(depth),其中 depth 是组件树的深度。对于典型的应用(深度 < 20 层),这个开销可以忽略不计。

相比之下,Vuex/Pinia 通过全局 store 对象访问,是 O(1) 的哈希表查找。但 provide/inject 的内存模型更轻量——不需要集中式存储。


Level 4 · 边界与陷阱(全体适用)

陷阱 1:在异步函数中调用 inject()

inject() 依赖 currentInstance,这个全局变量只在组件的同步 setup 执行期间有效:

// ❌ 错误:异步后 currentInstance 已经是 null
const setup = async () => {
  await fetchData()
  const user = inject(userKey) // 警告:inject() can only be used inside setup()
  // user 是 undefined
}

// ✅ 正确:在任何 await 之前调用 inject()
const setup = async () => {
  const user = inject(userKey) // 在 await 之前,currentInstance 有效
  await fetchData()
  // 使用 user...
}

根因:Vue 的响应式系统使用全局变量追踪"当前正在执行 setup 的组件"。async/await 会打破同步调用栈,导致 setup 挂起时 currentInstance 被重置为 null。

陷阱 2:provide 非响应式值后期修改无效

// ❌ 错误:provide 了普通对象,后续修改不会触发更新
const config = { theme: 'dark' }
provide('config', config)

// 之后修改...
config.theme = 'light' // 注入方不会更新(config 不是 reactive)

// ✅ 正确:provide reactive 对象
const config = reactive({ theme: 'dark' })
provide('config', config)
config.theme = 'light' // 注入方响应式更新

陷阱 3:inject 默认值工厂函数的 this 指向

当默认值是函数时,第三个参数 treatDefaultAsFactory: true 会将其作为工厂函数调用,this 指向组件实例 proxy:

// inject 的第三个参数
const user = inject(
  userKey,
  () => ({ id: 0, name: 'Guest', role: 'viewer' as const }),
  true // treatDefaultAsFactory:将函数作为工厂调用
)
// 如果省略第三个参数为 true,函数本身会作为默认值返回,而不是函数执行结果

这个 API 设计比较隐晦,容易忘记第三个参数导致 inject 拿到函数而非期望值。

陷阱 4:原型链覆盖的双向影响

provide 的"就近覆盖"只影响当前组件及其后代,不影响兄弟组件:

Root: provide('key', 'root-value')
  ComponentA: provide('key', 'a-value')  ← 覆盖
    ComponentA1: inject('key') → 'a-value' ✓
    ComponentA2: inject('key') → 'a-value' ✓
  ComponentB: (没有 provide)
    ComponentB1: inject('key') → 'root-value' ✓(不受 A 的覆盖影响)

但如果错误地认为"A provide 会影响所有后代"而忘记 B 的后代走的是另一条链,可能导致混淆。

陷阱 5:在 app.provide() 和组件 provide() 中使用相同 key

// main.ts
app.provide('theme', 'light') // 应用级提供

// RootComponent.vue
provide('theme', 'dark') // 根组件覆盖——OK,这是预期行为

// 但开发模式下 app.provide() 会警告重复 key:
app.provide('theme', 'light')
app.provide('theme', 'dark') // ⚠️ 警告:App already provides property with key "theme"

陷阱 6:inject 在函数式组件(Functional Component)中的可用性

函数式组件没有组件实例,但 Vue 3 支持在函数式组件中使用 inject——前提是函数式组件以 setup() 形式编写:

// ✅ 函数式组件可以使用 inject
const FunctionalComp = (props: { id: number }) => {
  const theme = inject(themeKey, 'light') // 正常工作
  return h('div', { class: `theme-${theme}` }, props.id)
}

provide/inject vs Pinia vs Props:选择决策树

需要共享状态吗?
│
├─ 状态在整个应用中共享(用户信息、全局配置)
│   └─ → Pinia(集中管理,devtools 支持,时间旅行)
│
├─ 状态只在某个组件子树中共享(表单上下文、主题、布局)
│   ├─ 需要跨越 3+ 层级
│   │   └─ → provide/inject(专为组件树共享设计)
│   └─ 只需要 1-2 层
│       └─ → props(明确,可追踪)
│
└─ 状态只属于单个组件
    └─ → ref/reactive(本地状态)

UI 组件库(Element Plus、Naive UI)大量使用 provide/inject 在 Form/FormItem、Table/Column、Select/Option 等父子关系组件间共享上下文,避免 props drilling。这是 provide/inject 最典型的应用场景。


章节小结

  1. 原型链即查找链provide() 调用 Object.create(parent.provides) 创建新对象,inject()in 运算符沿原型链查找,"就近覆盖"是 JavaScript 原型继承的自然结果,不需要额外实现。

  2. 惰性分裂:组件实例初始化时直接共享父组件的 provides 引用,只有当首次调用 provide() 时才分裂出新对象,节省大多数不提供依赖的组件的内存开销。

  3. 响应性来自引用传递:provide/inject 不提供响应性,响应性来自传递的 ref/reactive 对象本身。provide 一个普通值无法触发注入方的响应式更新。

  4. InjectionKey<T> 是编译时契约:通过 Symbol + TypeScript 泛型,在 provide 和 inject 两侧建立类型约束,将类型错误从运行时提前到编译期,是生产代码的标准实践。

  5. readonly 保护单向数据流:provide readonly(state) 配合暴露修改方法,让注入方无法直接篡改状态,维护了祖先组件对数据的所有权,是大型应用中 provide/inject 的推荐模式。

本章评分
4.8  / 5  (9 评分)

💬 留言讨论