持续创作,加速成长!这是我参与「掘金日新计划 · 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
分支,修复完毕