依赖注入:Vue3探秘系列— provide 与 inject 的实现原理(九)

295 阅读6分钟

前言

Vue3探秘系列文章链接:

不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)

不止响应式:Vue3探秘系列— 组件更新会发生什么(二)

不止响应式:Vue3探秘系列— diff算法的完整过程(三)

不止响应式:Vue3探秘系列— 组件的初始化过程(四)

终于轮到你了:Vue3探秘系列— 响应式设计(五)

计算属性:Vue3探秘系列— computed的实现原理(六)

侦听属性:Vue3探秘系列— watch的实现原理(七)

生命周期:Vue3探秘系列— 钩子函数的执行过程(八)

依赖注入:Vue3探秘系列— provide 与 inject 的实现原理(九)

Vue3探秘系列— Props:初始化与更新流程(十)

Vue3探秘系列— directive:指令的实现原理(十一)

Hello~大家好。我是秋天的一阵风

Vue 3 中的 provide 和 inject 是一种组件间通信的方式,它们允许跨越多个组件层级传递数据,而不需要直接父子关系。这种方式特别适用于那些需要在组件树中多个层级共享的状态或者服务。

一、 Prop 逐级透传问题

通常情况下,当我们需要从父组件向子组件传递数据时,会使用 props。想象一下这样的结构:有一些多层级嵌套的组件,形成了一棵巨大的组件树,而某个深层的子组件需要一个较远的祖先组件中的部分数据。在这种情况下,如果仅使用 props 则必须将其沿着组件链逐级传递下去,这会非常麻烦:

image.png

注意,虽然这里的 <Footer> 组件可能根本不关心这些 props,但为了使 <DeepChild> 能访问到它们,仍然需要定义并向下传递。如果组件链路非常长,可能会影响到更多这条路上的组件。这一问题被称为“prop 逐级透传”,显然是我们希望尽量避免的情况。

二、provide 和 inject

provide 和 inject 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

image.png

三、 使用例子

举个例子,我们在祖先组件调用 provide API

// Provider
import { provide, ref } from "vue";
export default {
  setup() {
    const theme = ref("dark");
    provide("theme", theme);
  },
};

然后在子孙组件调用 inject API

// Consumer
import { inject } from "vue";
export default {
  setup() {
    const theme = inject("theme", "light");
    return {
      theme,
    };
  },
};

这里要说明的是,inject 函数接受第二个参数作为默认值,如果祖先组件上下文没有提供 theme,则使用这个默认值。

默认情况下,inject 假设传入的注入名会被某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,则会抛出一个运行时警告。

实际上,你可以把依赖注入看作一部分“大范围有效的 prop”,而且它的规则更加宽松:祖先组件不需要知道哪些后代组件在使用它提供的数据,后代组件也不需要知道注入的数据来自哪里。

四、 实现原理

1. provide API

同学们回忆一下,在不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)中的2.2.3小结介绍 mountComponet函数的时候里面提到第一步就是创建组件实例,组件实例对象instance是通过 createComponentInstance方法创建的,instance对象有非常多的属性,其中对于 provides对象是这样定义的:

const instance = {
  // 依赖注入相关
  provides: parent ? parent.provides : Object.create(appContext.provides),
  // 其它属性
  // ...
};

在创建组件实例对象的时候,provides对象执行父组件实例的 provides对象

在我们使用 provide API 的时候则是将这个对象取出,往这个对象上赋值:

// core/packages/runtime-core/src/apiInject.ts

function provide(key, value) {
  let provides = currentInstance.provides
  const parentProvides = currentInstance.parent && currentInstance.parent.provides
  if (parentProvides === provides) {
    provides = currentInstance.provides = Object.create(parentProvides)
  }
  provides[key] = value
}

所以在默认情况下,组件实例的 provides 继承它的父组件,但是当组件实例需要提供自己的值的时候,它使用父级提供的对象创建自己的 provides 的对象原型。通过这种方式,在inject阶段,我们可以非常容易通过原型链查找来自直接父级提供的数据。

注意: 如果组件实例提供和父级 provides 中有相同key的数据,是可以覆盖父级提供的数据。

如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突:


// keys.js
export const myInjectionKey = Symbol()

// 在供给方组件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, { /*
  要提供的数据
*/ });

// 注入方组件
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const injected = inject(myInjectionKey)

2. inject API

function inject(key, defaultValue) {
  const instance = currentInstance || currentRenderingInstance;
  if (instance) {
    const provides = instance.provides;
    if (key in provides) {
      return provides[key];
    } else if (arguments.length > 1) {
      return defaultValue;
    } else if (process.env.NODE_ENV !== "production") {
      warn(`injection "${String(key)}" not found.`);
    }
  }
}
  1. inject的实现非常简单,就是从组件实例对象的 provides对象上取值。

  2. inject 支持两个参数,第一个参数是 key,我们可以访问组件实例中的 provides 对象对应的 key,层层查找父级提供的数据。第二个参数是默认值,如果查找不到数据,则直接返回默认值。

  3. 如果既查找不到数据且也没有传入默认值,则在非生产环境下报警告,提示用户找不到这个注入的数据。

五、 依赖注入的优点

同学们看到这里可能会觉得这也太简单了,不就是创建一个父级对象就完事了吗?

那为什么不在根组件创建一个共享数据 sharedData 呢?

// Root.js
export const sharedData = ref("");
export default {
  name: "Root",
  setup() {
    // ...
  },
  // ...
};

然后在子组件中使用 sharedData

import { sharedData } from "./Root.js";
export default {
  name: "Root",
  setup() {
    // 这里直接使用 sharedData 即可
  },
};

没错,在这个例子中通过模块化的方式是可以做到和 provideinject 一样的功能。

但是 provicdeinject的优点远不仅此。

1. 作用域不同

  • 依赖注入作用域是局部范围,所以你只能把数据注入以这个节点为根的后代组件中,不是这棵子树上的组件是不能访问到该数据的;

  • 模块化的作用域是全局范围的,你可以在任何地方引用它导出的数据。

2. 数据来源不同

  • 依赖注入:后代组件是不需要知道注入的数据来自哪里,只管注入并使用即可;

  • 模块化:用户必须明确知道这个数据是在哪个模块定义的,从而引入它。

3. 上下文不同

  • 依赖注入:提供数据的组件的上下文就是组件实例,而且同一个组件定义是可以有多个组件实例的,我们可以根据不同的组件上下文提供不同的数据给后代组件;

  • 模块化:它提供的数据,它是没有任何上下文的,仅仅是这个模块定义的数据,如果想要根据不同的情况提供不同数据,那么从 API 层面设计就需要做更改。

六、依赖注入的缺点

虽然 provide 和 inject 在某些场景下非常有用,特别是当需要跨多层组件传递数据时,但是它们也有一些固有的缺点。在选择使用 provide 和 inject 之前,开发者应该仔细权衡这些缺点是否会影响项目的长期维护和发展

1. 组件耦合度高

  • 问题描述

    • 使用 provide 和 inject 可能会导致组件之间的耦合度增加,因为后代组件直接依赖于祖先组件提供的数据。
  • 影响

    • 当需要重构或修改祖先组件时,可能会影响到多个后代组件。
    • 组件复用性降低,因为后代组件需要特定的祖先结构才能正常工作。

2. 缺乏类型安全

  • 问题描述

    • provide 和 inject 不提供内置的类型检查机制。
  • 影响

    • 在开发过程中,可能会出现类型错误,但编译器无法提前发现。
    • 这种类型的错误通常只能在运行时捕获,增加了调试难度。

3. 不直观的调试

  • 问题描述

    • 数据流不是线性的,而是跨越多个层级。
  • 影响

    • 调试时难以追踪数据的来源和流向。
    • 特别是在大型项目中,这会增加定位问题的时间成本。

七、适用场景:组件库开发

虽然缺点非常明显,但是在组件库的开发中使用时却是非常推荐的,因为对于一个特定组件,它和其嵌套的子组件上下文联系很紧密。

这里来举一个 Element-UI 组件库 Select 组件的例子:

<template>
  <el-select v-model="value" placeholder="请选择">
    <el-option
      v-for="item in options"
      :key="item.value"
      :label="item.label"
      :value="item.value">
    </el-option>
  </el-select>
</template>
<script>
  export default {
    data() {
      return {
        options: [{
          value: '选项1',
          label: '黄金糕'
        }, {
          value: '选项2',
          label: '双皮奶'
        }, {
          value: '选项3',
          label: '蚵仔煎'
        }, {
          value: '选项4',
          label: '龙须面'
        }, {
          value: '选项5',
          label: '北京烤鸭'
        }],
        value: ''
      }
    }
  }
</script>

这是 Select 组件的基础示例,它最终会在页面上渲染成这样:

image.png

子组件 ElOption 负责渲染每一个选项,它的内部想要访问最外层的 ElSelect 组件时,就可以通过依赖注入的方式,在 ElSelect 组件中提供组件的实例:

export default {
  provide() {
    return {
      select: this,
    };
  },
};

就这样,我们可以在 ElOption 组件注入这个数据:

export default {
  inject: ["select"],
};

虽然这些代码还是用的 Vue.js 2.xOptions API 方式,但是依赖注入的思想是不变的。

你可能会问,为什么不在 ElOption 子组件内通过 this.$parent 访问外层的 ElSelect 组件实例呢?

虽然 this.$parent 指向的是它的父组件实例,在我们这个例子是可以的,但如果组件结构发生了变化呢?

我们再来看另一个 Select 组件的例子:

<template>
  <el-select v-model="value" placeholder="请选择">
    <el-option-group
      v-for="group in options"
      :key="group.label"
      :label="group.label">
      <el-option
        v-for="item in group.options"
        :key="item.value"
        :label="item.label"
        :value="item.value">
      </el-option>
    </el-option-group>
  </el-select>
</template>
<script>
  export default {
    data() {
      return {
        options: [{
          label: '热门城市',
          options: [{
            value: 'Shanghai',
            label: '上海'
          }, {
            value: 'Beijing',
            label: '北京'
          }]
        }, {
          label: '城市名',
          options: [{
            value: 'Chengdu',
            label: '成都'
          }, {
            value: 'Shenzhen',
            label: '深圳'
          }, {
            value: 'Guangzhou',
            label: '广州'
          }, {
            value: 'Dalian',
            label: '大连'
          }]
        }],
        value: ''
      }
    }
  }
</script>


这是 Select 组件的分组示例,最终会在页面上渲染成这样:

image.png

显然,这里 ElOption 中的 this.$parent 指向的就不是 ElSelect 组件实例,而是 ElOptionGroup 组件实例。但如果我们用依赖注入的方式,即使结构变了,还是可以在 ElOption 组件中正确访问到 ElSelect 的实例。

所以,this.$parent 是一种强耦合的获取父组件实例方式,非常不利于代码的重构,因为一旦组件层级发生变化,就会产生非预期的后果,所以在平时的开发工作中你应该慎用这个属性。

相反,在组件库的场景中,依赖注入还是很方便的,除了示例中提供组件实例数据,还可以提供任意类型的数据。

因为入口组件和它的相关子组件关联性是很强的,无论后代组件的结构如何变化,最终都会渲染在入口组件的子树上。

总结:

通过本文的介绍,我们深入了解了 Vue.js 中 provide 和 inject 的使用方法及其背后的实现原理。这两种特性为跨层级组件间的数据传递提供了极大的便利,特别是在需要避免繁琐的逐层传递时尤为有用。

provide 和 inject 也有其局限性和潜在的缺点。例如,它们可能引入非响应式的数据、增加组件间的耦合度、缺乏类型安全性以及调试难度等问题。因此,在实际开发中,开发者需要根据项目的需求和具体情况来决定是否使用 provide 和 inject,并考虑是否有其他更适合的解决方案,比如 Vuex 等状态管理库。