Vue 3 的 Provide/Inject:祖传秘方与定向投喂
大家好,很高兴今天能和大家聊聊 Vue 3 中 provide
和 inject
这对“祖传秘方与定向投喂”的组合。相信很多小伙伴在使用 Vue 的时候,都会遇到组件之间数据共享的问题。如果组件层级嵌套不深,用 props
一层层传递可能还可以接受。但如果组件嵌套很深,那 props
传递简直就是一场噩梦,代码的可维护性也会直线下降。这时候,provide/inject
就如同及时雨,帮我们解决了这个问题。
今天我们就来深入剖析一下 provide/inject
的实现原理,看看 Vue 3 是如何巧妙地实现这种跨层级组件通信的。我会尽量用通俗易懂的语言,结合源码分析和实际例子,让大家彻底搞懂它们。
一、provide/inject
:解决什么问题?
在开始深入源码之前,我们先来明确一下 provide/inject
的作用。简单来说,它们提供了一种允许祖先组件向其后代组件注入依赖的方式,而不需要一层层地传递 props
。
举个例子,假设我们有一个根组件 App.vue
,它下面有很多层级的子组件,我们需要在这些子组件中使用一个全局配置对象 config
。如果使用 props
传递,我们需要在每一层组件中都声明 props
,并且将 config
传递下去,这显然非常繁琐。
使用 provide/inject
,我们可以在 App.vue
中 provide
这个 config
对象,然后在任何后代组件中 inject
这个 config
对象,就可以直接使用它了。
这种方式的优点在于:
- 简洁: 避免了
props
的层层传递,代码更加简洁。 - 解耦: 子组件不再依赖于父组件的
props
,组件之间的耦合度降低。 - 灵活: 可以在任何后代组件中
inject
,不需要关心组件之间的层级关系。
二、provide
的实现原理:设置祖传秘方
provide
的作用是在组件实例上设置一个 provides
属性,用于存储提供给后代组件的依赖。
在 Vue 3 中,provide
的实现位于 packages/runtime-core/src/apiProvide.ts
文件中。核心代码如下:
import { currentInstance } from './component'
import { isRef, unref } from '@vue/reactivity'
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
// by default an instance inherits its parent's provides object
// but when providing something:
// it should create its own provides object using parent provides object as prototype
// this way in resolve provides, we can use provides[key] to check if it's locally provided
if (provides === currentInstance.type.provides) {
provides = currentInstance.provides = Object.create(provides)
}
// TS ignore due to https://github.com/microsoft/TypeScript/issues/36688
provides[key as string] = value
}
}
让我们一行行拆解一下:
-
currentInstance
: 这是一个全局变量,指向当前正在处理的组件实例。provide
只能在setup()
函数中使用,因为只有在setup()
函数中currentInstance
才会被正确设置。 -
provides
: 每个组件实例都有一个provides
属性,用于存储它提供的依赖。 -
Object.create(provides)
: 关键的一步!如果当前组件实例没有自己的provides
对象,Vue 会创建一个新的provides
对象,并将父组件的provides
对象作为它的原型。这保证了子组件可以访问到父组件提供的依赖,同时也允许子组件覆盖父组件提供的依赖。 就像祖传秘方一样,儿子继承了老子的秘方,并且可以根据自己的情况进行改良。 -
provides[key as string] = value
: 将key
和value
存储到provides
对象中。key
可以是字符串、Symbol 或者 InjectionKey 类型。value
可以是任何类型的值,包括响应式数据。
简单总结一下,provide
的作用就是在组件实例的 provides
对象中存储一个键值对,并将父组件的 provides
对象作为原型。
三、inject
的实现原理:定向投喂
inject
的作用是在组件中注入 provide
提供的依赖。
在 Vue 3 中,inject
的实现同样位于 packages/runtime-core/src/apiProvide.ts
文件中。核心代码如下:
import { currentInstance } from './component'
import { warn } from './warning'
import { InjectionKey } from './types'
import { isFunction, hasOwn } from '@vue/shared'
export function inject<T>(key: InjectionKey<T> | string, defaultValue?: T, treatDefaultAsFactory = false): T {
if (!currentInstance) {
if (__DEV__) {
warn(`inject() can only be used inside setup().`)
}
} else {
const provides = currentInstance.parent == null ? currentInstance.vnode.appContext && currentInstance.vnode.appContext.provides : currentInstance.parent.provides
if (provides && (key as string | symbol) in provides) {
// TS ignore due to isReadonly() doesn't accept symbol key
return provides[key as string]
} else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(currentInstance.proxy)
: defaultValue
} else if (__DEV__) {
warn(`Injection "${String(key)}" not found`)
}
}
}
同样,我们来逐行分析:
-
currentInstance
: 和provide
一样,inject
也只能在setup()
函数中使用。 -
provides
: 这里获取的是父组件的provides
对象。注意,不是当前组件的provides
对象!inject
的目的是从祖先组件那里获取依赖,所以需要从父组件的provides
对象开始查找。如果当前组件是根组件,则从appContext
获取。 -
(key as string | symbol) in provides
: 检查父组件的provides
对象中是否存在指定的key
。由于provides
对象是基于原型链的,所以会沿着原型链向上查找,直到找到对应的key
或者到达原型链的顶端。 -
provides[key as string]
: 如果找到了key
,就返回对应的值。 -
defaultValue
: 如果没有找到key
,并且提供了defaultValue
,则返回defaultValue
。defaultValue
可以是一个值,也可以是一个函数。如果treatDefaultAsFactory
为true
,并且defaultValue
是一个函数,则会调用这个函数,并将当前组件实例的proxy
作为this
上下文。 -
warn
: 如果没有找到key
,并且没有提供defaultValue
,则会发出警告。
简单总结一下,inject
的作用就是在父组件的 provides
对象中查找指定的 key
,如果找到了就返回对应的值,否则返回 defaultValue
或者发出警告。
四、流程梳理:从 provide
到 inject
现在,我们来梳理一下从 provide
到 inject
的整个流程:
-
provide
: 在祖先组件中使用provide
函数,将依赖存储到组件实例的provides
对象中。如果组件实例已经有provides
对象,则创建一个新的provides
对象,并将父组件的provides
对象作为原型。 -
inject
: 在后代组件中使用inject
函数,从父组件的provides
对象开始查找指定的key
。由于provides
对象是基于原型链的,所以会沿着原型链向上查找,直到找到对应的key
或者到达原型链的顶端。 -
数据共享: 后代组件通过
inject
函数获取到祖先组件提供的依赖,从而实现跨层级的数据共享。
可以把这个过程想象成一个家族企业。老爹(祖先组件)设立了一个基金会(provides
对象),用于支持家族企业的发展。儿子(子组件)继承了老爹的基金会,并且可以根据自己的需要进行调整。孙子(后代组件)可以通过基金会(inject
)获取资金(依赖),从而发展自己的事业。
五、代码示例:祖传秘方是如何使用的?
为了更好地理解 provide/inject
的使用,我们来看一个简单的代码示例:
App.vue (祖先组件)
<template>
<div>
<p>App Component</p>
<ChildComponent />
</div>
</template>
<script setup>
import { provide, ref } from 'vue'
import ChildComponent from './components/ChildComponent.vue'
const message = ref('Hello from App!')
const config = {
theme: 'light',
apiBaseUrl: 'https://example.com/api'
}
provide('message', message)
provide('config', config)
</script>
components/ChildComponent.vue (中间组件)
<template>
<div>
<p>Child Component</p>
<GrandChildComponent />
</div>
</template>
<script setup>
import GrandChildComponent from './GrandChildComponent.vue'
</script>
components/GrandChildComponent.vue (后代组件)
<template>
<div>
<p>GrandChild Component</p>
<p>Message: {{ injectedMessage }}</p>
<p>Theme: {{ injectedConfig.theme }}</p>
<p>API Base URL: {{ injectedConfig.apiBaseUrl }}</p>
</div>
</template>
<script setup>
import { inject } from 'vue'
const injectedMessage = inject('message')
const injectedConfig = inject('config')
console.log('injectedMessage isRef:', injectedMessage);
</script>
在这个例子中:
App.vue
使用provide
提供了message
和config
两个依赖。ChildComponent.vue
没有做任何事情,只是一个中间组件。GrandChildComponent.vue
使用inject
注入了message
和config
两个依赖,并将其显示在模板中。
可以看到,GrandChildComponent.vue
成功地获取到了 App.vue
提供的依赖,而不需要通过 props
传递。
六、使用 Symbol
作为 key
:更安全的选择
在上面的例子中,我们使用了字符串作为 key
。虽然简单易懂,但存在一定的风险。如果不同的组件使用了相同的字符串作为 key
,可能会导致冲突。
为了避免冲突,我们可以使用 Symbol
作为 key
。Symbol
是一种原始数据类型,它的特点是唯一性。即使创建了多个具有相同描述的 Symbol
,它们也是不同的。
我们可以这样修改上面的代码:
// 定义 Symbol
import { InjectionKey, Symbol } from 'vue'
const messageKey: InjectionKey<string> = Symbol('message')
const configKey: InjectionKey<object> = Symbol('config')
// App.vue
provide(messageKey, message)
provide(configKey, config)
// GrandChildComponent.vue
const injectedMessage = inject(messageKey)
const injectedConfig = inject(configKey)
使用 Symbol
作为 key
可以保证依赖的唯一性,避免潜在的冲突。同时,使用 TypeScript 的 InjectionKey
类型可以提供更好的类型检查。
七、响应式数据:祖传秘方也可以更新
provide/inject
不仅可以传递普通的值,还可以传递响应式数据。这意味着,如果祖先组件提供的响应式数据发生了变化,后代组件也会自动更新。
在上面的例子中,message
是一个 ref
对象,也就是响应式数据。当我们在 App.vue
中修改 message
的值时,GrandChildComponent.vue
中显示的 message
也会自动更新。
// App.vue
<template>
<div>
<p>App Component</p>
<button @click="updateMessage">Update Message</button>
<ChildComponent />
</div>
</template>
<script setup>
import { provide, ref } from 'vue'
import ChildComponent from './components/ChildComponent.vue'
const message = ref('Hello from App!')
const config = {
theme: 'light',
apiBaseUrl: 'https://example.com/api'
}
provide('message', message)
provide('config', config)
function updateMessage() {
message.value = 'Message updated!'
}
</script>
点击 Update Message
按钮,GrandChildComponent.vue
中显示的 Message
也会随之更新。
这意味着,祖传秘方不仅可以传承,还可以根据时代的变化进行更新!
八、总结:provide/inject
的优缺点
最后,我们来总结一下 provide/inject
的优缺点:
优点:
- 简化代码: 避免了
props
的层层传递。 - 解耦组件: 子组件不再依赖于父组件的
props
。 - 提高灵活性: 可以在任何后代组件中使用。
- 支持响应式数据: 祖先组件可以动态更新依赖。
缺点:
- 隐式依赖: 依赖关系是隐式的,不容易追踪。
- 调试困难: 如果依赖出现问题,不容易定位到源头。
- 可能导致过度使用: 容易滥用
provide/inject
,导致组件之间的关系过于复杂。
表格总结:
特性 | 优点 | 缺点 |
---|---|---|
代码简洁 | 避免 props 穿透,减少冗余代码 |
依赖关系隐式,不易追踪 |
组件解耦 | 子组件不强依赖父组件的 props |
调试困难,问题定位复杂 |
灵活性 | 任意后代组件可注入 | 可能导致过度使用,组件关系复杂化 |
响应式支持 | 数据变化自动更新 | |
Key的安全性 | 使用 Symbol 增加安全性 |
总而言之,provide/inject
是一把双刃剑。合理使用可以提高代码的可维护性和灵活性,但滥用则可能导致代码难以理解和调试。
在使用 provide/inject
的时候,我们需要权衡利弊,确保它能够真正解决问题,而不是带来更多的麻烦。
好了,今天的讲座就到这里。希望通过今天的讲解,大家能够对 Vue 3 的 provide/inject
有更深入的理解。记住,祖传秘方虽好,也要谨慎使用哦!
感谢大家的聆听!