自我介绍:大家好,我是吉帅振的网络日志;微信公众号:吉帅振的网络日志;前端开发工程师,工作4年,去过上海、北京,经历创业公司,进过大厂,现在郑州敲代码。
Vue.js 为我们提供了很多组件通讯的方式,常见的是父子组件通过 prop 传递数据。但是有时,我们希望能跨父子组件通讯,比如,无论组件之间嵌套多少层级,我都希望在后代组件中能访问它们祖先组件的数据。Vue.js 2.x 给我们提供了一种依赖注入的解决方案,即在祖先组件提供一个 provide 选项,举个例子:
// Provider
export default {
provide: function () {
return {
foo: this.foo
}
}
}
这就相当于在祖先组件提供 foo 这个变量数据,我们就可以在任意子孙组件中注入这个变量数据:
// Consumer
export default {
inject: ['foo']
}
这样,我们就可以在子孙组件中通过 this.foo 访问祖先组件提供的数据,以达到组件通讯的目的。到了 Vue.js 3.0,除了可以继续沿用这种 Options 的依赖注入,还可以使用依赖注入的 API 函数 provide 和 inject,你可以在 setup 函数中调用它们。举个例子,我们在祖先组件调用 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,则使用这个默认值。实际上,你可以把依赖注入看作一部分“大范围有效的 prop”,而且它的规则更加宽松:祖先组件不需要知道哪些后代组件在使用它提供的数据,后代组件也不需要知道注入的数据来自哪里。
1.provide API
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 对象:
const instance = {
// 依赖注入相关
provides: parent ? parent.provides : Object.create(appContext.provides),
// 其它属性
// ...
}
默认情况下,组件实例的 provides 继承它的父组件,但是当组件实例需要提供自己的值的时候,它使用父级提供的对象创建自己的 provides 的对象原型。通过这种方式,在 inject 阶段,我们可以非常容易通过原型链查找来自直接父级提供的数据。另外,如果组件实例提供和父级 provides 中有相同 key 的数据,是可以覆盖父级提供的数据。举个例子:
import { createApp, h, provide, inject } from 'vue'
const ProviderOne = {
setup () {
provide('foo', 'foo')
provide('bar', 'bar')
return () => h(ProviderTwo)
}
}
const ProviderTwo = {
setup () {
provide('foo', 'fooOverride')
provide('baz', 'baz')
return () => h(Consumer)
}
}
const Consumer = {
setup () {
const foo = inject('foo')
const bar = inject('bar')
const baz = inject('baz')
return () => h('div', [foo, bar, baz].join('&'))
}
}
createApp(ProviderOne).mount('#app')
可以看到,这是一个嵌套 provider 的情况。根据 provide 函数的实现,ProviderTwo 提供的 key 为 foo 的 provider 会覆盖 ProviderOne 提供的 key 为 foo 的 provider,所以最后渲染在 Consumer 组件上的就是 fooOverride&bar&baz 。
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.`)
}
}
}
前文我们已经分析了 provide 的实现后,在此基础上,理解 inject 的实现就非常简单了。inject 支持两个参数,第一个参数是 key,我们可以访问组件实例中的 provides 对象对应的 key,层层查找父级提供的数据。第二个参数是默认值,如果查找不到数据,则直接返回默认值。
1.对比模块化共享数据的方式
// Root.js
export const sharedData = ref('')
export default {
name: 'Root',
setup() {
// ...
},
// ...
}
然后在子组件中使用 sharedData:
import { sharedData } from './Root.js'
export default {
name: 'Root',
setup() {
// 这里直接使用 sharedData 即可
}
}
当然,从这个示例上来看,模块化的方式是可以共享数据,但是 provide 和 inject 与模块化方式有如下几点不同。
作用域不同
对于依赖注入,它的作用域是局部范围,所以你只能把数据注入以这个节点为根的后代组件中,不是这棵子树上的组件是不能访问到该数据的;而对于模块化的方式,它的作用域是全局范围的,你可以在任何地方引用它导出的数据。
数据来源不同
对于依赖注入,后代组件是不需要知道注入的数据来自哪里,只管注入并使用即可;而对于模块化的方式提供的数据,用户必须明确知道这个数据是在哪个模块定义的,从而引入它。
上下文不同
对于依赖注入,提供数据的组件的上下文就是组件实例,而且同一个组件定义是可以有多个组件实例的,我们可以根据不同的组件上下文提供不同的数据给后代组件;而对于模块化提供的数据,它是没有任何上下文的,仅仅是这个模块定义的数据,如果想要根据不同的情况提供不同数据,那么从 API 层面设计就需要做更改。
比如允许用户传递一个参数:
export function getShareData(context) {
// 根据不同的 context 参数返回不同的数据
}
2.依赖注入的缺陷和应用场景
依赖注入,它确实提供了一种组件共享的方式,但并非完美的。正因为依赖注入是上下文相关的,所以它会将你应用程序中的组件与它们当前的组织方式耦合起来,这使得重构变得困难。依赖注入的特点 :祖先组件不需要知道哪些后代组件使用它提供的数据,后代组件也不需要知道注入的数据来自哪里。如果在一次重构中我们不小心挪动了有依赖注入的后代组件的位置,或者是挪动了提供数据的祖先组件的位置,都有可能导致后代组件丢失注入的数据,进而导致应用程序异常。所以,我并不推荐在普通应用程序代码中使用依赖注入。这里来举一个 Element-UI 组件库 Select 组件的例子:
子组件 ElOption 负责渲染每一个选项,它的内部想要访问最外层的 ElSelect 组件时,就可以通过依赖注入的方式,在 ElSelect 组件中提供组件的实例:
export default {
provide() {
return {
'select': this
};
}
}
就这样,我们可以在 ElOption 组件注入这个数据:
export default {
inject: ['select']
}
虽然这些代码还是用的 Vue.js 2.x 的 Options API 方式,但是依赖注入的思想是不变的。你可能会问,为什么不在 ElOption 子组件内通过 this. p a r e n t 访问外层的 E l S e l e c t 组件实例呢?虽然 t h i s . parent 访问外层的 ElSelect 组件实例呢?虽然 this. parent访问外层的ElSelect组件实例呢?虽然this.parent 指向的是它的父组件实例,在我们这个例子是可以的,但如果组件结构发生了变化呢?我们再来看另一个 Select 组件的例子:
显然,这里 ElOption 中的 this. p a r e n t 指向的就不是 E l S e l e c t 组件实例,而是 E l O p t i o n G r o u p 组件实例。但如果我们用依赖注入的方式,即使结构变了,还是可以在 E l O p t i o n 组件中正确访问到 E l S e l e c t 的实例。所以, t h i s . parent 指向的就不是 ElSelect 组件实例,而是 ElOptionGroup 组件实例。但如果我们用依赖注入的方式,即使结构变了,还是可以在 ElOption 组件中正确访问到 ElSelect 的实例。所以,this. parent指向的就不是ElSelect组件实例,而是ElOptionGroup组件实例。但如果我们用依赖注入的方式,即使结构变了,还是可以在ElOption组件中正确访问到ElSelect的实例。所以,this.parent 是一种强耦合的获取父组件实例方式,非常不利于代码的重构,因为一旦组件层级发生变化,就会产生非预期的后果,所以在平时的开发工作中你应该慎用这个属性。相反,在组件库的场景中,依赖注入还是很方便的,除了示例中提供组件实例数据,还可以提供任意类型的数据。因为入口组件和它的相关子组件关联性是很强的,无论后代组件的结构如何变化,最终都会渲染在入口组件的子树上。
掌握 Vue.js 依赖注入的实现原理,了解依赖注入的使用场景和它的缺陷。