网络日志

inject 不生效?!依赖注入背后的实现原理和运行逻辑是怎样的?

一个问题

如上图所示,我们先来思考一个问题,宿主项目使用了业务组件库中的组件,然后在宿主项目中向业务组件注入了一个名为 datekey,其值为当前的时间戳,问 业务组件可以拿到宿主项目注入的数据吗?

在回答这个问题之前,我们先来看一下 provide 和 inject 的使用方式。

依赖注入

provide

要为组件后代供给数据,需要使用到 provide() 函数:

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

provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
</script>

如果不使用 <script setup>,请确保 provide() 是在 setup() 同步调用的:

import { provide } from 'vue'

export default {
  setup() {
    provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
  }
}

provide() 函数接收两个参数。第一个参数被称为 注入名,可以是一个字符串、数值或 Symbol。后代组件会用注入名来查找期望注入的值。一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

第二个参数是供给的值,值可以是任意类型,包括响应式的状态,比如一个 ref:

import { ref, provide } from 'vue'

const count = ref(0)
provide('key', count)

供给的响应式状态使后代组件可以由此和供给者建立响应式的联系。

应用层 provide

除了供给一个组件的数据,我们还可以在整个应用层面做供给:

import { createApp } from 'vue'

const app = createApp({})

app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')

应用级的供给在应用的所有组件中都可以注入。这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来供给值。

inject

要注入祖先组件供给的数据,需使用 inject() 函数:

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

const message = inject('message')
</script>

如果供给的值是一个 ref,注入进来的就是它本身,而 不会 自动解包。这使得被注入的组件保持了和供给者的响应性连接。

同样的,如果没有使用 <script setup>inject() 需要在 setup() 同步调用:

import { inject } from 'vue'

export default {
  setup() {
    const message = inject('message')
    return { message }
  }
}

注入的默认值

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

如果在供给的一侧看来属性是可选提供的,那么注入时我们应该声明一个默认值,和 props 类似:

// 如果没有祖先组件提供 "message"
// `value` 会是 "这是默认值"
const value = inject('message', '这是默认值')

在一些场景中,默认值可能需要通过调用一个函数或初始化一个类来取得。为了 避免在不使用可选值的情况下进行不必要的计算或产生副作用,我们可以使用工厂函数来创建默认值

const value = inject('key', () => new ExpensiveClass())

配合响应性

当使用响应式 provide/inject 值时,建议尽可能将任何对响应式状态的变更都保持在 provider 内部。 这样可以确保 provide 的状态和变更操作都在同一个组件内,使其更容易维护。

有的时候,我们可能需要在 injector 组件中更改数据。在这种情况下,我们推荐在 provider 组件内提供一个更改数据方法:

<!-- 在 provider 组件内 -->
<script setup>
import { provide, ref } from 'vue'

const location = ref('North Pole')

function updateLocation() {
  location.value = 'South Pole'
}

provide('location', {
  location,
  updateLocation
})
</script>
<!-- 在 injector 组件 -->
<script setup>
import { inject } from 'vue'

const { location, updateLocation } = inject('location')
</script>

<template>
  <button @click="updateLocation">{{ location }}</button>
</template>

最后,如果你想确保从 provide 传过来的数据不能被 injector 的组件更改,你可以使用 readonly() 来包装提供的值。

<script setup>
import { ref, provide, readonly } from 'vue'

const count = ref(0)
provide('read-only-count', readonly(count))
</script>

使用 Symbol 作为注入名

至此,我们已经了解了如何使用字符串作为注入名。但如果你正在构建大型的应用程序,包含非常多的依赖供给,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

建议在一个单独的文件中导出这些注入名 Symbol:

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)

实现原理

在对依赖注入有一个大致的了解之后我们来看一下其实现的原理是怎样的。直接上源码:

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
    const parentProvides =
      currentInstance.parent && currentInstance.parent.provides
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides)
    }
    // TS doesn't allow symbol as index type
    provides[key as string] = value
  }
}
export function inject<T>(key: InjectionKey<T> | string): T | undefined
export function inject<T>(
  key: InjectionKey<T> | string,
  defaultValue: T,
  treatDefaultAsFactory?: false
): T
export function inject<T>(
  key: InjectionKey<T> | string,
  defaultValue: T | (() => T),
  treatDefaultAsFactory: true
): T

export function inject(
  key: InjectionKey<any> | string,
  defaultValue?: unknown,
  treatDefaultAsFactory = false
) {
  // fallback to `currentRenderingInstance` so that this can be called in
  // a functional component
  const instance = currentInstance || currentRenderingInstance
  if (instance) {
    // #2400
    // to support `app.use` plugins,
    // fallback to appContext's `provides` if the instance is at root
    const provides =
      instance.parent == null
        ? instance.vnode.appContext && instance.vnode.appContext.provides
        : instance.parent.provides

    if (provides && (key as string | symbol) in provides) {
      // TS doesn't allow symbol as index type
      return provides[key as string]
    } else if (arguments.length > 1) {
      return treatDefaultAsFactory && isFunction(defaultValue)
        ? defaultValue.call(instance.proxy)
        : defaultValue
    } else if (__DEV__) {
      warn(`injection "${String(key)}" not found.`)
    }
  } else if (__DEV__) {
    warn(`inject() can only be used inside setup() or functional components.`)
  }
}
源码位置:packages/runtime-core/src/apiInject.ts

先不管开头提出的问题,我们先来看一下 provide 的源码,注意下面这句代码:

if (parentProvides === provides) {
  provides = currentInstance.provides = Object.create(parentProvides);
}

这里要解决一个问题,当父级 key 和 爷爷级别的 key 重复的时候,对于子组件来讲,需要取最近的父级别组件的值,那这里的解决方案就是利用原型链来解决。

provides 初始化的时候是在 createComponent 时处理的,当时是直接把 parent.provides 赋值给组件的 provides,所以,如果说这里发现 provides 和 parentProvides 相等的话,那么就说明是第一次做 provide(对于当前组件来讲),我们就可以把 parent.provides 作为 currentInstance.provides 的原型重新赋值。

至于为什么不在 createComponent 的时候做这个处理,可能的好处是在这里初始化的话,是有个懒执行的效果(优化点,只有需要的时候才初始化)。

看完了 provide 的源码,我们再来看一下 inject 的源码。

inject 的执行逻辑比较简单,首先拿到当前实例,如果当前实例存在的话进一步判断当前实例的父实例是否存在,如果父实例存在则取父实例的 provides 进行注入,如果父实例不存在则取全局的(appContext)的 provides 进行注入。

inject 失效?

在看完 provide 和 inject 的源码之后,我们来分析一下文章开头提出的问题。

我们在业务组件中注入了来自宿主项目的 provide 出来的 key,业务组件首先会去寻找当前组件(instance),然后根据当前组件寻找父组件的 provides 进行注入即可,显然我们在业务组件中是可以拿到宿主项目注入进来的数据的。

第二个问题

分析完了文章开头提出的问题,我们再来看一个有意思的问题。下图中的业务组件能拿到宿主项目注入的数据吗?

答案可能跟你想的有点不一样:这个时候我们就拿不到宿主项目注入的数据了!!!

问题出在了哪里?

问题出在了 Symbol 这里,事实上在这个场景下,宿主项目引入的 Symbol 和 业务组件库引入的 Symbol 本质上 并不是同一个 Symbol,因为在 不同应用中创建的 Symbol 实例总是唯一的

如果想要所有的应用共享一个 Symbol 实例,这个时候我们就需要另一个 API 来创建或获取 Symbol,那就是 Symbol.for(),它可以注册或获取一个 window 全局的 Symbol 实例。

我们的公共二方库(common)只需要做如下修改即可:

export const date = Symbol.for('date');

总结

我们要想 inject 上层提供的 provide 需要注意以下几点:

  • 确保 inject 和 provide 的组件在同一颗组件树中
  • 若使用 Symbol 作为 key 值,请确保两个组件处于同一个应用中
  • 若两个组件不处于同一个应用中,请使用 Symbol.for 创建全局的 Symbol 实例作为 key 值使用

参考

更多精彩请关注我们的公众号「百瓶技术」,有不定期福利呦!