分析 Vue 3 源码中 `provide` 和 `inject` 的实现原理,以及它们在组件层级通信中的精确作用。

Vue 3 的 Provide/Inject:祖传秘方与定向投喂

大家好,很高兴今天能和大家聊聊 Vue 3 中 provideinject 这对“祖传秘方与定向投喂”的组合。相信很多小伙伴在使用 Vue 的时候,都会遇到组件之间数据共享的问题。如果组件层级嵌套不深,用 props 一层层传递可能还可以接受。但如果组件嵌套很深,那 props 传递简直就是一场噩梦,代码的可维护性也会直线下降。这时候,provide/inject 就如同及时雨,帮我们解决了这个问题。

今天我们就来深入剖析一下 provide/inject 的实现原理,看看 Vue 3 是如何巧妙地实现这种跨层级组件通信的。我会尽量用通俗易懂的语言,结合源码分析和实际例子,让大家彻底搞懂它们。

一、provide/inject:解决什么问题?

在开始深入源码之前,我们先来明确一下 provide/inject 的作用。简单来说,它们提供了一种允许祖先组件向其后代组件注入依赖的方式,而不需要一层层地传递 props

举个例子,假设我们有一个根组件 App.vue,它下面有很多层级的子组件,我们需要在这些子组件中使用一个全局配置对象 config。如果使用 props 传递,我们需要在每一层组件中都声明 props,并且将 config 传递下去,这显然非常繁琐。

使用 provide/inject,我们可以在 App.vueprovide 这个 config 对象,然后在任何后代组件中 inject 这个 config 对象,就可以直接使用它了。

这种方式的优点在于:

  • 简洁: 避免了 props 的层层传递,代码更加简洁。
  • 解耦: 子组件不再依赖于父组件的 props,组件之间的耦合度降低。
  • 灵活: 可以在任何后代组件中 inject,不需要关心组件之间的层级关系。

二、provide 的实现原理:设置祖传秘方

provide 的作用是在组件实例上设置一个 provides 属性,用于存储提供给后代组件的依赖。

在 Vue 3 中,provide 的实现位于 packages/runtime-core/src/apiProvide.ts 文件中。核心代码如下:

import { currentInstance } from './component'
import { isRef, unref } from '@vue/reactivity'

export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
  if (!currentInstance) {
    if (__DEV__) {
      warn(`provide() can only be used inside setup().`)
    }
  } else {
    let provides = currentInstance.provides
    // by default an instance inherits its parent's provides object
    // but when providing something:
    // it should create its own provides object using parent provides object as prototype
    // this way in resolve provides, we can use provides[key] to check if it's locally provided
    if (provides === currentInstance.type.provides) {
      provides = currentInstance.provides = Object.create(provides)
    }
    // TS ignore due to https://github.com/microsoft/TypeScript/issues/36688
    provides[key as string] = value
  }
}

让我们一行行拆解一下:

  1. currentInstance: 这是一个全局变量,指向当前正在处理的组件实例。provide 只能在 setup() 函数中使用,因为只有在 setup() 函数中 currentInstance 才会被正确设置。

  2. provides: 每个组件实例都有一个 provides 属性,用于存储它提供的依赖。

  3. Object.create(provides): 关键的一步!如果当前组件实例没有自己的 provides 对象,Vue 会创建一个新的 provides 对象,并将父组件的 provides 对象作为它的原型。这保证了子组件可以访问到父组件提供的依赖,同时也允许子组件覆盖父组件提供的依赖。 就像祖传秘方一样,儿子继承了老子的秘方,并且可以根据自己的情况进行改良。

  4. provides[key as string] = value:keyvalue 存储到 provides 对象中。key 可以是字符串、Symbol 或者 InjectionKey 类型。value 可以是任何类型的值,包括响应式数据。

简单总结一下,provide 的作用就是在组件实例的 provides 对象中存储一个键值对,并将父组件的 provides 对象作为原型。

三、inject 的实现原理:定向投喂

inject 的作用是在组件中注入 provide 提供的依赖。

在 Vue 3 中,inject 的实现同样位于 packages/runtime-core/src/apiProvide.ts 文件中。核心代码如下:

import { currentInstance } from './component'
import { warn } from './warning'
import { InjectionKey } from './types'
import { isFunction, hasOwn } from '@vue/shared'

export function inject<T>(key: InjectionKey<T> | string, defaultValue?: T, treatDefaultAsFactory = false): T {
  if (!currentInstance) {
    if (__DEV__) {
      warn(`inject() can only be used inside setup().`)
    }
  } else {
    const provides = currentInstance.parent == null ? currentInstance.vnode.appContext && currentInstance.vnode.appContext.provides : currentInstance.parent.provides

    if (provides && (key as string | symbol) in provides) {
      // TS ignore due to isReadonly() doesn't accept symbol key
      return provides[key as string]
    } else if (arguments.length > 1) {
      return treatDefaultAsFactory && isFunction(defaultValue)
        ? defaultValue.call(currentInstance.proxy)
        : defaultValue
    } else if (__DEV__) {
      warn(`Injection "${String(key)}" not found`)
    }
  }
}

同样,我们来逐行分析:

  1. currentInstance:provide 一样,inject 也只能在 setup() 函数中使用。

  2. provides: 这里获取的是父组件provides 对象。注意,不是当前组件的 provides 对象! inject 的目的是从祖先组件那里获取依赖,所以需要从父组件的 provides 对象开始查找。如果当前组件是根组件,则从 appContext 获取。

  3. (key as string | symbol) in provides: 检查父组件的 provides 对象中是否存在指定的 key。由于 provides 对象是基于原型链的,所以会沿着原型链向上查找,直到找到对应的 key 或者到达原型链的顶端。

  4. provides[key as string]: 如果找到了 key,就返回对应的值。

  5. defaultValue: 如果没有找到 key,并且提供了 defaultValue,则返回 defaultValuedefaultValue 可以是一个值,也可以是一个函数。如果 treatDefaultAsFactorytrue,并且 defaultValue 是一个函数,则会调用这个函数,并将当前组件实例的 proxy 作为 this 上下文。

  6. warn: 如果没有找到 key,并且没有提供 defaultValue,则会发出警告。

简单总结一下,inject 的作用就是在父组件的 provides 对象中查找指定的 key,如果找到了就返回对应的值,否则返回 defaultValue 或者发出警告。

四、流程梳理:从 provideinject

现在,我们来梳理一下从 provideinject 的整个流程:

  1. provide: 在祖先组件中使用 provide 函数,将依赖存储到组件实例的 provides 对象中。如果组件实例已经有 provides 对象,则创建一个新的 provides 对象,并将父组件的 provides 对象作为原型。

  2. inject: 在后代组件中使用 inject 函数,从父组件的 provides 对象开始查找指定的 key。由于 provides 对象是基于原型链的,所以会沿着原型链向上查找,直到找到对应的 key 或者到达原型链的顶端。

  3. 数据共享: 后代组件通过 inject 函数获取到祖先组件提供的依赖,从而实现跨层级的数据共享。

可以把这个过程想象成一个家族企业。老爹(祖先组件)设立了一个基金会(provides 对象),用于支持家族企业的发展。儿子(子组件)继承了老爹的基金会,并且可以根据自己的需要进行调整。孙子(后代组件)可以通过基金会(inject)获取资金(依赖),从而发展自己的事业。

五、代码示例:祖传秘方是如何使用的?

为了更好地理解 provide/inject 的使用,我们来看一个简单的代码示例:

App.vue (祖先组件)

<template>
  <div>
    <p>App Component</p>
    <ChildComponent />
  </div>
</template>

<script setup>
import { provide, ref } from 'vue'
import ChildComponent from './components/ChildComponent.vue'

const message = ref('Hello from App!')
const config = {
  theme: 'light',
  apiBaseUrl: 'https://example.com/api'
}

provide('message', message)
provide('config', config)
</script>

components/ChildComponent.vue (中间组件)

<template>
  <div>
    <p>Child Component</p>
    <GrandChildComponent />
  </div>
</template>

<script setup>
import GrandChildComponent from './GrandChildComponent.vue'
</script>

components/GrandChildComponent.vue (后代组件)

<template>
  <div>
    <p>GrandChild Component</p>
    <p>Message: {{ injectedMessage }}</p>
    <p>Theme: {{ injectedConfig.theme }}</p>
    <p>API Base URL: {{ injectedConfig.apiBaseUrl }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'

const injectedMessage = inject('message')
const injectedConfig = inject('config')

console.log('injectedMessage isRef:', injectedMessage);
</script>

在这个例子中:

  • App.vue 使用 provide 提供了 messageconfig 两个依赖。
  • ChildComponent.vue 没有做任何事情,只是一个中间组件。
  • GrandChildComponent.vue 使用 inject 注入了 messageconfig 两个依赖,并将其显示在模板中。

可以看到,GrandChildComponent.vue 成功地获取到了 App.vue 提供的依赖,而不需要通过 props 传递。

六、使用 Symbol 作为 key:更安全的选择

在上面的例子中,我们使用了字符串作为 key。虽然简单易懂,但存在一定的风险。如果不同的组件使用了相同的字符串作为 key,可能会导致冲突。

为了避免冲突,我们可以使用 Symbol 作为 keySymbol 是一种原始数据类型,它的特点是唯一性。即使创建了多个具有相同描述的 Symbol,它们也是不同的。

我们可以这样修改上面的代码:

// 定义 Symbol
import { InjectionKey, Symbol } from 'vue'

const messageKey: InjectionKey<string> = Symbol('message')
const configKey: InjectionKey<object> = Symbol('config')

// App.vue
provide(messageKey, message)
provide(configKey, config)

// GrandChildComponent.vue
const injectedMessage = inject(messageKey)
const injectedConfig = inject(configKey)

使用 Symbol 作为 key 可以保证依赖的唯一性,避免潜在的冲突。同时,使用 TypeScript 的 InjectionKey 类型可以提供更好的类型检查。

七、响应式数据:祖传秘方也可以更新

provide/inject 不仅可以传递普通的值,还可以传递响应式数据。这意味着,如果祖先组件提供的响应式数据发生了变化,后代组件也会自动更新。

在上面的例子中,message 是一个 ref 对象,也就是响应式数据。当我们在 App.vue 中修改 message 的值时,GrandChildComponent.vue 中显示的 message 也会自动更新。

// App.vue
<template>
  <div>
    <p>App Component</p>
    <button @click="updateMessage">Update Message</button>
    <ChildComponent />
  </div>
</template>

<script setup>
import { provide, ref } from 'vue'
import ChildComponent from './components/ChildComponent.vue'

const message = ref('Hello from App!')
const config = {
  theme: 'light',
  apiBaseUrl: 'https://example.com/api'
}

provide('message', message)
provide('config', config)

function updateMessage() {
  message.value = 'Message updated!'
}
</script>

点击 Update Message 按钮,GrandChildComponent.vue 中显示的 Message 也会随之更新。

这意味着,祖传秘方不仅可以传承,还可以根据时代的变化进行更新!

八、总结:provide/inject 的优缺点

最后,我们来总结一下 provide/inject 的优缺点:

优点:

  • 简化代码: 避免了 props 的层层传递。
  • 解耦组件: 子组件不再依赖于父组件的 props
  • 提高灵活性: 可以在任何后代组件中使用。
  • 支持响应式数据: 祖先组件可以动态更新依赖。

缺点:

  • 隐式依赖: 依赖关系是隐式的,不容易追踪。
  • 调试困难: 如果依赖出现问题,不容易定位到源头。
  • 可能导致过度使用: 容易滥用 provide/inject,导致组件之间的关系过于复杂。

表格总结:

特性 优点 缺点
代码简洁 避免 props 穿透,减少冗余代码 依赖关系隐式,不易追踪
组件解耦 子组件不强依赖父组件的 props 调试困难,问题定位复杂
灵活性 任意后代组件可注入 可能导致过度使用,组件关系复杂化
响应式支持 数据变化自动更新
Key的安全性 使用 Symbol 增加安全性

总而言之,provide/inject 是一把双刃剑。合理使用可以提高代码的可维护性和灵活性,但滥用则可能导致代码难以理解和调试。

在使用 provide/inject 的时候,我们需要权衡利弊,确保它能够真正解决问题,而不是带来更多的麻烦。

好了,今天的讲座就到这里。希望通过今天的讲解,大家能够对 Vue 3 的 provide/inject 有更深入的理解。记住,祖传秘方虽好,也要谨慎使用哦!

感谢大家的聆听!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注