【Vue.js 3.0源码】依赖注入子孙组件共享数据

自我介绍:大家好,我是吉帅振的网络日志;微信公众号:吉帅振的网络日志;前端开发工程师,工作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 依赖注入的实现原理,了解依赖注入的使用场景和它的缺陷。

你可能感兴趣的:(Vue.js,3.0源码,vue.js,javascript,前端,Vue.js,3.0,Vue.js,3.0源码)