Vue 3的Provide和Inject机制:探寻其实现原理与潜在问题

注:本文使用vue版本为3.3.7

provideinjectVue 中用于父组件向子组件传递数据的一种高级选项。它们主要用于解决组件之间共享数据的需求,特别是在跨层级嵌套的情况下。

需求

设想,如果我们在父组件定义了一组数据,要在很深的后代组件里面使用。

仅使用props的话,我们需要沿着组件链逐级传递下去,不光非常麻烦,而且难以维护,可能会因为中间一环的改动产生难以预料的问题。

如果使用了provideinject,我们可以直接无视组件链的中间环节,只要保证数据在父组件定义,那么我们一定可以在后代组件拿到。

如果数据具有相应式,那么拿到的数据也具有响应式。

使用

我们可以在父组件通过provide函数来给后代组件提供数据。

js 复制代码
<script setup>
import { provide } from 'vue'

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

它接收两个参数。

第一个参数被称为注入名,在名义上 ,可以是一个字符串或是一个 Symbol

后代组件会用注入名来查找期望注入的值。

一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

如果不同父组件,使用了相同的注入名,那么使用最近的。

可能有同学注意到了,在介绍注入名的类型的时候,用了"名义上"。

是的,在实际使用中,ts类型会告诉你可以使用number

下面是官方提供的类型。

typescript 复制代码
export declare function provide<T, K = InjectionKey<T> | string | number>(key: K, value: K extends InjectionKey<infer V> ? V : T): void;

为什么会这样呢?我们之后再说。

现在父组件已经注入了依赖值。我们可以在子组件通过inject来获取。

js 复制代码
<script setup>
import { inject } from 'vue'

const message = inject('message','say world')
</script>

它接收三个参数。

同样的,第一个参数被称为注入名,可以是一个字符串或是一个 Symbol

在这里它的类型与文档统一,不可以使用number类型作为注入名。

第二个参数是可选参数,用作默认值。props类似,如果父组件并没有提供对应的依赖,那么就可以使用默认值。

第三个参数也是可选参数,用来修饰默认值。

在有些场景下,为了避免在用不到默认值的情况下进行不必要的计算或产生副作用,我们可以使用工厂函数来创建默认值。在第三个参数为true的情况下,inject在处理默认值的时候会主动执行第二个参数,获取它的返回值当做默认值。

typescript 复制代码
const message = inject('message', () => new someClass(), true)

已知问题

看到这里,大家心里已经猜到了provideinject的实现原理,以及他们的问题了。

provide接受number类型,但 inject不接受number类型。

所以当父组件的注入名为number类型的时候,在后代组件中inject使用number作为注入名会报类型错误。但可以获取到值,使用对应字符串不会报错,也可以获取到值。

js 复制代码
// 父组件
<script setup>
import { provide, } from 'vue'
import Comp from './Comp.vue';
provide(1, 11)
</script>

// 后代组件
<script setup>
import { inject } from 'vue'

const a = inject(1) // Argument of type 'number' is not assignable to parameter of type 'string | InjectionKey<any>'
const b = inject('1')
</script>
<template>
    <div>
        {{ a }} // 11
        {{ b }} // 11
    </div>
</template>

这看起来像什么?没错,原型链,provideinject就是基于原型链实现的。

解析

我先来看看provide的源码

typescript 复制代码
export function provide<T, K = InjectionKey<T> | string | number>(
  key: K, // 注入名
  value: K extends InjectionKey<infer V> ? V : T
) {
  // 检查是否在 setup 函数内使用 provide
  if (!currentInstance) {
    // 抛出错误 provide() 只能在 setup() 内部使用。
  } else {
    // 访问当前组件实例的 provides 对象
    let provides = currentInstance.provides

    // 默认情况下,实例继承其父实例的 provides 对象
    const parentProvides =
      currentInstance.parent && currentInstance.parent.provides

    // 如果父 provides 对象与当前实例 provides 对象相同,
    // 则使用父 provides 对象作为原型创建一个新的 provides 对象
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides)
    }

    // 将提供的值分配给 provides 对象中指定的key
    provides[key as string] = value
  }
}

函数接受两个参数,key 就是注入名,value 表示提供的具体值。函数首先检查是否在 setup 函数内使用 provide,如果不是则发出警告。

如果当前实例的 provides 与父实例的相同,它会以父实例的 provides 对象作为原型创建一个新的 provides 对象,然后覆盖当前实例的 provides

然后,它访问当前组件实例的 provides ,将提供的值分配给指定的注入名。

为什么当前实例的 provides会与父实例的provides相同呢?

那是因为在创建组件实例的时候,provides如果初始化就是使用父实例的provides

typescript 复制代码
// 创建全局上下文
export function createAppContext(): AppContext {
  return {
    provides: Object.create(null),
  }
}

// 使用全局上下文
const emptyAppContext = createAppContext()

export function createComponentInstance(){
  // ...
  const appContext =
    (parent ? parent.appContext : vnode.appContext) || emptyAppContext
  const instance: ComponentInternalInstance = {
    provides: parent ? parent.provides : Object.create(appContext.provides),
  }
  return instance
}

在初始化的时候,如果存在parent,那么就初始化为父实例的provides,否则使用全局上下文的provides作为原型。

然后我们看看inject的源码。

typescript 复制代码
export function inject(
  key: InjectionKey<any> | string, // 注入名
  defaultValue?: unknown, // 可选的默认值,如果依赖未找到时使用
  treatDefaultAsFactory = false // 是否将默认值视为工厂函数,默认为 false
) {
  // 回退到 `currentRenderingInstance`,以便在函数组件中调用
  const instance = currentInstance || currentRenderingInstance

  // 还支持从应用级 provides 中查找,使用 `app.runWithContext()`
  if (instance || currentApp) {
    // 如果实例位于根级别,则回退到 appContext 的 `provides`
    const provides = instance
      ? instance.parent == null
        ? instance.vnode.appContext && instance.vnode.appContext.provides
        : instance.parent.provides
      : currentApp!._context.provides

    if (provides && (key as string | symbol) in provides) {
      return provides[key as string]
    } else if (arguments.length > 1) {
      // 返回默认值,可以将默认值视为工厂函数
      return treatDefaultAsFactory && isFunction(defaultValue)
        ? defaultValue.call(instance && instance.proxy)
        : defaultValue
    } else if (__DEV__) {
      warn(`injection "${String(key)}" not found.`) // 发出警告,注入未找到
    }
  } else if (__DEV__) {
     // 发出警告,只能在 setup() 或函数组件中使用
  }
}

inject 函数的核心思想是在当前组件实例的父组件的 provides 对象中查找对应的属性。

通过原型链的机制,provides串联起所有的上级组件,试图在整个组件层级中都能顺利找到所需的数据。如果找不到,那么就使用默认值,如果没有默认值,那么就会抛出错误。

这个设计实现了一种灵活的依赖注入机制,使得组件层级嵌套不影响获取所需数据的便利性。

问题来源

那么,既然我们知道了它们是基于响应链的机制,那么为什么provide的注入类型跟inject不同呢?

那是因为有历史渊源的。

我们可以通过历史记录注意到,最早是没有number

number是在这个PR被添加的,不过后来有人提出了质疑

概括来说,当时这个PR是2020年提交的,当时的typescriptReflect.ownKeys返回的是PropertyKey[],也就是string | number | symbol组成的数组。可以看到当时的代码

而质疑是2022年提出的,直到现在,Reflect.ownKeys返回的是(string | symbol)[]。可以在最新代码确认到。

所以当时PR的作者也承认,number 类型可以移除了。

但由于Vue已经支持了number类型的注入名------虽然获取它还需要将number转为string。所以为了避免破坏性变化,打算保留number,所以移除number的PR也被关闭了。

当然,有人会想到既然不允许移除providenumber,那么就给inject加上number呢?

也确实已经有人做了,并提交了PR,但是由于担心仅仅是为了修正类型报错就引入一个新的类型,可能会产生冲突,因此,迟迟没有合并,最后这个PR也被关闭了。

这个PR的老哥又提交了一个移除providenumberPR,一年后自己关闭了。

结论

从原型链的机制上来说,让numberstring实现共存确实有点小困难,不过肯定可以实现。放着不管确实是一个权衡的办法。

numberstring从功能上来说,只是无关紧要的一个问题,按照官网文档不会遇到任何问题。

相关推荐
悦涵仙子1 小时前
CSS中的变量应用——:root,Sass变量,JavaScript中使用Sass变量
javascript·css·sass
兔老大的胡萝卜1 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
cs_dn_Jie4 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic5 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿5 小时前
webWorker基本用法
前端·javascript·vue.js
customer086 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
清灵xmf6 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据6 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_390161776 小时前
防抖函数--应用场景及示例
前端·javascript
334554327 小时前
element动态表头合并表格
开发语言·javascript·ecmascript