注:本文使用vue版本为3.3.7
provide
和 inject
是 Vue
中用于父组件向子组件传递数据的一种高级选项。它们主要用于解决组件之间共享数据的需求,特别是在跨层级嵌套的情况下。
需求
设想,如果我们在父组件定义了一组数据,要在很深的后代组件里面使用。
仅使用props
的话,我们需要沿着组件链逐级传递下去,不光非常麻烦,而且难以维护,可能会因为中间一环的改动产生难以预料的问题。
如果使用了provide
和 inject
,我们可以直接无视组件链的中间环节,只要保证数据在父组件定义,那么我们一定可以在后代组件拿到。
如果数据具有相应式,那么拿到的数据也具有响应式。
使用
我们可以在父组件通过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)
已知问题
看到这里,大家心里已经猜到了provide
和 inject
的实现原理,以及他们的问题了。
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>
这看起来像什么?没错,原型链,provide
和 inject
就是基于原型链实现的。
解析
我先来看看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年提交的,当时的typescript
中Reflect.ownKeys
返回的是PropertyKey[]
,也就是string | number | symbol
组成的数组。可以看到当时的代码。
而质疑是2022年提出的,直到现在,Reflect.ownKeys
返回的是(string | symbol)[]
。可以在最新代码确认到。
所以当时PR的作者也承认,number
类型可以移除了。
但由于Vue
已经支持了number
类型的注入名------虽然获取它还需要将number
转为string
。所以为了避免破坏性变化,打算保留number
,所以移除number
的PR也被关闭了。
当然,有人会想到既然不允许移除provide
的number
,那么就给inject
加上number
呢?
也确实已经有人做了,并提交了PR,但是由于担心仅仅是为了修正类型报错就引入一个新的类型,可能会产生冲突,因此,迟迟没有合并,最后这个PR也被关闭了。
这个PR的老哥又提交了一个移除provide
的number
的PR,一年后自己关闭了。
结论
从原型链的机制上来说,让number
和string
实现共存确实有点小困难,不过肯定可以实现。放着不管确实是一个权衡的办法。
number
和string
从功能上来说,只是无关紧要的一个问题,按照官网文档不会遇到任何问题。