组件更新
虽然已经实现了diff,但是更新还没做完。
根组件中的setup暴露了ref属性msg和count,把msg传给子组件,并且提供修改msg的方法changeChildProps和修改count的方法changeCount。
当点击按钮1,由于msg是在根组件内声明的,将从根组件开始更新。传入子组件的props改变,因此子组件也需要更新。
点击按钮2,同样从根组件开始更新,但是props没有改变,因此子组件不用更新。
export const App = {
name: 'App',
setup() {
const msg = ref('123');
const count = ref(1);
window.msg = msg;
const changeChildProps = () => {
msg.value = '456';
};
const changeCount = () => {
count.value++;
};
return { msg, changeChildProps, changeCount, count };
},
render() {
return h('div', {}, [
h('div', {}, '你好'),
h(
'button',
{
onClick: this.changeChildProps
},
'change child props'
),
h(Child, {
msg: this.msg
}),
h(
'button',
{
onClick: this.changeCount
},
'change self count'
),
h('p', {}, 'count: ' + this.count)
]);
}
};
// 子组件
export default {
name: 'Child',
setup(props, { emit }) {},
render(proxy) {
return h('div', {}, [
h('div', {}, 'child - props - msg: ' + this.$props.msg)
]);
}
};
子组件使用了this.$props,需要完善组件代理,从instance上获取props。
复习:props本身是vnode的属性,在patch -> processComponent -> mountComponent -> setupComponent -> initProps中,设置了instance的props属性。
const publicPropertiesMap = {
$el: (i) => i.vnode.el,
$slots: (i) => i.slots,
$props: (i) => i.props
};
来串一下修改msg之后的流程:从根结点开始,调用render生成新的element vnode,然后patch -> patchElement -> patchChildren -> patchedKeyedChildren -> patch相同的结点。
在patch相同的结点时,patch传入old Child组件和new Child组件,这俩都是component vnode,于是patch -> processComponent,而之前的processComponent中只有mount的逻辑,没有update的逻辑。所以点击按钮1,就会mount新的Child组件。
增加更新:
function processComponent(n1, n2, container, parentComponent) {
if (!n1) {
mountComponent(n2, container, parentComponent);
} else {
updateComponent(n1, n2);
}
}
在updateComponent中要更新Child组件,那么需要调用render生成新的element vnode,再把它们传入patch。这个流程是不是似曾相识?它就是之前在effect中传入的回调函数。
// 把runner作为update属性保存到instance上
instance.update = effect(() => {
const { proxy, isMounted } = instance;
if (!isMounted) {
// ...
} else {
// 就是这段
const subTree = instance.render.call(proxy);
patch(instance.subTree, subTree, container, instance, null);
instance.subTree = subTree;
}
});
那么现在要如何重新运行这个回调呢,答案是runner,把runner作为update属性保存到instance上。
为了在updateComponent中拿到instance,可以在vnode上保存component属性,它就是vnode的instance。
在instance上保存next,表示用于更新的vnode。
function mountComponent(initialVNode, container, parentComponent) {
// 为了实现组件更新,在vnode上保存instance
const instance = (initialVNode.component = createComponentInstance(
initialVNode,
parentComponent
));
setupComponent(instance);
setupRenderEffect(instance, initialVNode, container);
}
function updateComponent(n1, n2) {
// 拿到instance,同时别忘了更新n2.component
const instance = (n2.component = n1.component);
// 只有当props变化时,子组件才需要更新
if (shouldComponentUpdate(n1, n2)) {
instance.next = n2;
instance.update(); // runner
} else {
// 无需更新,因此el也不会再改变,直接赋值给n2
n2.el = n1.el;
instance.vnode = n2;
}
}
function shouldComponentUpdate(prevVNode, nextVNode) {
const { props: prevProps } = prevVNode;
const { props: nextProps } = nextVNode;
for (const key in prevProps) {
if (prevProps[key] !== nextProps[key]) {
return true;
}
}
return false;
}
最后再修改一下更新回调函数,取出instance.next。
function setupRenderEffect(instance: any, initialVNode, container) {
instance.update = effect(() => {
const { proxy, isMounted } = instance;
if (!isMounted) {
// ...
} else {
const { next, vnode } = instance;
if (next) {
// patchElement中有 n2.el = n1.el,这一步应当可有可无
next.el = vnode.el;
updateComponentPreRender(instance, next);
}
const subTree = instance.render.call(proxy);
patch(instance.subTree, subTree, container, instance, null);
instance.subTree = subTree;
}
});
}
function updateComponentPreRender(instance, nextVNode) {
// 子组件访问的this.$props是instance.props
// 此处修改后,视图就能渲染新的props.msg
instance.props = nextVNode.props;
// 更新完毕,instance.next设置为null
instance.next = null;
// 更新instance.vnode
instance.vnode = nextVNode;
}
异步更新、nextTick
提供demo app:
import {
h,
ref,
getCurrentInstance,
nextTick
} from '../../lib/my-mini-vue.esm.js';
export default {
name: 'App',
setup() {
const count = ref(1);
const instance = getCurrentInstance();
async function onClick() {
for (let i = 0; i < 100; i++) {
console.log('update');
count.value = i;
}
console.log(instance.vnode.el.querySelector('p').innerText);
nextTick(() => {
console.log(instance.vnode.el.querySelector('p').innerText);
});
await nextTick();
console.log(instance.vnode.el.querySelector('p').innerText);
}
return {
onClick,
count
};
},
render() {
const button = h('button', { onClick: this.onClick }, 'update');
const p = h('p', {}, 'count:' + this.count);
return h('div', {}, [button, p]);
}
};
暂时注释掉下方的nextTick,可见setup中有一个for循环,每轮循环都会修改ref.value,按照目前的实现,每次值改变都会触发页面更新,在patchElement函数中加一行log,当我点击页面上的按钮时,如图所示,每次都会触发更新:
显然这样的性能比较低,理想情况应该是在i自增到99以后,页面直接更新成99,即只更新一次。为了做到这样,要提供一个队列,把每次刷新操作视为一个“job”,当全部job都加入数组后,找时机flush这个队列。
修改ref.value后,每次都会执行effect中的回调函数,然后进行更新操作,不便于我们控制。因此用到Scheduler,又因为之前用instance.update保存了回调函数,我们便可以这样:
function setupRenderEffect(instance: any, initialVNode, container) {
instance.update = effect(
() => {
const { proxy, isMounted } = instance;
if (!isMounted) {
const subTree = (instance.subTree = instance.render.call(proxy));
patch(null, subTree, container, instance, null);
initialVNode.el = subTree.el;
instance.isMounted = true;
} else {
const { next } = instance;
if (next) {
updateComponentPreRender(instance, next);
}
const subTree = instance.render.call(proxy);
patch(instance.subTree, subTree, container, instance, null);
instance.subTree = subTree;
}
},
{
// 添加Scheduler
scheduler: () => {
console.log('update - scheduler');
queueJobs(instance.update);
}
}
);
}
queueJobs将任务添加到队列,并flush。
刚才说,我们要“找时机flush”。由事件循环可知,flush是微任务比较合适。app组件中执行的for循环在某轮宏任务之中,每次修改value都会把回调加入队列,等到flush执行的时候,队列已经初始化完毕,依次执行其中任务并且出队。
宏任务(同步) -> 微任务 -> 渲染(可选) -> 宏任务 -> 微任务...
const queue: any[] = [];
const p = Promise.resolve(); // 用于复用promise
let isFlushPending = false;
// nextTick把回调变为微任务
export function nextTick(fn) {
return fn ? p.then(fn) : p;
}
export function queueJobs(job) {
// 不会添加相同的任务
if (!queue.includes(job)) {
console.log('push');
queue.push(job);
}
queueFlush();
}
function queueFlush() {
// 只开启一次flush,在本轮微任务时执行
// 起初没有复用promise,本函数末尾写的是 return Promise.resolve()
// 此时如果没有isFlushPending的话,就会运行多次queueFlush,产生多个Promise
if (isFlushPending) return;
isFlushPending = true;
// flush操作是微任务
return nextTick(() => {
flushJobs();
});
}
function flushJobs() {
let job;
// 开始执行flush,脱离pending状态
isFlushPending = false;
while ((job = queue.shift())) {
job && job();
}
}
再来分析之前的组件,每次添加到队列的任务都是instance.update,属于同一个任务,所以只有第一个会进入队列。到for循环结束时,队列中只有一个回调,flush时,也就只会更新一次了。
再说说nextTick,上面已经给出了实现。由于更新操作变成了微任务,for循环刚结束时,还没来得及更新页面,因此此时获得的dom还是更新前的。给nextTick提供回调,或是使用await,可以使得回调在微任务阶段执行,根据调用nextTick的位置在修改ref的value之前或之后,可以拿到修改前/后的dom。
async function onClick() {
// 第一个nextTick
nextTick(() => {
console.log(instance.vnode.el.querySelector('p').innerText);
});
for (let i = 0; i < 100; i++) {
console.log('update');
count.value = i;
}
// 没用nextTick
console.log(instance.vnode.el.querySelector('p').innerText);
// 后两个nextTick
nextTick(() => {
console.log(instance.vnode.el.querySelector('p').innerText);
});
await nextTick();
console.log(instance.vnode.el.querySelector('p').innerText);
}
点击按钮,输出如下: