持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第27天,点击查看活动详情
前面我们实现了元素的更新,也是整个runtime-core模块中最困难的部分,这节也是实现更新逻辑,但是不再是元素的更新了,而是组件的更新,组件的更新就相对而言简单很多,首先我们通过一个例子来看看组件更新的场景吧
1. 组件更新场景
首先有一个子组件Child,它会接收一个msg属性,并将其渲染出来
export const Child = {
name: 'Child',
setup() {},
render() {
return h(
'p',
{},
`here is child, I receive a message from App: ${this.$props.msg}`
);
},
};
然后父组件中会调用该子组件并传递props给它,以及会有按钮,点击之后会修改传入的msg,我们的目的就是要触发子组件的更新
export const App = {
name: 'App',
setup() {
const msg = ref('hello plasticine');
const count = ref(0);
const changeMsg = () => {
msg.value = 'hello plasticine' ? 'hello again' : 'hello plasticine';
};
const addCount = () => {
count.value++;
};
return { msg, changeMsg, count, addCount };
},
render() {
return h('div', {}, [
h(Child, { msg: this.msg }),
h('button', { onClick: this.changeMsg }, 'change msg'),
h('p', {}, `count: ${this.count}`),
h('button', { onClick: this.addCount }, 'add count'),
]);
},
};
但是现在有一个问题,子组件中我们访问了this.$props,这个是我们前面还没有实现的,所以先来把这个小功能实现一下
2. render 函数中访问 this.$props
this.$xxx的实现逻辑是放在componentPublicInstance.ts中的,由于我们之前已经实现了在组件实例上挂载了proxy代理对象,而代理对象的handler会根据publicPropertiesMap去获取$xxx属性,所以我们在publicPropertiesMap中添加$props的获取实现即可
const publicPropertiesMap = {
$el: (i) => i.vnode.el,
$slots: (i) => i.slots,
+ $props: (i) => i.props,
};
现在我们的案例场景就可以渲染出来了
3. 未实现组件更新逻辑时的 bug
现在我们还没有实现组件更新的逻辑,那么点击change msg会怎样呢?
可以看到,当点击
change msg修改msg时,居然会重复渲染出子组件的内容,这是为什么呢?
可以回顾一下我们之前的patch逻辑中对组件类型的处理
function patch(n1, n2, container, parentComponent = null, anchor) {
const { type, shapeFlag } = n2;
switch (type) {
case Fragment:
processFragment(n1, n2, container, parentComponent, anchor);
break;
case Text:
processText(n1, n2, container);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 真实 DOM
processElement(n1, n2, container, parentComponent, anchor);
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
// 处理 component 类型
processComponent(n1, n2, container, parentComponent, anchor);
}
break;
}
}
只有一个地方 -- processComponent用于处理组件
function processComponent(
n1,
n2: any,
container: any,
parentComponent,
anchor
) {
mountComponent(n2, container, parentComponent, anchor);
}
而这个函数中只调用了mountComponent函数去处理组件,也就是无论如何,只要遇到组件,就会将它挂载,所以也就会看到bug中无论怎么修改msg,都会触发依赖,调用子组件的render函数重新将子组件渲染一遍,而没有进行修改
但是有一个问题,为什么点击add count也会导致子组件重新渲染呢?明明子组件都没有用到count这个响应式变量呀
别忘了,count在父组件的render函数中有用到,所以当修改count的时候,父组件的render函数会被执行,由于render函数中用到了子组件Child,所以每次执行render函数都会把子组件的render函数也执行一遍
4. 实现 updateComponent 更新组件
4.1 修改组件处理入口
首先我们要修改一下processComponent入口,将updateComponent更新组件的调用逻辑添加上
function processComponent(
n1,
n2: any,
container: any,
parentComponent,
anchor
) {
if (!n1) {
// 没有旧组件 -- 挂载组件
mountComponent(n2, container, parentComponent, anchor);
} else {
// 有旧组件 -- 更新组件
updateComponent(n1, n2);
}
}
判断逻辑也很简单,只要n1旧vnode不存在就意味着要挂载组件,存在则进行组件更新
4.2 组件更新的思路
组件更新的本质起始就是重新调用组件的render函数,对已有的组件元素进行更新,所以我们肯定需要在updateComponent函数中获取到组件实例对象,这样才能获取到它的render函数并进行调用
但是其实并不只是调用render函数那么简单,我们还要处理组件的子组件呢,但是别忘了之前我们实现了一个交setupRenderEffect的函数
在这个函数里面实现了对组件render函数的调用,以及进行组件实例上subTree属性的更新,将render函数的结果作为当前组件实例的子树,好在这一切都已经在setupRenderEffect中实现了,所以最终我们的目的是希望调用下面这段代码:
function setupRenderEffect(instance, container, anchor) {
effect(() => {
if (!instance.isMounted) {
// ...
} else {
// ================ 调用这段代码进行组件更新 ================
const { proxy, vnode } = instance;
const subTree = instance.render.call(proxy); // 新 vnode
const prevSubTree = instance.subTree; // 旧 vnode
instance.subTree = subTree; // 新的 vnode 要更新到组件实例的 subTree 属性 作为下一更新的旧 vnode
patch(prevSubTree, subTree, container, instance, anchor);
// ================ 调用这段代码进行组件更新 ================
}
});
}
这里由于我们需要做数据响应式,所以将整个setupRenderEffect的代码包装到effect函数中了,而effect函数执行完毕后会返回一个runner,也就是被包裹的这个函数
我们可以把这个runner挂载到组件实例上,给组件实例添加一个update属性,这个属性指向返回的runner,这样就能够通过调用组件实例的update方法实现对组件的更新了!
但是我们能够在updateComponent中获取到的只有n1和n2,它们是vnode,并不是组件实例,所以还需要给vnode添加一个指向组件实例的引用,方便我们直接通过n1/n2虚拟节点获取到它们对应的组件实例,从而调用update方法
4.3 给组件实例挂载 update 方法
通过前面的思路分析,可以很清楚地知道我们应当将effect调用后返回的runner挂载到组件实例上
function setupRenderEffect(instance, container, anchor) {
instance.update = effect(() => {
// ...
}
}
4.4 给组件实例添加 update 属性
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {},
slots: {},
provides: parent ? parent.provides : {},
parent,
isMounted: false,
subTree: {},
+ update: null,
};
4.5 给 vnode 添加指向组件实例的属性
export function createVNode(type, props?, children?) {
const vnode = {
type,
props,
children,
shapeFlag: getShapeFlag(type),
el: null,
key: props?.key,
+ component: null,
};
}
并且我们还需要在首次挂载组件的时候,将创建的组件实例挂载到vnode上,这样后续才能在vnode上访问到组件实例
function mountComponent(
initialVNode: any,
container,
parentComponent,
anchor
) {
// 根据 vnode 创建组件实例
- const instance = createComponentInstance(initialVNode, parentComponent);
+ const instance = (initialVNode.component = createComponentInstance(
+ initialVNode,
+ parentComponent
+ ));
// setup 组件实例
setupComponent(instance);
setupRenderEffect(instance, container, anchor);
}
现在就可以通过n1/n2访问到组件实例了
function updateComponent(n1, n2) {
const instance = (n2.component = n1.component);
instance.update();
}
这里注意一定要把组件实例赋值给n2,因为n2是新的vnode,它是没有被挂载的,不存在组件实例,而由于它和n1都是同一个组件,所以我们需要把n1.component赋值给n2.component,这样也保证了更新前后都是同一个组件
最后调用组件实例的update方法即可
5. 组件更新预处理
在真正调用组件实例的update之前,我们需要进行一些预处理,因为vnode的props是会变化的,所以我们需要保证组件获取到的也是最新的props,同时每次组件更新之后,都要修改组件实例的vnode属性,让其指向最新的vnode
5.1 将新 vnode 挂载到组件实例中
为了能够在setupRenderEffect中获取到更新后的vnode.props,我们需要把新的vnode挂载到组件实例上,可以给组件实例添加一个next属性,让其指向新的vnode,也就是n2
所以现在组件实例有两个引用会指向vnode,一个是vnode属性,指向旧的vnode,另一个是next属性,会指向updateComponent中新的vnode
export function createComponentInstance(vnode, parent) {
console.log('createComponentInstance -- parent: ', parent);
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {},
slots: {},
provides: parent ? parent.provides : {},
parent,
isMounted: false,
subTree: {},
update: null,
+ next: null
};
}
然后在updateComponent中将n2赋值给next属性
function updateComponent(n1, n2) {
const instance = (n2.component = n1.component);
+ instance.next = n2;
instance.update();
}
5.2 修改 setupRenderEffect 对组件进行更新
由于调用组件实例的update方法实际上就是调用setupRenderEffect中effect函数中的runner,所以我们真正的组件更新逻辑其实要在这里进行处理
刚才已经给组件实例添加了next属性了,现在就可以在这里获取到
function setupRenderEffect(instance, container, anchor) {
instance.update = effect(() => {
if (!instance.isMounted) {
// 首次挂载组件
// ...
} else {
// 组件更新
- const { proxy, vnode } = instance;
+ const { proxy, vnode, next } = instance;
+ if (next) {
+ // 让新 vnode.el 指向旧 vnode.el,因为它们仍然是同一个 vnode
+ next.el = vnode.el;
+ updateComponentPreRender(instance, next);
+ }
const subTree = instance.render.call(proxy); // 新 vnode
const prevSubTree = instance.subTree; // 旧 vnode
instance.subTree = subTree; // 新的 vnode 要更新到组件实例的 subTree 属性 作为下一更新的旧 vnode
patch(prevSubTree, subTree, container, instance, anchor);
}
});
}
这里我们从组件实例中解构出next属性,如果next存在则说明有新的vnode,那么我们就要做以下几件事:
- 让新的
vnode.el指向旧vnode.el,因为它们仍然是同一个vnode,只是属性发生了变化 - 调用
updateComponentPreRender函数更新组件实例
没错,这里我们又多了一个updateComponentPreRender函数去处理真正的组件实例的更新
function updateComponentPreRender(instance, nextVNode) {
instance.vnode = nextVNode;
instance.next = null;
instance.props = nextVNode.props;
}
主要就是让vnode指向新的vnode,让next指向null,这样的一来新的vnode在下一次更新组件的时候就会成为老的vnode
其次,还要更新组件的props
6. 测试组件更新
至此我们的组件更新逻辑已经算是实现了,那么我们来看一下是否真的可以更新呢,还是打开开头准备的案例
可以看到确实是可以更新组件了,但其实还有一个小问题,如果当我们修改和子组件无关的父组件数据,触发父组件的视图更新,执行父组件的
render函数的话,是否会导致子组件的更新逻辑又被执行呢?
7. 修复子组件更新逻辑的不必要调用 bug
可以进入调试模式看看,我们在processComponent中的更新组件调用入口添加一个断点
function processComponent(
n1,
n2: any,
container: any,
parentComponent,
anchor
) {
if (!n1) {
// 没有旧组件 -- 挂载组件
mountComponent(n2, container, parentComponent, anchor);
} else {
+ debugger;
// 有旧组件 -- 更新组件
updateComponent(n1, n2);
}
}
当我们点击add count的时候,会在断点处停下,并且查看n1和n2,正是子组件中的内容
可以看到,
n1和n2中的props是一样的,因为本来我们就没有修改子组件的数据,但是现在却触发了更新逻辑,很明显是有问题的
要解决这个问题很简单,不难发现子组件更新的前提是它的props发生了变化,才导致需要更新子组件的视图,所以我们只需要在调用update方法之前判断以下props是否发生改变即可
考虑到后续这个子组件更新的前提条件还会变,可能不仅仅是通过props来约束,所以我们可以将这个判断逻辑封装到一个shouldUpdateComponent函数中
创建src/runtime-core/componentUpdateUtils.ts
export function shouldUpdateComponent(prevVNode, nextVNode) {
const { props: prevProps } = prevVNode;
const { props: nextProps } = nextVNode;
for (const key in nextVNode) {
if (prevProps[key] !== nextProps[key]) {
return true;
}
}
return false;
}
然后修改updateComponent函数,只在需要更新的时候才更新组件
function updateComponent(n1, n2) {
const instance = (n2.component = n1.component);
debugger;
if (shouldUpdateComponent(n1, n2)) {
instance.next = n2;
instance.update();
} else {
// 即使不需要更新 也要修改 n2.el = n1.el,因为它们仍然是同一个 vnode
n2.el = n1.el;
// 让 n2 成为下一次组件更新时的旧 vnode
instance.vnode = n2;
}
}
这次我们把断点打在updateComponent中,观察以下点击add count是否会进入else分支,会的话就说明修复完毕了
function updateComponent(n1, n2) {
const instance = (n2.component = n1.component);
+ debugger;
if (shouldUpdateComponent(n1, n2)) {
instance.next = n2;
instance.update();
} else {
// 即使不需要更新 也要修改 n2.el = n1.el,因为它们仍然是同一个 vnode
n2.el = n1.el;
// 让 n2 成为下一次组件更新时的旧 vnode
instance.vnode = n2;
}
}
可以看到,确实进入了
else分支,修复完毕