通过组件父子关系和原型链实现跨层级依赖注入
在组件开发中,父组件有时需要向后代组件提供一些数据或方法。
如果只通过 props 传递,就需要一层一层地向下传递:
祖先组件 -> 父组件 -> 子组件 -> 后代组件
中间组件即使不使用这些数据,也需要继续声明和转发 props。
provide 和 inject 就是为了解决这个问题:
- 祖先组件通过
provide提供依赖 - 后代组件通过
inject获取依赖 - 中间组件不需要关注依赖的传递过程
import { createApp, h, inject, provide } from '../dist/vue.esm.js'
const GrandChild = {
setup() {
const theme = inject('theme')
return () => {
return h('div', `theme: ${theme}`)
}
}
}
const Parent = {
setup() {
return () => {
return h(GrandChild)
}
}
}
const App = {
setup() {
provide('theme', 'dark')
return () => {
return h(Parent)
}
}
}
createApp(App).mount('#app')
这里 App 和 GrandChild 之间隔着一层 Parent,但 GrandChild 仍然可以直接拿到 App 提供的 theme。
要实现这个功能,首先需要让组件实例知道自己的父组件。
保存组件父子关系
1. 传递 parentComponent
组件实例是在 mountComponent 中创建的。
之前创建组件实例时,只传入了 vnode;现在需要额外传入当前组件的父组件实例:
function processComponent(
n1,
n2,
container,
anchor,
parentComponent
) {
if (n1 == null) {
mountComponent(n2, container, anchor, parentComponent)
}
else {
updateComponent(n1, n2)
}
}
function mountComponent(vnode, container, anchor, parentComponent) {
const instance = createComponentInstance(vnode, parentComponent)
vnode.component = instance
setupComponent(instance)
setupRenderEffect(instance, container, anchor)
}
创建实例时保存 parent:
export function createComponentInstance(vnode, parent) {
const { type } = vnode
const instance = {
type,
vnode,
parent
// ...
}
instance.ctx = {
_: instance
}
return instance
}
这样,每个组件实例都可以通过 instance.parent 找到自己的父组件。
2. 在 patch 链路中传递父组件
组件的 render 会生成 subTree,subTree 中可能继续包含元素节点和子组件。
所以, 在执行 patch 时,需要把当前组件实例作为 parentComponent 继续向下传递:
function setupRenderEffect(instance, container, anchor) {
const componentUpdateFn = () => {
if (!instance.isMounted) {
const subTree = renderComponentRoot(instance)
patch(
null,
subTree,
container,
anchor,
instance
)
instance.subTree = subTree
instance.isMounted = true
}
else {
const prevTree = instance.subTree
const subTree = renderComponentRoot(instance)
patch(
prevTree,
subTree,
container,
anchor,
instance
)
instance.subTree = subTree
}
}
// ...
}
普通元素和子节点继续接收 parentComponent:
function processElement(
n1,
n2,
container,
anchor,
parentComponent
) {
if (n1 == null) {
mountElement(n2, container, anchor, parentComponent)
}
else {
patchElement(n1, n2, parentComponent)
}
}
function mountElement(vnode, container, anchor, parentComponent) {
const el = vnode.el = hostCreateElement(vnode.type)
const { children, shapeFlag } = vnode
// ...
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(el, children, parentComponent)
}
hostInsert(el, container, anchor)
}
function mountChildren(container, children, parentComponent) {
for (let i = 0; i < children.length; i++) {
const child = children[i] = normalizeVNode(children[i])
patch(
null,
child,
container,
null,
parentComponent
)
}
}
当父组件的 render 生成子组件 vnode 时,最终会执行:
patch(null, childVNode, container, anchor, parentComponent)
子组件就可以通过这个参数创建自己的实例:
const childInstance = createComponentInstance(
vnode,
parentComponent
)
这样,组件树中的父子关系就建立好了。
实现
1. 保存 appContext
根组件没有父组件,因此 instance.parent 在根组件上是 null。
但是,根组件仍然需要一个应用级上下文,用来保存整个应用共享的配置和依赖。
在 createApp 时创建 appContext:
// packages/runtime-core/src/apiCreateApp.ts
import { h } from './h'
export function createAppAPI(render) {
return function createApp(rootComponent, rootProps) {
const context = {
provides: {}
}
const app = {
_container: null,
mount(container) {
const vnode = h(rootComponent, rootProps)
vnode.appContext = context
render(vnode, container)
app._container = container
},
unmount() {
render(null, app._container)
}
}
return app
}
}
- 有父组件:继承父组件的
appContext - 没有父组件:使用根 vnode 上的
appContext
export function createComponentInstance(vnode, parent) {
const { type } = vnode
const appContext = parent
? parent.appContext
: vnode.appContext
const instance = {
type,
vnode,
parent,
appContext
// ...
}
instance.ctx = {
_: instance
}
return instance
}
这样,根组件和后代组件都可以访问同一个应用上下文。
2. 使用原型链保存 provides
每个组件都需要有自己的 provides 对象。
同时,子组件应该能够读取父组件提供的依赖,所以可以让子组件的 provides 继承自父组件的 provides:
export function createComponentInstance(vnode, parent) {
const { type } = vnode
const appContext = parent
? parent.appContext
: vnode.appContext
const instance = {
type,
vnode,
parent,
appContext,
provides: Object.create(
parent
? parent.provides
: appContext.provides
)
}
instance.ctx = {
_: instance
}
return instance
}
App
└── Parent
└── GrandChild
创建实例后,provides 的原型关系是:
grandChild.provides
↓ [[Prototype]]
parent.provides
↓ [[Prototype]]
appContext.provides
因此,后代组件读取某个 key 时,JavaScript 会沿着原型链向上查找:
- 先查找当前组件的
provides - 当前组件没有时,查找父组件的
provides - 继续没有时,查找应用级
provides
3. 实现 provide
// packages/runtime-core/src/apiInject.ts
import { getCurrentInstance } from './component'
export function provide(key, value) {
const instance = getCurrentInstance()
instance.provides[key] = value
}
由于当前组件的 provides 已经通过原型链继承了父组件的 provides,直接写入当前对象即可:
instance.provides[key] = value
这样做有两个效果:
- 当前组件的后代可以读取这个值
- 不会修改父组件的
provides
4. 实现 inject
inject 同样需要获取当前组件实例,然后从当前组件的父级依赖中查找:
export function inject(key, defaultValue) {
const instance = getCurrentInstance()
if (!instance) {
return defaultValue
}
const provides = instance.parent
? instance.parent.provides
: instance.appContext.provides
if (key in provides) {
return provides[key]
}
return defaultValue
}
完整的查找流程是:
inject(key)
-> 获取当前组件实例
-> 找到父组件或 appContext
-> 从 provides 原型链查找 key
-> 找到则返回对应值
-> 没找到则返回默认值
小结
processComponent创建组件实例时保存parentparentComponent需要沿着patch、元素挂载和子节点挂载流程向下传递- 根组件通过 vnode 获取
appContext - 后代组件通过
parent.appContext共享应用上下文 - 每个组件的
provides通过原型链继承父级依赖 provide将值写入当前组件的providesinject从父组件的provides中查找依赖