前言
Vue应该是国内受众最广的前端框架,究其原因,主要是还是学习成本低,上手容易。大部分开发者,可能只需要学习一遍文档,就可以开始使用了。
曾经我也是其中一员,Vue使用已经比较长时间了,但是一直停留在使用的阶段,并未去探索其底层的实现原理和设计思想。
人生没有追求跟咸鱼有什么区别!那就撸起袖子干就完了!(一个重要原因,是学习源码的人越来越多,我实现不想被他们卷死,那只能努力卷死别人 😭)
希望通过Vue3源码学习系列,记录自己学习过程的经验总结,以便日后回顾。如果文章中有不正确的地方,还望各路大佬指正。
虚拟DOM
什么是虚拟DOM?简单来做,就是用js去描述真实的DOM对象结构。(Vue在编译过程中会通过ATS形成抽象语法树)
这样做的好处是抛弃的DOM中一些无用的信息,使得的整个树形结构更加简单清晰,使得对DOM的操作更加灵活。
老规矩,举个🌰:
真实的DOM
<ul id="list">
<li class="item">张三</li>
<li class="item">李四</li>
<li class="item">王五</li>
</ul>
虚拟DOM
{
tag:'ul',
props:{ id:'list' },
children: [
{ tag: 'li', props:{class:'item'}, children:'张三' },
{ tag: 'li', props:{class:'item'}, children:'李四' },
{ tag: 'li', props:{class:'item'}, children:'王五' }
]
}
如果将王五改成赵六,那新的虚拟DOM会变成:
{
tag:'ul',
props:{ id:'list' },
children: [
{ tag: 'li', props:{class:'item'}, children:'张三' },
{ tag: 'li', props:{class:'item'}, children:'李四' },
{ tag: 'li', props:{class:'item'}, children:'赵六' }
]
}
一直有人说Vue性能高的原因是因为使用虚拟DOM,那是否通过用新的虚拟DOM去替换旧的虚拟DOM,性能上真的比直接替换真实的DOM要好呢??我们比较一下两者的过程:
我们看到第二种方式里,由于多了一个创建新的虚拟DOM,并将它渲染成真实DOM的过程,因此如果只是简单的用新的虚拟DOM替换旧的虚拟DOM,然后渲染成真实DOM,从性能上并不会比直接渲染成真实DOM要好。
真实的情况是,Vue通过差异化的算法,比较新旧两个虚拟DOM,从中找出需要变更的虚拟DOM节点,在真实DOM结构上,只操作了这些需要变更的DOM节点。
所以更严谨的说法,应该是通过虚拟DOM的差异化算法操作真实DOM,性能高于直接操作真实DOM。虚拟DOM的差异化算法就是我们接下去要讲的Diff算法。
比起只是学习Vue3中的Diff算法,同时学习Vue2和Vue3两者的Diff算法,应该能更好的理解Vue3的优化点在哪里
Diff的几个基本原则
只做同层比较,不会做跨层级比较
只有通同类型节点才做比较,非同类型节点,直接销毁旧节点并创建新节点
// Vue3 /packages/runtime-core/src/vnode.ts
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}
Vue2中虚拟DOM Diff算法
我们先来看一下Vue2中和虚拟DOM相关的部分核心代码
patch(核心流程都在这个函数里)
patch方法比较新旧两个虚拟节点是否为统一类型
- 是:通过patchVnode做更深层次的比较
- 否:直接用新节点替换旧节点
// /src/core/vdom/patch.js
/**
* @param oldVnode 旧的虚拟DOM节点,可以不存在或是一个 DOM 对象
* @param vnode 新的虚拟DOM节点
* @param hydrating 是否是服务端渲染
* @param removeOnly 是给 transition-group 用的
*/
function patch(oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
// 如果没有新节点,但是旧节点存在,则直接触发destroy钩子
if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
return;
}
let isInitialPatch = false;
const insertedVnodeQueue = [];
if (isUndef(oldVnode)) {
// 如果旧节点不存在则直接创建新节点
isInitialPatch = true;
createElm(vnode, insertedVnodeQueue);
} else {
// 当新旧节点都存在的情况
// 判断旧节点是否为真实的节点(dom元素的nodeType为1)
const isRealElement = isDef(oldVnode.nodeType);
// 比较是否为一个类型的节点
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 旧节点不是真实节点且新旧节点是同一节点,则做更近一步的对比修改
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
} else {
// 旧节点不是真实元素,或新旧节点不是同一节点
if (isRealElement) {
// 挂载到真实元素和处理服务端渲染(这部分逻辑,目前我还没有理解清晰)
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR);
hydrating = true;
}
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true);
return oldVnode;
} else if (process.env.NODE_ENV !== 'production') {
warn(
'The client-side rendered virtual DOM tree is not matching ' +
'server-rendered content. This is likely caused by incorrect ' +
'HTML markup, for example nesting block-level elements inside ' +
'<p>, or missing <tbody>. Bailing hydration and performing ' +
'full client-side render.'
);
}
}
// 不是服务端渲染或服务端渲染失败,把 oldVnode 转换成 VNode 对象.
oldVnode = emptyNodeAt(oldVnode);
}
// 旧节点的真实DOM
const oldElm = oldVnode.elm;
// 旧节点的父元素
const parentElm = nodeOps.parentNode(oldElm);
// 根据新的 vnode 创建一个 DOM 节点,挂载到父节点上
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
);
// 递归更新父组件占位符,只有组件的渲染 VNode 才有vnode.parent
// 考虑这样的情况:
// parent-component 的模板为:
// <template>
// <child-component></child-component>
// <template>
// child-component 的模板为:
// <template>
// <div class="child-root"></div>
// <template>
//
// 未渲染的 HTML:
// <div id="root">
// <parent-component></parent-component>
// </div>
//
// 渲染后的 HTML:
// <div id="root">
// <div class="child-root"></div>
// </div>
if (isDef(vnode.parent)) {
let ancestor = vnode.parent;
const patchable = isPatchable(vnode);
while (ancestor) {
// 递归地将 vnode.elm 赋值给所有祖先占位 vnode 的 elm
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor);
}
ancestor.elm = vnode.elm;
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor);
}
const insert = ancestor.data.hook.insert;
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]();
}
}
} else {
registerRef(ancestor);
}
ancestor = ancestor.parent;
}
}
if (isDef(parentElm)) {
// 销毁旧节点
removeVnodes([oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
// 触发旧节点的destroy钩子
invokeDestroyHook(oldVnode);
}
}
}
// 触发新节点的insert钩子
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
// 返回新节点的真实的DOM
return vnode.elm;
}
sameVnode
someVnode方法判断是否是同一类型的节点
// Vue2 /src/core/vdom/patch.js
// 主要通过对key和标签名做比较
function sameVnode (a, b) {
return (
a.key === b.key && // 标签名是否一样
a.asyncFactory === b.asyncFactory && ( // 是否都是异步工厂方法
(
a.tag === b.tag && // 标签名是否一样
a.isComment === b.isComment && // 是否都为注释节点
isDef(a.data) === isDef(b.data) && // 是否都定义了data
sameInputType(a, b) // 当标签为input时,type必须是否相同
) || (
isTrue(a.isAsyncPlaceholder) && // 是否都有异步占位符节点
isUndef(b.asyncFactory.error)
)
)
)
}
patchVnode
如果新旧节点是sameVnode,则不会重新创建DOM节点,而是通过patchVnode方法对原来的DOM节点做修补。
修补的大致逻辑是:
-
如果oldVnode和vnode是同一个引用对象,则直接返回
-
如果oldVnode的isAsyncPlaceholder为true,表示当前节点异步占位节点,直接返回
-
如果 oldVnode 和 vnode 都是静态节点,且key相等,并且vnode是克隆节点或者是带有v-once 指令控制的节点时,把oldVnode.elm和oldVnode.child都复制到 vnode 上,然后返回
-
如果vnode不是文本节点,则按以下步骤处理:
-
如果vnode和oldVnode都有子节点,而且子节点不是同一个引用对象的话,就调用updateChildren更新子节点
-
如果只有vnode有子节点,就创建子节点(addVnodes)
-
如果只有oldVnode有子节点,就删除该子节点(removeVnodes)
-
如果oldVnode是文本节点,就直接删除DOM上的文本
-
-
如果vnode是文本节点,而且跟oldVnode文本内容不一样,则直接更新DOM上的文本
/**
* @param oldVnode 旧的虚拟DOM节点
* @param vnode 新的虚拟DOM节点
* @param insertedVnodeQueue 插入节点的队列
* @param ownerArray
* @param index
* @param removeOnly
*/
function patchVnode(
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// 当新旧节点是同一引用对象,则直接返回
if (oldVnode === vnode) {
return;
}
// 如果虚拟节点的elm属性存在的话,就说明有被渲染过了,如果ownerArray存在,说明存在子节点,如果这两点到成立,那就克隆一个vnode节点
if (isDef(vnode.elm) && isDef(ownerArray)) {
vnode = ownerArray[index] = cloneVNode(vnode);
}
const elm = (vnode.elm = oldVnode.elm);
// oldVnode是否存在异步占位符
if (isTrue(oldVnode.isAsyncPlaceholder)) {
// vnode是否存在异步工厂函数,主要是异步组件会使用到
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue);
} else {
vnode.isAsyncPlaceholder = true;
}
return;
}
// 处理静态节点
// oldVnode和vnode是静态节点,且key属性都相等,且vnode是克隆的虚拟DOM或者是带有v-once的组件
// 则更新componentInstance属性并且直接返回,说明整个组件没有发生变化
if (
isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance;
return;
}
let i;
const data = vnode.data;
// 当vnode是组件时,hook包含init, prepatch, insert , destroy四个钩子
// init 实例化子组件
// prepatch 更新子组件
// insert 调用子组件的 ’mounted‘生命周期,或者当’keepAlive‘存在的时候触发组件的activated生命周期
// destroy调用子组件的 ’destroyed‘生命周期,或者当’keepAlive‘存在的时候触发组件的deactivated生命周期
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode);
}
const oldCh = oldVnode.children;
const ch = vnode.children;
// 调用各种更新,updateAttrs、updateClass、updateDOMListeners、updateDOMProps、updateStyle、updateDrectives
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
}
if (isUndef(vnode.text)) {
// vnode不是文本组件
if (isDef(oldCh) && isDef(ch)) {
// oldVnode和vnode都存在子节点,而且两者的子节点不是同一个引用对象,则更新子节点
if (oldCh !== ch)
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
} else if (isDef(ch)) {
// 如果oldVnode不存在子节点,而vnode存在子节点
// 当oldVnode是文本节点时,先置空文本
// 然后创建子节点
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '');
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果oldVnode存在子节点,而vnode不存在子节点,则删除子节点
removeVnodes(oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 如果oldVnode是文本节点,则置空
nodeOps.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
// 如果vnode是文本节点,而且跟oldVnode文本内容不一样,则直接更新DOM上的文本
nodeOps.setTextContent(elm, vnode.text);
}
if (isDef(data)) {
// 调用postpatch 钩子
if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode);
}
}
updateChildren (Diff算法)
updateChildren应该是patch过程中最重要的一个方法,主要是对新旧虚拟DOM的子节点做对比,Diff算法就提现在这个过程中。对比的方法采用的是首尾指针法(或者叫双端比较法),
NewS、NewE、OldS、OldE分别代表新旧两个子节点数组的开始和结束索引,按照OldS和NewS、OldE和NewE、OldS和NewE、OldE和NewS进行两两比较,比较遵循以下几个原则:
- OldS和NewS同一类型节点(sameVnode),位置不变,OldS和NewS均+1
- OldE和NewE同一类型节点(sameVnode),位置不变,OldE和NewE均-1
- OldS和NewE同一类型节点(sameVnode),OldS移动到OldE之后,OldS +1, NewE -1
- OldE和NewS同一类型节点(sameVnode),OldE移动到OldS之前,NewS +1, OldE -1
- 不符合前面4种情况,则根据key生成OldS和OldE之间的index表,通过NewS指向节点的key在index表中查找该节点是否在OldS和OldE之间:若是,直接移动到OldS前,并把旧节点设置成undefined 。若不是,创建后,移动到OldS前,NewS +1
- 当旧的节点先遍历完(OldS > OldE), 则将[NewS, NewE] 之间的节点插入到真实的dom中(插入到NewS+1的节点之前)
- 当新的节点先遍历完(NewS > NewE), 则将[OldS, OldE] 之间的节点从真实的dom中移除
是否感觉一脸懵逼??莫慌,我们先通过几个图例来理解整个过程,最后再来看代码。
我们可以看到:
- 旧的子节点是 a b c
- 新的子节点是 a c b
- NewS、NewE、OldS、OldE分别代表新旧两个子节点数组的开始和结束位置索引
第一次比较:
我们可以看到OldS和NewS指向的是同一个类型节点a(使用规则1),节点的位置不需要移动,同时OldS和NewS均+1
第二次比较:
- OldS和NewS指向的不是同一类型节点(不适用规则1)
- OldE和NewE指向的不是同一类型节点(不适用规则2)
- OldS和NewE指向的是同一类型的节点b(适用规则3),我们把OldS移动到OldE之后,OldS +1,NewE -1
需要注意的是,我们移动节点位置,操作的是真实的DOM节点
第三次比较:
OldS和NewS指向的是同一类型节点c(适用规则1),节点的位置不需要移动,同时OldS和NewS均+1
此时,OldS大于OldE、NewS大于NewE,代表新旧子节点都检查完毕。
如果新子节点比旧子节点多的情况如何处理?
如上图,我们可以看到几个情况:
- 新的子节点比旧的子节点多了一个c节点
- 在新的子节点中,d和d的位置发生了变化
第一次比较:
我们可以看到OldS和NewS指向的是同一个类型节点a(使用规则1),节点的位置不需要移动,同时OldS和NewS均+1
第二次比较:
- OldS和NewS指向的不是同一类型节点(不适用规则1)
- OldE和NewE指向的不是同一类型节点(不适用规则2)
- OldS和NewE指向的是同一类型节点b(适用规则3),我们把OldS移动到OldE之后,OldS +1,NewE -1
第三次比较:
- OldS和NewS指向的不是同一类型节点(不适用规则1)
- OldE和NewE指向的是同一类型节点(适用规则2),节点位置不变,OldE和NewE均-1
此时,OldS大于OldE,说明旧的子节点已经遍历完毕,而NewS等于NewE,新的子节点还没有遍历完毕,新的子节点多于旧的子节点,需要将多的子节点插入到真实的DOM中。
这里需要注意的是,多余的子节点插入的位置。按规则6,需要插入到NewS+1的节点之前的位置。此时NewS指向的是c,+1后变成d,因此需要插入到d对应的真实DOM节点之前的位置。
旧的子节点多于新的子节点,就是只是直接把多余的节点移除,逻辑比较简单,就不单独举例了。
最后,我们来看一种比较复杂的情况:
第一次比较:
- OldS和NewS指向的不是同一类型节点(不适用规则1)
- OldE和NewE指向的不是同一类型节点(不适用规则2)
- OldS和NewE指向的不是同一类型节点(不适用规则3)
- OldE和NewS指向的是同一类型节点(适用规则4),OldE移动到OldS之前,NewS +1, OldE -1
第二次比较:
- OldS和NewS指向的不是同一类型节点(不适用规则1)
- OldE和NewE指向的不是同一类型节点(不适用规则2)
- OldS和NewE指向的不是同一类型节点(不适用规则3)
- OldE和NewS指向的不是同一类型节点(不适用规则4)
不符合前面4项规则,那我们只能通过规则5来判断操作,还记得规则5吗?
根据key生成OldS和OldE之间的index表,通过NewS指向节点的key在index表中查找该节点是否在OldS和OldE之间:若是,直接移动到OldS前,并把旧节点设置成undefined ,若不是,创建后,移动到OldS前。NewS +1
- 先根据key生成OldS和OldE之间的index表: {b:0, d:1, c:2}
- 通过NewS指向的节点的key是e,在index中表找不到,所以该节点不在在OldS和OldE之间
- 创建该节点的真实DOM,并插入到OldS指向的真实DOM之前,NewS +1
第三次比较:
- OldS和NewS指向的是同一类型节点(适用规则1),节点位置不变,OldS和NewS均+1
第四次比较: 和第二次比较同样处理,不在详细描述,直接上图
此时,NewS大于NewE,说明新的子节点已经遍历完了,而OldS小于OldE,说明有多余的旧的子节点需要移除。
到此为止,我们已经通过图例,大致了解了Vue2中关于虚拟DOM的Diff算法,我们再来看一下代码:
/**
* @param parentElm 父节点
* @param oldCh 旧 VNode 的子 VNode 数组
* @param newCh 新 VNode 的子 VNode 数组
* @param insertedVnodeQueue
* @param removeOnly
*/
function updateChildren(
parentElm,
oldCh,
newCh,
insertedVnodeQueue,
removeOnly
) {
let oldStartIdx = 0;
let newStartIdx = 0;
// 旧的子节点的最后一个索引
let oldEndIdx = oldCh.length - 1;
// 第一个旧的子节点
let oldStartVnode = oldCh[0];
// 最后一个旧的子节点
let oldEndVnode = oldCh[oldEndIdx];
// 新的子节点的最后一个索引
let newEndIdx = newCh.length - 1;
// 第一个新的子节点
let newStartVnode = newCh[0];
// 最后一个新的子节点
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, vnodeToMove, refElm;
const canMove = !removeOnly;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 判断oldStartVnode 和 oldEndVnode是否为 undefined,是因为最后一个 else 里的逻辑可能会将旧子节点设置为 undefined(规则5的其中一种处理情况)
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// oldStartVnode 和 newStartVnode 是同一个 VNode(规则1)
// 通过patchVnode做修补
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// oldEndVnode 和 newEndVnode 是同一个 VNode(规则2)
// 通过patchVnode做修补
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// oldStartVnode 和 newEndVnode 是同一个 VNode(规则3)
// 通过patchVnode做修补,然后移动节点位置
patchVnode(
oldStartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// oldEndVnode 和 newStartVnode 是同一个 VNode(规则4)
// 通过patchVnode做修补,然后移动节点位置
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 处理规则5的情况
// 根据 key 生成 OldS 和 OldE 之间的index表
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
// 查找新的子节点是否在 oldStartIdx 和 oldEndIdx 之间
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (isUndef(idxInOld)) {
// 新的子节点不存在,则在 oldStartVnode 对应的真实 DOM 节点之前,创建并插入新的真实 DOM 节点
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
} else {
// 新的子节点存在,则直接移动到 oldStartVnode 对应的真实 DOM 节点之前,并将旧节点设置成 undefined
// 这里再次判断了是否同一类型节点,以防 key 一样,但是节点类型不同
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(
vnodeToMove,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldCh[idxInOld] = undefined;
canMove &&
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx > oldEndIdx) {
// oldChildren 先遍历完,说明 newChildren 存在多余节点,添加这些新节点
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
refElm,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else if (newStartIdx > newEndIdx) {
// newChildren 先遍历完,说明 oldChildren 存在多余节点,直接删除掉
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}
Vue3中虚拟DOM Diff算法
patch
核心流程同样是在patch函数,我们来看一下内部的逻辑
// /packages/runtime-core/src/renderer.ts
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = false
) => {
// 新旧VNode是同一个对象,则不做处理直接返回
if (n1 === n2) {
return;
}
// 旧VNode存在,而且新旧VNode不是同一个类型,则卸载旧的VNode
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1);
unmount(n1, parentComponent, parentSuspense, true);
n1 = null;
}
// 如果新VNode的patchFlag是BAIL,则diff时不进行优化
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false;
n2.dynamicChildren = null;
}
const { type, ref, shapeFlag } = n2;
// 根据 VNode 类型进行不同的处理
switch (type) {
case Text: // 文本节点
processText(n1, n2, container, anchor);
break;
case Comment: // 注释节点
processCommentNode(n1, n2, container, anchor);
break;
case Static: // 静态节点
if (n1 == null) {
// 如果旧VNode不存在,则直接挂载新的节点
mountStaticNode(n2, container, anchor, isSVG);
} else if (__DEV__) {
patchStaticNode(n1, n2, container, isSVG);
}
break;
case Fragment: // // Fragment类型节点
processFragment(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) { // 元素节点
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else if (shapeFlag & ShapeFlags.COMPONENT) { // 组件类型节点
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else if (shapeFlag & ShapeFlags.TELEPORT) { // Teleport类型节点
(type as typeof TeleportImpl).process(
n1 as TeleportVNode,
n2 as TeleportVNode,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
internals
);
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { // Suspense类型节点
(type as typeof SuspenseImpl).process(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
internals
);
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`);
}
}
// set ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2);
}
};
isSameVNodeType
// /packages/runtime-core/src/vnode.ts
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
if (
__DEV__ &&
n2.shapeFlag & ShapeFlags.COMPONENT &&
hmrDirtyComponents.has(n2.type as ConcreteComponent)
) { // 开发模式下的逻辑,忽略
return false
}
// 当新旧VNode的类型和key均一致时,才判断为同一类型节点
return n1.type === n2.type && n1.key === n2.key
}
PatchFlags
Vue2在patch阶段时会对VNode进行全量diff,但是我们知道有的节点只声明了动态文本或者动态class/style,那就没有必要去做全量的diff。
因此vue3对这部分进行了优化,在生成 AST 树后,生成VNode时,就会根据节点的特点打上对应的patchFlag,从而实现对节点的靶向更新。
我们先看PatchFlags的定义:
// /packages/shared/src/patchFlags.ts
export const enum PatchFlags {
// 动态文本节点
// 十进制: 1
// 二进制: 0000 0000 0001
TEXT = 1,
// 动态 class
// 十进制: 2
// 二进制: 0000 0000 0010
CLASS = 1 << 1,
// 动态 style
// 十进制: 4
// 二进制: 0000 0000 0100
STYLE = 1 << 2,
// 动态属性,但不包含类名和样式。如果是组件,则可以包含类名和样式
// 十进制: 8
// 二进制: 0000 0000 1000
PROPS = 1 << 3,
// 具有动态 key 属性,当 key 改变时,需要进行完整的 diff 比较。
// 十进制: 16
// 二进制: 0000 0001 0000
FULL_PROPS = 1 << 4,
// 带有监听事件的节点
// 十进制: 32
// 二进制: 0000 0010 0000
HYDRATE_EVENTS = 1 << 5,
// 一个不会改变子节点顺序的 fragment
// 十进制: 64
// 二进制: 0000 0100 0000
STABLE_FRAGMENT = 1 << 6,
// 带有 key 属性的 fragment 或部分子字节有 key
// 十进制: 128
// 二进制: 0000 1000 0000
KEYED_FRAGMENT = 1 << 7,
// 子节点没有 key 的 fragment
// 十进制: 256
// 二进制: 0001 0000 0000
UNKEYED_FRAGMENT = 1 << 8,
// 一个节点只会进行非 props 比较
// 十进制: 512
// 二进制: 0010 0000 0000
NEED_PATCH = 1 << 9,
// 动态 slot
// 十进制: 1024
// 二进制: 0100 0000 0000
DYNAMIC_SLOTS = 1 << 10,
// 标识用户在模板的根级放置了*注释而创建的片段。这是一个仅限开发人员的标志,因为*注释在生产中是被剥离的。
// 十进制: 2048
// 二进制: 1000 0000 0000
DEV_ROOT_FRAGMENT = 1 << 11,
// 静态节点
HOISTED = -1,
// 指示在 diff 过程应该要退出优化模式
BAIL = -2,
}
patchFlag 的分为两大类:
- 当 patchFlag 的值大于 0 时,代表所对应的元素在 patchVNode 时或 render 时是可以被优化生成或更新的。
- 当 patchFlag 的值小于 0 时,代表所对应的元素在 patchVNode 时,是需要被 full diff,即进行递归遍历 VNode tree 的比较更新过程。
PatchFlags的具体使用,我们后面会结合具体代码来讲。
ShapeFlags
ShapeFlag 按字面翻译就是形状标记,它的作用是标记节点的形状(到底是怎么样的节点),如普通元素、函数组件、普通组件、keep alive 路由组件等等。从而帮助render 的时,可以根据不同 ShapeFlag 的枚举值来进行不同的 patch 操作。
// /packages/shared/src/shapeFlags.ts
export const enum ShapeFlags {
ELEMENT = 1, // 普通元素
FUNCTIONAL_COMPONENT = 1 << 1, // 函数组件
STATEFUL_COMPONENT = 1 << 2, // 有状态的组件
TEXT_CHILDREN = 1 << 3, // 子节点为文本元素
ARRAY_CHILDREN = 1 << 4, // 子节点为列表元素
SLOTS_CHILDREN = 1 << 5, // 子节点为插槽
TELEPORT = 1 << 6, // teleport组件
SUSPENSE = 1 << 7, // suspense组件
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 可以被keep alive的组件
COMPONENT_KEPT_ALIVE = 1 << 9, // 已经被keep alive的组件
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 组件
}
ShapeFlags的具体使用,我们一样放到后面会结合具体代码来讲。
processElement
processElement用来处理元素节点,逻辑很简单:如果不存在旧节点,则直接挂载新节点;如果新旧节点都存在,则进行patch操作。
// /packages/runtime-core/src/renderer.ts
const processElement = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
isSVG = isSVG || (n2.type as string) === 'svg'
if (n1 == null) { // 没有旧节点,直接挂载新节点
mountElement(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else { // 新旧节点都存在,进行patch操作
patchElement(
n1,
n2,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
patchElement
patchElement会更新元素的子节点,以及其本身的props、class、style等等
// /packages/runtime-core/src/renderer.ts
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
const el = (n2.el = n1.el!);
let { patchFlag, dynamicChildren, dirs } = n2;
// 如果n2没有patchFlag,则设置成FULL_PROPS
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS;
const oldProps = n1.props || EMPTY_OBJ;
const newProps = n2.props || EMPTY_OBJ;
let vnodeHook: VNodeHook | undefined | null;
// 触发VNode的onVnodeBeforeUpdate钩子
if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
}
// 触发指令的beforeUpdate钩子
if (dirs) {
invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate');
}
// 开发模式而且是热更新时,做全量 diff
if (__DEV__ && isHmrUpdating) {
patchFlag = 0;
optimized = false;
dynamicChildren = null;
}
const areChildrenSVG = isSVG && n2.type !== 'foreignObject';
if (dynamicChildren) {
// 靶向更新,只更新动态子节点
patchBlockChildren(
n1.dynamicChildren!,
dynamicChildren,
el,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds
);
if (__DEV__ && parentComponent && parentComponent.type.__hmrId) {
traverseStaticChildren(n1, n2);
}
} else if (!optimized) {
// 没有动态子节点,做全量 diff
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
);
}
// 对节点属性做patch
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
// FULL_PROPS表示属性包含动态变化的属性key,即属性名本身就是动态的,e.g. :[foo]="bar"
// 由于属性名本身具有动态不确定性,无法保证新旧属性的唯一对应关系,因此需要挂载新属性,同时卸载无效的旧属性
// 因此只能对新旧属性做全量diff来保证属性更新的准确性,无法做到属性靶向更新
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
);
} else {
// 动态class 绑定
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG);
}
}
// 动态style 绑定
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG);
}
// PROPS表示除了动态class、style以外的常规动态属性,这些属性在编译阶段被收集到dynamicProps中
// 在运行时只需要对dynamicProps中记录的属性进行靶向更新即可
if (patchFlag & PatchFlags.PROPS) {
// if the flag is present then dynamicProps must be non-null
const propsToUpdate = n2.dynamicProps!;
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i];
const prev = oldProps[key];
const next = newProps[key];
// #1471 force patch value
if (next !== prev || key === 'value') {
hostPatchProp(
el,
key,
prev,
next,
isSVG,
n1.children as VNode[],
parentComponent,
parentSuspense,
unmountChildren
);
}
}
}
}
// 动态的文本节点
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string);
}
}
} else if (!optimized && dynamicChildren == null) {
// 不做优化,而且没有动态子节点时, 做全量的 diff
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
);
}
if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
// 补丁渲染完成后触发生命周期钩子(nextTick中触发)
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated');
}, parentSuspense);
}
};
整个函数的主要处理流程:
- 判断新的VNode是否具有动态子节点:有,则只对动态子节点做patch;没有,则做全量的diff
- 当patch标记存在,则根据patchFlag对节点属性做不同的patch
- patchFlag 为 FULL_PROPS 时,元素中包含了动态的属性 key ,需要进行全量的 props diff。
- patchFlag 为 CLASS 时,且新旧节点的 class 不一致时,会对 class 进行 patch。
- patchFlag 为 STYLE 时,会对 style 进行更新。
- patchFlag 为 PROPS 时,元素拥有动态的props 或者 attrs,将新节点的动态属性提取出来,并遍历这个这个属性中所有的 key,当新旧属性不一致,或者该 key 需要强制更新时,则调用 hostPatchProp 对属性进行更新。
- patchFlag 为 TEXT 时,则表示元素的子节点是文本,如果新旧节点中的文本不一致,则调用 hostSetElementText 直接更新。
- 当patch不存在,同时不存在优化标记,且动态子节点也不存在,则直接对 props 进行全量 diff
patchBlockChildren
<div>
<span class="title">This is article list</span>
<ul>
<li>第一个静态的li</li>
<li v-for="article in article_list" :key="article.id"> {{ article.title }}</li>
</ul>
</div>
article_list数据内容:
article_list = [{
id: 1,
title: '第一篇文章'
}]
当article_list发生变化时,按vue2的patch过程,需要对全部节点做diff操作,包括span和第一个li。而这两个元素是静态,是不可能发生变化的。 因此在vue3里,会将动态子节点 (Block) 提取出来,只对动态子节点做更新,从而提高性能。
const result = {
type: Symbol(Fragment),
patchFlag: 64,
children: [
{ type: 'span', patchFlag: -1, ...},
{
type: 'ul',
patchFlag: 0,
children: [
{ type: 'li', patchFlag: -1, ...},
{
type: Symbol(Fragment),
children: [
{ type: 'li', patchFlag: 1 ...},
{ type: 'li', patchFlag: 1 ...}
]
}
]
}
],
dynamicChildren: [
{
type: Symbol(Fragment),
patchFlag: 128,
children: [
{ type: 'li', patchFlag: 1 ...},
{ type: 'li', patchFlag: 1 ...}
]
}
]
}
注意:dynamicChildren不仅收集直接动态子节点,还收集所有子代节点中的动态节点
我们来看一下patchBlockChildren的实现:
// /packages/runtime-core/src/renderer.ts
const patchBlockChildren: PatchBlockChildrenFn = (
oldChildren,
newChildren,
fallbackContainer,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds
) => {
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i];
const newVNode = newChildren[i];
// 在 patch 过程中,有几种情况是需要提供节点的真实父容器才能准确patch:
// 1. Fragment类型: Fragment非真实容器,其子节点还是依赖其外层的真实父容器
// 2. 新旧VNode非同一类型节点: 替换节点依赖于真实父容器
// 3. 组件VNode或者teleport组件VNode: 组件中有可能是任何内容,因此需要真实父容器
const container =
oldVNode.el &&
(oldVNode.type === Fragment ||
!isSameVNodeType(oldVNode, newVNode) ||
oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
? hostParentNode(oldVNode.el)!
: fallbackContainer;
patch(
oldVNode,
newVNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
true
);
}
};
patchChildren
前面我们已经介绍了patch过程的各种优化,但是无论如何,我们始终会有需要做全量Diff的场景,这个时候针对Diff算法的优化,就对Vue3性能的提升显得至关重要。
我们之前在讲Vue2的Diff算法的时候提到,key是作为判断是否是同一类型节点的判断因素之一,在Vue3中也是一样,作为VNode的唯一标识符,Vue3将节点key的情况分成两种:
- 全部未标记key属性的子节点序列
- 部分或者全部都标记了key属性的子节点序列
我们先来看一下patchChildren的实现:
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized = false
) => {
const c1 = n1 && n1.children;
const prevShapeFlag = n1 ? n1.shapeFlag : 0;
const c2 = n2.children;
const { patchFlag, shapeFlag } = n2;
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// 部分或者全部都标记了key属性的子节点序列
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
return;
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 全部未标记key属性的子节点序列
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
return;
}
}
// children有三种可能:
// 1. 文本节点
// 2. 数组节点
// 3. 空
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 当新的子节点是文本子节点时
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 如果旧的子节点是数组,先把旧的子节点全部卸载
unmountChildren(c1 as VNode[], parentComponent, parentSuspense);
}
if (c2 !== c1) {
// 将新的文本子节点直接更新
hostSetElementText(container, c2 as string);
}
} else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 当旧的子节点是数组时
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 如果新的子节点也是数组, 直接做全量的diff
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
// 如果新的子节点是空,则直接卸载旧的子节点
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true);
}
} else {
// 当旧的子节点是文本节点时,直接置空
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '');
}
// 当新的子节点是数组时,直接挂载
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
}
}
}
};
patchUnkeyedChildren
全部没有key属性标记的子节点diff很简单,直接按顺序对子节点进行patch,对于多余的节点进行挂载 或卸载操作
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
c1 = c1 || EMPTY_ARR;
c2 = c2 || EMPTY_ARR;
const oldLength = c1.length;
const newLength = c2.length;
const commonLength = Math.min(oldLength, newLength);
let i;
// 在新旧两个子节点的公共部分,按顺序进行patch
for (i = 0; i < commonLength; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]));
patch(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
}
if (oldLength > newLength) {
// 如果旧的子节点多,则卸载多余的旧的子节点
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength
);
} else {
// 如果新的子节点多,则挂载多余的新的子节点
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
commonLength
);
}
};
patchKeyedChildren
现在我们终于迎来最核心的部分,Vue3在Diff算法上有了本质的提升,还记得Vue2的Diff算法吗?不记得的话,可以往前回顾一下!
Vue3的Diff算法由预处理、贪心算法+二分查找、回溯几个部分组成,同时也引入的最长递增子序列的概念。 由于patchKeyedChildren函数比较庞大,我们在讲解每个部分时,只关注与其相关的代码。
预处理
- 从新旧子节点的首部按顺序向后遍历比较 (isSameVNodeType) ,如果是同一类型节点,则进行patch操作,如果不是,则立刻终止遍历
- 从新旧子节点的尾部按顺序向前遍历比较 (isSameVNodeType) ,如果是同一类型节点,则进行patch操作,如果不是,则立刻终止遍历
我们来看一下相关代码:
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // 旧的子节点尾部索引
let e2 = l2 - 1; // 新的子节点尾部索引
// 从头部向尾部遍历,遍历完新旧子节点中的任何一个后则终止遍历
// 遍历过程中,如果遇到同一类型节点(type和key都相等),则直接进行patch操作,否则立刻跳出遍历
// 此时,i记录的是跳出遍历是时,子序列的索引
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]));
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
break;
}
i++;
}
// 从尾部向头部遍历,只要e1和e2中有一个遇到i,则遍历停止
// 遍历过程中,如果遇到同一类型节点(type和key都相等),则直接进行patch操作,否则立刻跳出遍历
// 此时,e1和e2记录了新旧子节点最新的尾部索引位置
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]));
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
break;
}
e1--;
e2--;
}
// 暂时忽略无关代码
};
我们通过图片来加深一下了解:
前置预处理
后置预处理
首尾相遇处理
前后置预处理时,可能出现新旧子节点首尾相遇的情况,比如 i>e1 或者i>e2,这就以为有旧子节点需要卸载或者有新子节点需要挂载,我们看一下代码如何处理:
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // 旧的子节点尾部索引
let e2 = l2 - 1; // 新的子节点尾部索引
// 前置预处理
// 后置预处理
// 旧的子节点首尾指针相撞,新的子节点首尾指针未相撞,表示新的子节点首尾指针间
// 的节点是新增节点,需要挂载它们
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor;
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
i++;
}
}
}
// 旧的子节点首尾指针未相撞,新的子节点首尾指针相撞,表示旧的子节点首尾指针间
// 的节点是多余节点,需要卸载它们
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true);
i++;
}
} else {
// 存在未知序列需要处理
}
};
最长递增子序列
做完预处理后,剩下的是未知的子节点序列。那如何处理这部分节点呢?? 我们先来了解一个新概念: 最长递增子序列
维基百科:最长递增子序列(longest increasing subsequence)是指,在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。
const array = [10, 9, 2, 5, 3, 7, 101, 18]
// array的最长递增子序列是 [2, 5, 7, 101]、[2, 5, 7, 18]、[2, 3, 7, 101]、[2, 3, 7, 18],长度是4
那如何能在一个数值序列里,找出最长递增子序列呢?Vue3里采用的是贪心算法+二分查找
贪心算法:也叫做贪婪算法,在每一步做选择时,总是选择当前最优的方法。
我们来看一个例子:
贪心算法的规则是:如果当前数值大于已选结果的最后一位,则直接往后新增,若当前数值更小,则直接替换前面第一个大于它的数值
我们分解一下整个过程:
- 初始化时,将数组序列的第一个值10,放到result数组中,此时result是**[10]**
- 第一次遍历,数值序列的第二个值是9,比result的最后一个值 (10) 小,按规则,用9替换10,此时result是 [9]
- 第二次遍历,数值序列的第三个值是2,比result的最后一个值 (9) 小,按规则,用2替换9,此时result是 [2]
- 第三次遍历,数值序列的第四个值是5,比result的最后一个值 (2) 大,按规则,直接放入result,此时result是 [2, 5]
- 第四次遍历,数值序列的第五个值是3,比result的最后一个值 (5) 小,按规则,需要替换result第一个大于它的值,因此用3替换5,此时result是 [2, 3]
- 第五次遍历,数值序列的第六个值是7,比result的最后一个值 (3) 大,按规则,直接放入result,此时result是 [2, 3, 7]
- 第六次遍历,数值序列的第七个值是101,比result的最后一个值 (7) 大,按规则,直接放入result,此时result是 [2, 3, 7, 101]
- 第七次遍历,数值序列的第八个值是18,比result的最后一个值 (101) 小,按规则,需要替换result第一个大于它的值,因此用18替换101,此时result是 [2, 3, 7, 18]
但是该算法最终的结果未必是我们想要的,我们来看一个例子:
为什么会有这个问题呢?那是因为我们在过程中,只追求了每次遍历的最优解,而没有考虑到全局最优解。
接下去,我们结合Vue3的代码来讲解Diff的过程,以及它是如何解决这个问题的。
如上图,我们可以看到,经过首尾预处理后:
- i=2,e1=5,e2=5
- 节点F需要卸载,节点I需要新挂载
为新的子节点序列,创建一个存储key=>index关系的Map
这一步主要是为方便后面旧vnode可以通过key快速匹配到相同key的新vnode,并进行patch
// 存在未知序列需要处理
const s1 = i; // 旧的子节点序列头部指针
const s2 = i; // 新的子节点序列头部指针
// 遍历新的子节点序列,记录节点的key=>index键值对
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map();
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]));
if (nextChild.key != null) {
// 忽略调试模式代码
keyToNewIndexMap.set(nextChild.key, i);
}
}
此时的keyToNewIndexMap存储的数据是:
D => 2
E => 3
C => 4
I => 5
从头部遍历旧的子节点序列,通过key去判断旧VNode在新的子节点序列中是否存在。是,对节点进行patch; 否, 则卸载节点
let j;
let patched = 0; // 记录新序列中已经patch的节点个数
const toBePatched = e2 - s2 + 1; // 新序列中全部需要patch的节点总数
let moved = false; // 记录新VNode相对于旧VNode是否发生了位置移动
// 按顺序推进旧序列指针进行新旧节点patch时,记录旧节点对应新节点index
// 的峰值,如果相邻两个旧节点对应的新节点的相对位置不变,那么newIndex
// 应该是保持递增的,否则一旦newIndex变小,就说明相邻两旧节点对应的新
// 节点的相对位置发生了变化,那肯定发生了节点的移位操作
let maxNewIndexSoFar = 0;
// 用数组记录新旧节点index对应关系
// 用数组记录是为了后面创建最长递增子序列,从而用最少的次数把生需要移动的节点放到正确的位置
// 0 表示新节点没有相对应的旧节点,为了将旧节点index = 0和表示没有对应节点的 0 进行区分,因此对应的旧节点index均 +1
const newIndexToOldIndexMap = new Array(toBePatched);
// 初始化数组,每个子元素都是0
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;
// 开始遍历老节点
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) {
// 当旧节点找到对应的新节点时,会执行patch,同时patched计数加1
// 当patched >= toBePatched时,说明新的子节点序列已经全部patch完毕
// 剩余的旧节点直接卸载
unmount(prevChild, parentComponent, parentSuspense, true);
continue;
}
let newIndex;
if (prevChild.key != null) {
// 如果旧节点带有key,则通过key找到拥有相同key的新节点的index
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
// 如果旧节点没有key,则在新的子节点序列里找出一个同样不带key的相似的新节点(type相等)
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as 节点)
) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) {
// newIndex等于undefined,说明未找到与旧节点对应的新节点,则直接卸载旧节点
unmount(prevChild, parentComponent, parentSuspense, true);
} else {
// 把老节点的索引,记录在存放新节点的数组中(加1)
newIndexToOldIndexMap[newIndex - s2] = i + 1;
// maxNewIndexSoFar记录与当前旧节点对应新节点的index最大值
// newIndex递增,说明相邻旧节点对应的新节点相对位置没变化,无需移动节点
// 一旦maxNewIndexSoFar变小,说明相邻节点相对位置发生变化,新的子节点序列一定发生移动行为
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
// patch 新旧节点
patch(
prevChild,
c2[newIndex] as 节点,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
// patched计数 +1
patched++;
}
}
此时newIndexToOldIndexMap的存储的新旧节点index的对应关系为:
- I 是新增节点,所以是0
- F 节点在新的子节点序列中未找到,所以直接卸载
- C 在旧的子节点序列的index是2,加1后成3(D,E类似)
挂载新增的节点和将其他节点移动到正确的位置
在这个环节,Vue3通过最长递增子序列在newIndexToOldIndexMap中找到尽可能多的index递增的旧节点。因为我们是参考旧节点之间的顺序来对新节点进行移动,因此找到最多位置不便的旧节点,那新节点需要移动次数也就最少。
我们套用之前介绍的算法,来求得newIndexToOldIndexMap最长递增子序列试试。 是不是感觉哪里不对?求出来的结果是 [0, 3] ,按我们的设想应该是 [4, 5] 才对,因为D和E才是位置没有发生变化的两个节点。
我们来看一下最长递增子序列的算法:
function getSequence(arr: number[]): number[] {
// 遍历 arr 的时候,每操作一次 result 进行 push 或者替换值时
// 都把 result 被操作索引的前一项放到 p
// 最后进行回溯时,通过最后一项,就可以通过p这个数组找到它的前一项
const p = arr.slice();
const result = [0]; // 记录最长增长子序列的索引的数组
/**
* 假设arr是 [2,4,5,3],此时p也是 [2,4,5,3]
* result是 [0]
*/
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
// 0 是特殊占位符,表示新增节点,不参与计算
if (arrI !== 0) {
// j 是子序列索引最后一项,将 j 在原数据 arr 对应的值和当前值 arrI 做比较
// 如果 arrI 大,将 arrI 在 p 中的对应位置的值设置成 result 数组的最后一项
//(通过 push 进去的这一项(索引 i) 在 p 中对应的位置存就是它的前一项)
// 并且将arrI 对应的索引 i push 到 result
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
// 如果 arrI 小,通过二分查找,在result中找到第一个大于它的值,并进行替换
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
// 逻辑和上面的 p[i] = j 一样 都是记录前一项
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
/**
* 第一次遍历
* p = [2,4,5,3] result = [0]
* 第二次遍历
* p = [2,0,5,3] result = [0,1]
* 第三次遍历
* p = [2,0,1,3] result = [0,1,2]
* 第四次遍历
* p = [2,0,1,0] result = [0,3,2]
*/
u = result.length;
v = result[u - 1];
/**
* u = 3, v = 2
*/
// 进行回溯修补
while (u-- > 0) {
result[u] = v;
// 最后一项在p中记录了它的前一项 所以取出前一项放在result
v = p[v];
/** 第一次遍历
* result = [0,3,2] v = 1
* 第二次遍历
* result = [0,1,2] v = 0
* 第三次遍历
* result = [0,1,2] v = 2
*/
}
return result;
}
这里需要注意一点,getSequence获取的是最长递增子序列的索引的数组
我们在上一步获得的newIndexToOldIndexMap的数据是 [4, 5, 3, 0] 我们按步骤来拆解真个过程:
先做遍历 这个时候的结果,明显并不是我们想要的。 D和E的节点的位置没有发生变化,所以result应该是[0, 1]才符合预期
回溯 我们通过回溯来进行修正
我们最终获得最长递增子序列的索引是 [0, 1]
最后我们来看一下通过这个索引来如何挂载和移动节点:
// 获取newIndexToOldIndexMap最长递增子序列的索引
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR;
j = increasingNewIndexSequence.length - 1;
// 从新序列尾部向前遍历是为了将后面patch完的节点作为dom操作的定位锚点
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex] as VNode;
// 获取定位锚点,以后面一个节点的dom元素为锚点,如果已经是末尾节点,那么
// 锚点就是外部传入的锚点
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor;
if (newIndexToOldIndexMap[i] === 0) {
// 如果索引对应的值是0,则说明是新节点,需要挂载
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else if (moved) {
// 何时移动节点:
// 1. 节点序列反序 (无最长稳定子序列)
// 2. 当前新节点index和当前稳定子序列index不相同,说明是相对位置发生变化的节点
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 以定位锚点为插入位置进行新节点的dom移动
move(nextChild, container, anchor, MoveType.REORDER);
} else {
// index相同说明当前新节点无需移动位置,因为最长稳定子序列中的index表示
// 该index对应新节点未发生相对位置变化
j--;
}
}
}
第一次遍历
- nextIndex是5,对应节点 I
- 以 H 作为锚点
- newIndexToOldIndexMap[3]是0,说明 I 是新增节点,需要挂载
第二次遍历
- nextIndex是4,对应节点 C
- 以 I 作为锚点
- i 是2,不在最长子序列的索引数组[0, 1],说明需要移动
- 以 I 为锚点,移动到 I 之前
第三、四次遍历
- 第三和第四次遍历时,i 分别是 1 和 0,都在最长子序列的索引数组[0, 1]中,不需要移动
总结
至此,Vue3的虚拟Dom的Diff算法整理完毕,整个逻辑比较复杂,我也是花了2天时间,才算是大致理清。其中肯定会有理解不透彻的地方,还望大家留言指正。