持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第18天,点击查看活动详情
本节我们会来实现provide/inject,有了它我们就可以实现组件跨级传递数据,在父组件的setup中通过provide提供数据,无论多深层级的子组件都可以通过inject的方式获取到数据
实现的思路借鉴了js的原型链的思想,其实从表面上看也能够发现provide/inject和原型链就是挺像的
1. 思考实现方式
首先思考一下如何实现,我们可以先搭建一个应用场景,通过场景去进行思考,场景大致如下:
- 父组件
App中通过provide提供数据 - 一级子组件
Foo通过inject注入直接父组件App中提供的数据
目前先只考虑这种使用provide/inject实现简单的直接父子组件通信的功能,等实现了之后我们再一步步往后扩展
1.1 应用场景准备
首先是父组件App
export const App = {
name: 'App',
setup() {
provide('appKey', 'appValue');
},
render() {
return h('div', {}, [h(Foo)]);
},
};
然后是Foo子组件
const Foo = {
name: 'Foo',
setup() {
const appValue = inject('appKey');
return {
appValue,
};
},
render() {
return h('div', {}, [
h('p', {}, 'Foo'),
h('p', {}, `Foo inject appKey -- ${this.appValue}`),
]);
},
};
1.2 思考需要做什么
首先肯定需要实现provide和inject这两个函数,然后可以给组件实例扩展一个provides属性,这样一来就可以在provide中将key/value挂载到组件实例的provides属性上
但是由于provide是独立的函数,没办法直接获取到组件实例,所以我们应当先实现一个getCurrentInstance函数,用于返回当前组件实例
类似的,在子组件调用inject的时候,我们也应该要能够获取到子组件的组件实例,然后通过parent获取到它的父组件实例,并返回父组件实例中provides对象的相应key对应的value即可
这样就可以实现直接父子组件的provide/inject逻辑了,再梳理一下,我们要干一下几件事情:
- 实现
getCurrentInstance函数 - 给组件实例扩展
provides属性,该属性是一个对象类型 - 给组件实例扩展
parent属性,用于获取父组件实例 - 创建并实现
provide/inject函数
那么接下来就逐一实现吧
provide/inject是vue提供的用于父子组件之间跨层级通讯的一个 API,比如说父组件想给直接子组件传值时,可以使用props,但是如果是想给间接子组件传值,则可以使用provide/inject,当然,也有别的方案,但是本节主要是讨论如何实现provide/inject
2. 实现直接父子组件之间的 provide/inject
2.1 组件实例扩展 provides 和 parent 属性
根据前面的梳理,我们先去给组件实例扩展一个provides属性,它是一个对象,相当于一个容器,当调用provides的时候,就会往这里面存入数据,而子组件需要用到的时候,就会到这个容器当中取出数据
其次,还需要加一个parent属性,用于给子组件获取到自己的父组件实例
- export function createComponentInstance(vnode) {
+ export function createComponentInstance(vnode, parent) {
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {},
slots: {},
+ provides: {},
+ parent,
};
component.emit = emit as any;
return component;
}
2.2 修复添加 parent 参数的函数调用链关系
现在我们修改了reateComponentInstance函数,添加了一个parent参数,那么之前用到这个函数的地方就需要进行修改,如果你是用vscode的话,按住crtl然后点击createComponentInstance函数,就会找到引用了这个函数的代码,然后跳转过去进行修改,或者将光标移动到该函数的位置,然后按下F12也可以
目前我们只有
renderer.ts的mountComponent函数中有用到创建组件实例的函数,现在也给它修改,添加上parent参数
- function mountComponent(initialVNode: any, container) {
+ function mountComponent(initialVNode: any, container, parentComponent) {
// 根据 vnode 创建组件实例
- const instance = createComponentInstance(initialVNode);
+ const instance = createComponentInstance(initialVNode, parentComponent);
// setup 组件实例
setupComponent(instance);
setupRenderEffect(instance, container);
}
修改了之后发现processComponent函数也报错了,也给它加上
- function processComponent(vnode: any, container: any) {
+ function processComponent(vnode: any, container: any, parentComponent) {
- mountComponent(vnode, container);
+ mountComponent(vnode, container, parentComponent);
}
由于patch中用到了processComponent函数,因此也要修改
- export function patch(vnode, container) {
+ export function patch(vnode, container, parentComponent = null) {
const { type, shapeFlag } = vnode;
switch (type) {
case Fragment:
processFragment(vnode, container);
break;
case Text:
processText(vnode, container);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 真实 DOM
processElement(vnode, container);
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
// 处理 component 类型
- processComponent(vnode, container);
+ processComponent(vnode, container, parentComponent);
}
break;
}
}
小技巧:当前代码中有报错的时候,可以按下**F8**直接跳转
mountChildren中调用了patch,按下F8跳转过去进行修改
- function mountChildren(children: any, container: any, parentComponent) {
+ function mountChildren(children: any, container: any, parentComponent) {
children.forEach((v) => {
- patch(v, container);
+ patch(v, container, parentComponent);
});
}
setupRenderEffect中也是,但是setupRenderEffect中调用patch的时候,subTree的父组件就是instance实例,所以不需要给函数签名扩展parentComponent参数了
function setupRenderEffect(instance, container) {
const { proxy, vnode } = instance;
const subTree = instance.render.call(proxy);
// subTree 可能是 Component 类型也可能是 Element 类型
// 调用 patch 去处理 subTree
// Element 类型则直接挂载
- patch(subTree, container);
+ patch(subTree, container, instance);
// subTree vnode 经过 patch 后就变成了真实的 DOM 此时 subTree.el 指向了根 DOM 元素
// 将 subTree.el 赋值给 vnode.el 就可以在组件实例上访问到挂载的根 DOM 元素对象了
vnode.el = subTree.el;
}
processFragment中调用了mountChildren,也要修改
- function processFragment(vnode: any, container: any) {
+ function processFragment(vnode: any, container: any, parentComponent) {
- mountChildren(vnode.children, container);
+ mountChildren(vnode.children, container, parentComponent);
}
mountElement中调用了mountChildren,需要修改
- function mountElement(vnode: any, container: any) {
+ function mountElement(vnode: any, container: any, parentComponent) {
// 将 DOM 对象挂载到 vnode 上 从而让组件实例能够访问到
const el = (vnode.el = document.createElement(vnode.type));
const { children, shapeFlag } = vnode;
// children
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
el.textContent = children;
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
- mountChildren(children, el);
+ mountChildren(children, el, parentComponent);
}
// props
const { props } = vnode;
for (const [key, value] of Object.entries(props)) {
const isOn = (key: string) => /^on[A-Z]/.test(key);
// 处理事件监听
if (isOn(key)) {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
container.append(el);
}
patch中调用了processFragment,需要修改
export function patch(vnode, container, parentComponent) {
const { type, shapeFlag } = vnode;
switch (type) {
case Fragment:
- processFragment(vnode, container);
+ processFragment(vnode, container, parentComponent);
break;
case Text:
processText(vnode, container);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 真实 DOM
processElement(vnode, container);
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
// 处理 component 类型
processComponent(vnode, container, parentComponent);
}
break;
}
}
processElement中调用了mountElement,需要修改
- function processElement(vnode: any, container: any) {
+ function processElement(vnode: any, container: any, parentComponent) {
- mountElement(vnode, container);
+ mountElement(vnode, container, parentComponent);
}
最后是patch中调用了processElement,修改
export function patch(vnode, container, parentComponent) {
const { type, shapeFlag } = vnode;
switch (type) {
case Fragment:
processFragment(vnode, container, parentComponent);
break;
case Text:
processText(vnode, container);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 真实 DOM
- processElement(vnode, container);
+ processElement(vnode, container, parentComponent);
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
// 处理 component 类型
processComponent(vnode, container, parentComponent);
}
break;
}
}
现在就全部修复完毕啦
2.3 检查是否能够获取到父组件实例
我们在createComponentInstance的时候输出一下parentComponent看看是否能够成功在子组件中获取到父组件实例
export function createComponentInstance(vnode, parent) {
+ console.log('createComponentInstance -- parent: ', parent);
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {},
slots: {},
provides: {},
parent,
};
component.emit = emit as any;
return component;
}
可以看到确实是正确获取到父组件实例了,第一个输出是
App根组件实例的,因为是根组件,所以父组件自然为null,而第二个则是Foo组件的父组件,为App组件
2.4 创建并实现 provide/inject
现在的组件实例中已经有provides和parent属性了,那也就意味着我们可以开始实现provide/inject了
首先创建src/runtime-core/apiInject.ts,并创建两个函数
import { getCurrentInstance } from './component';
export function provide(key, value) {
const currentInstance: any = getCurrentInstance();
if (currentInstance) {
const { provides } = currentInstance;
provides[key] = value;
}
}
export function inject(key) {
const currentInstance: any = getCurrentInstance();
if (currentInstance) {
const parentProvides = currentInstance.parent.provides;
return parentProvides[key];
}
}
目前由于只需要考虑直接父子组件之间的provide/inject实现,所以inject中只会到父组件的provides中去取值,先看看效果如何
可以看到成功了!
3. 实现跨级父子组件之间的 provide/inject
3.1 简单实现
现在我们可以适当升级一下需求,我们给Foo添加一个子组件Bar,并且希望在Bar中能够通过inject获取到App组件提供的数据
const Bar = {
name: 'Bar',
setup() {
const appValue = inject('appKey');
return {
appValue,
};
},
render() {
return h('div', {}, [
h('p', {}, 'Bar'),
h('p', {}, `Bar inject appKey -- ${this.appValue}`),
]);
},
};
在Foo组件中使用Bar组件
const Foo = {
name: 'Foo',
setup() {
const appValue = inject('appKey');
return {
appValue,
};
},
render() {
return h('div', {}, [
h('p', {}, 'Foo'),
h('p', {}, `Foo inject appKey -- ${this.appValue}`),
+ h(Bar),
]);
},
};
可以看到现在是
undefined,基于当前的场景,我们可以修改组件实例的provides初始值,让其指向父组件实例的provides,这样一来Foo组件的provides就是对App组件的provides的引用,那么Bar组件寻找父组件的provides的时候,获取到的就是对App组件实例的provides
export function createComponentInstance(vnode, parent) {
console.log('createComponentInstance -- parent: ', parent);
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {},
slots: {},
- provides: {},
+ provides: parent ? parent.provides : {},
parent,
};
component.emit = emit as any;
return component;
}
这样一来就可以实现跨级父子组件之间的
provide/inject了
3.2 通过原型链的方式优化
上面这种情况只适用于组件的setup中没有调用provide提供数据的情况,这时组件实例的provides指向父组件的provides,能够正常工作,但是如果组件自身有通过provide提供数据的话,由于其provides是对父组件的provides的引用,因此提供的数据会被添加到父组件的provides中
可以通过下面这个小案例看看目前存在的问题
const Foo = {
name: 'Foo',
setup() {
+ // 提供一个和父组件 App 中提供过的同名数据 检查是否会覆盖父组件中提供的 appKey
+ provide('appKey', 'fooValue');
const appValue = inject('appKey');
return {
appValue,
};
},
render() {
return h('div', {}, [
h('p', {}, 'Foo'),
h('p', {}, `Foo inject appKey -- ${this.appValue}`),
h(Bar),
]);
},
};
现在整个组件的层级结构是App是根组件,Foo是一级子组件,Bar是二级子组件
App中通过provide提供了一个appKey的数据,值为appValue
Foo中也通过provide提供了一个appKey的数据,但是值为fooValue,并且Foo自身注入了名为appKey的数据,那么其值应当为它的父组件App中的appValue
Bar中通过inject注入了名为appKey的数据,由于它的直接父组件Foo中有提供这个数据,所以值应当为fooValue
以上是正常情况下provide/inject的情况,但是现在我们看看效果,真的是这样吗?
可以看到并不是,
Foo中的appKey的值应当为appValue,但是现在却变成了fooValue,说明它提供appKey这个数据的时候,覆盖了父组件App中提供的appKey数据
而Bar组件中注入的appKey的值虽然是fooValue,和我们预期的一样,但它其实并不是从Foo的provides中获取的,而是从App的provides中获取的,事实上Foo并没有自己的provides对象
所以这就是问题的根源所在了!每个组件实例都应该有自己的provides对象,而不是简单地设置为一个对父组件provides的引用就当完事儿了
然后组件通过inject注入数据其实就是一个往父组件中找值的过程,这个过程和js的原型链十分像,父组件中有则直接返回该值,如果没有,则继续往父组件的父组件中去找,直到找到null都找不到,则说明没有提供过该数据
既然如此,那么我们就可以通过原型链的方式实现正确的provide/inject逻辑
可以在provide中让当前组件实例的provides对象指向一个空对象,并且这个空对象的原型要指向父组件实例的provides对象,这个需求正好能够通过Object.create实现
export function provide(key, value) {
const currentInstance: any = getCurrentInstance();
if (currentInstance) {
const { provides } = currentInstance;
+ // 让当前组件实例的 provides 指向一个空对象 并且该空对象以父组件的 provides 为原型
+ currentInstance.provides = Object.create(currentInstance.parent.provides);
provides[key] = value;
}
}
export function inject(key) {
const currentInstance: any = getCurrentInstance();
if (currentInstance) {
const parentProvides = currentInstance.parent.provides;
return parentProvides[key];
}
}
但是问题就是不能每次调用provide都将当前组件实例的provides变为空对象,这样会导致之前提供的数据被清空,这个置为空对象的逻辑更像是一个初始化的逻辑,那么我们就要思考一下什么时候才要初始化呢?
初始化,也就意味着是首次使用provide,此时组件实例的provides是初始化为父组件实例的provides的,那么我们就可以判断一下了,如果自己的provides和父组件实例的provides是同一个对象的话,说明需要进行初始化了
export function provide(key, value) {
const currentInstance: any = getCurrentInstance();
if (currentInstance) {
let { provides } = currentInstance;
+ const parentProvides = currentInstance.parent?.provides;
- // 让当前组件实例的 provides 指向一个空对象 并且该空对象以父组件的 provides 为原型
- currentInstance.provides = Object.create(currentInstance.parent.provides);
+ if (provides === parentProvides) {
+ // 让当前组件实例的 provides 指向一个空对象 并且该空对象以父组件的 provides 为原型
+ provides = currentInstance.provides = Object.create(parentProvides);
+ }
provides[key] = value;
}
}
现在的结果就是正确的了
4. 扩展 inject 支持默认值
我们还可以扩展inject默认值,当父级组件没有提供需要的数据的时候,就使用提供的默认值,默认值可以是普通值,也可以是一个函数
export function inject(key, defaultValue) {
const currentInstance: any = getCurrentInstance();
if (currentInstance) {
const parentProvides = currentInstance.parent.provides;
if (!(key in parentProvides)) {
if (typeof defaultValue === 'function') {
return defaultValue();
}
return defaultValue;
}
return parentProvides[key];
}
}
然后修改一下Bar组件,注入两个父组件没有提供过的数据
const Bar = {
name: 'Bar',
setup() {
const appValue = inject('appKey');
+ const normalValue = inject('normalKey', 'normalValue');
+ const functionValue = inject('functionKey', () => 'functionValue');
return {
appValue,
+ normalValue,
+ functionValue,
};
},
render() {
return h('div', {}, [
h('p', {}, 'Bar'),
h('p', {}, `Bar inject appKey -- ${this.appValue}`),
+ h(
+ 'p',
+ {},
+ `Bar inject normalKey that not provide in parent -- ${this.normalValue}`
+ ),
+ h(
+ 'p',
+ {},
+ `Bar inject functionKey that not provide in parent -- ${this.functionValue}`
+ ),
]);
},
};
可以看到默认值生效了,无论是普通值还是函数返回的默认值都可以