patch更新时候,如果新旧节点都是数组的情况,当时采用把旧节点全部清除,然后完全重新添加新节点,这样可以实现目标,但是性能不佳。本章开始介绍vue的渲染器核心diff算法,简单来说就是新旧vnode节点的子节点都是一组节点的情况下,以最小性能开销完成更新操作。
9.1减少DOM操作的性能开销
有如下结构的新旧虚拟节点:
// 旧vnodeconst oldVnode = {type: "div",children: [{type: "p", children:1},{type: "p", children:2},{type: "p", children:3}]}// 新vnodeconst newVnode = {type: "div",children: [{type: "p", children:4},{type: "p", children:5},{type: "p", children:6}]}
按照第8章的做法:
- 卸载所有旧节点,需要3次DOM删除操作
- 挂载所有新的子节点,需要3次DOM添加操作
这样完成目标需要6次操作DOM,性能不佳。通过观察可以发现,新旧node的子节点,之后children内容不同,可以通过算法实现只更新内容,这样就可以只需操作3次DOM就能达到目的,性能会提升一倍。
重新调整patchChildren函数
function patchChildren(n1, n2, container) {if(typeof n2.children === "string"){// ...} else if(Array.isArray(n2.children)){// 重新实现两组子节点的更新方式const oldChildren = n1.children;const newChildren = n2.children;// 遍历旧的childrenfor(let i = 0; i< oldChildren.length; i++){//调用patch函数更新子节点patch(oldChildren[i], newChildren[i])}}else{//...}}
oldChildren和newChildren分别代表旧的子节点和新的子节点,并将2组对应位置的节点分别传递给patch函数进行更新。patch函数在执行更新时,发现新旧子节点只有文本内容不同,只会更新文本节点内容。
用示意图展示更新过程,菱形表示新子节点,矩形表示旧的子节点,圆形表示真实的DOM节点。
这种做法减少了操作DOM的次数,但是当新旧子节点数量不同,会存在问题。新的子节点多,无法正常添加新节点,新的节点少,无法删除旧的子节点。
通过分析,在新旧两组子节点更新是,不能固定变量新的子节点或旧的子节点,而是应该遍历长度短的那一组,然后在接着比对新旧2组子节点的长度,如果新的子节点更长,说明有新节点要添加,否则就是旧的子节点要卸载。
function patchChildren(n1, n2, container) {if (typeof n2.children === 'string') {if (Array.isArray(n1.children)) {n1.children.forEach((c) => unmount(c))}setElementText(container, n2.children)} else if (Array.isArray(n2.children)) {const oldChildren = n1.childrenconst newChildren = n2.childrenconst oldLen = oldChildren.lengthconst newLen = newChildren.lengthconst commonLength = Math.min(oldLen, newLen)for (let i = 0; i < commonLength; i++) {patch(oldChildren[i], newChildren[i])}// 如果 nextLen > prevLen,将多出来的元素添加if (newLen > oldLen) {for (let i = commonLength; i < newLen; i++) {patch(null, newChildren[i], container)}} else if (oldLen > newLen) {// 如果 prevLen > nextLen,将多出来的元素移除for (let i = commonLength; i < oldLen; i++) {unmount(oldChildren[i])}}} else {if (Array.isArray(n1.children)) {n1.children.forEach(c => unmount(c))} else if (typeof n1.children === 'string') {setElementText(container, '')}}}
9.2DOM复用和Key的作用
diff算法的核心就是减少操作DOM的次数,提升性能。
使用上节的方式,仍存在优化空间,有如下新旧2组子节点
// oldChildren[{type:"p"}, {type:"div"}, {type:"span"}]// newChildren[{type:"span"}, {type:"p"}, {type:"div"}]
如果使用上节的patchChildren算法,仍需要6次DOM操作。
但其实通过观察可以发现,新旧子节点只是顺序不同,最好的处理方式是,移动DOM来完成子节点的更新。
接下来就该处理如何确定新的子节点是否出现在旧的一组子节点中。仅仅通过判断type类型可以吗?其实这是不行的,如果子节点的类型都是p,那么就完全相同,是否就不需要移动更新呢,这样会出现bug。
// oldchildren[{ type: 'p', children: '1', key: 1 },{ type: 'p', children: '2', key: 2 },{ type: 'p', children: 'hello', key: 3 }]// newChildren[{ type: 'p', children: 'world', key: 3 },{ type: 'p', children: '1', key: 1 },{ type: 'p', children: '2', key: 2 }]
这时就需要使用到key属性,key是节点的身份标识,通过key可以对比新旧子节点是否相同。
通过key属性可以明确知道新子节点在旧子节点中的位置,就可以进行对应的DOM移动操作。
function patchChildren(n1, n2, container) {if (typeof n2.children === 'string') {if (Array.isArray(n1.children)) {n1.children.forEach((c) => unmount(c))}setElementText(container, n2.children)} else if (Array.isArray(n2.children)) {const oldChildren = n1.childrenconst newChildren = n2.children// 遍历新的 childrenfor (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i]let j = 0// 遍历旧的 childrenfor (j; j < oldChildren.length; j++) {const oldVNode = oldChildren[j]// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新// 节点相同,内容或属性可能已经发生变化if (newVNode.key === oldVNode.key) {patch(oldVNode, newVNode, container)break // 这里需要 break}}}} else {if (Array.isArray(n1.children)) {n1.children.forEach(c => unmount(c))} else if (typeof n1.children === 'string') {setElementText(container, '')}}}
第19行通过对比节点的key是否相同,来判断节点是否是同一个节点。如果是同一个节点,执行patch打补丁操作后直接跳出循环。
在代码中使用了双层for循环,外层循环用于遍历新的一组子节点,内层循环则遍历旧的一组子节点。找到相同节点则进行patch打补丁操作。经过这一步操作能够保证所有可复用的节点本身都更新完毕。
执行完毕后,key为3的元素仍显示在最后,下一步就需要找出需要移动的DOM元素,进行移动。
注意⚠️:这里的双层for循环,性能是极差的,在实际项目中无法使用。
9.3找出需要移动的元素
通过key对比出相同节点可以复用,但是没有进行移动。要想进行移动,第一步需要找出要移动的节点,第二步进行移动操作。
本节目标是找出需要移动的节点元素。
什么情况下需要移动操作呢,肯定是新旧子节点元素的顺序发生了变化。
上图中新旧2组子节点的顺序没变化,图中也标注了旧的子节点的索引。
- key为1的节点,在旧children数组中索引为0
- key为2的节点,在旧children数组中索引为1
- key为3的节点,在旧children数组中索引为2
新子节点找在旧子节点中找到可以复用的元素,这些复用节点在旧的一组子节点中的位置索引可以得到一个序列: 0 1 2,这是一个递增的序列,这种情况下不需要移动任何节点。
然后按照上节的更新,调整新节点的位置,结果如下图显示
- 新子节点的第一个节点为p-3,它的key为3,在旧的子节点中可以查找到相同的key,说明可以复用,该节点在旧的子节点中的索引值为2.
- 新子节点的第二个节点为p-1,它的key为1,可以在旧的子节点中找到对应key,可以复用,索引为0;
- 新子节点的第二个节点为p-2,它的key为2,可以在旧的子节点中找到对应key,可以复用,索引为1;
这时可以发现索引值递增的顺序被打乱了,现在的序列为2、0、1;新p-1节点对应的索引小于新p-3节点对应的索引,【索引小本来应排在前】但是排在p-3的后边,说明p-1节点需要移动。同理,p-2节点的索引也小于p-3节点的索引,但仍排在后边,说明p-2节点需要移动。
在旧的children中查找具有相同key值节点的过程中,遇到的最大索引值,如果后续寻找的过程中,存在索引值比当前遇到的最大索引值还要小的节点,说明该节点需要移动。
可以使用lastIndex变量存储整个寻找过程中遇到的最大索引值。
function patchChildren(n1, n2, container) {if (typeof n2.children === 'string') {// ...} else if (Array.isArray(n2.children)) {const oldChildren = n1.childrenconst newChildren = n2.children// 用lastIndex存储寻找过程中遇到的最大索引值let lastIndex = 0;for (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i]let j = 0// 遍历旧的 childrenfor (j; j < oldChildren.length; j++) {const oldVNode = oldChildren[j]// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新// 节点相同,内容或属性可能已经发生变化if (newVNode.key === oldVNode.key) {patch(oldVNode, newVNode, container)if(j < lastIndex){// 如果当前找到的可复用节点,在旧的children中的索引小于最大索引值lastIndex// 说明该节点需要移动,怎么移动,下节在分析}else{// 如果当前找到的节点,在旧children中的索引 不小于 最大索引值,则更新lastINdexlastIndex = j;}break // 这里需要 break}}}} else {// ...}}
- 代码第18行,如果新节子节点的key相同,说明在旧的children中找到了可以复用的DOM节点
- 用可复用的节点在旧的children中的索引
j与lastIndex进行比较,如果j小于lastIndex,说明节点需要移动。否则不需要移动,不移动的同时要将该变量j的值赋值给变量lastIndex,保证lastIndex始终存储着当前遇到的最大索引值。
9.4 如何移动元素
上节找到了需要移动的节点元素p-1和p-2,接下来就要移动他们。移动节点指的是移动虚拟节点对应的真实DOM节点。要移动真实的DOM节点,就需要获取到对它的引用。真实DOM节点都是保存在虚拟节点的vnode.el属性中
在更新操作时,渲染器会调用patchElement函数在新旧虚拟节点之间进行打补丁。
function patchElement(n1, n2){// 新的vnode n2 引用了真实的DOM元素const el = n2.el = n1.el;// ...}

patchElement函数首先将旧节点的n1.el属性赋值给新节点的n2.el属性,其实就是DOM元素的复用。复用之后,新节点也将持有对真实DOM的引用。
添加上新节节点之间的关系
更新步骤如下:
- 新子节点的第一个节点p-3,key为3,通过遍历查询可以找到相同的key值,说明节点可以复用。并且该节点在旧的子节点中的索引为2,原lastIndex值为0,小于2,说明DOM节点不需要移动,并更新lastIndex值为2
- 新子节点的第二个节点p-1,key为1,从旧节点中查询出可以复用节点,并且该节点在旧的子节点中索引为0,此时lastIndex为2,索引0小于2,说明节点p-1需要移动。
- 节点p-1对应的真实DOM需要移动。新children的顺序就是更新后真实DOM节点应该的顺序,所以p-1节点在新children的位置就代表了真实DOM更新后的位置。p-1节点排在p-3节点的后边,所以应该把p-1节点所对应的真实DOM移动到节点p-3所对应的真实DOM后面,移动后DOM顺序 p-2、p-3、p-1
- 新子节点的第三个阶段p-2,key为2,也是可以复用的节点,发现该节点在旧子节点中的索引为1,此时lastIndex的为为2,索引1小于2,所以节点p-2对应的真实DOM需要移动。
- 节点p-2在新children中排在节点p-1后面,所以应该把p-2对应的真实DOM移动到p-1对应的真实DOM后。

经过移动操作,此时真实DOM的顺序与新的一组子节点的顺序相同,p-3、p-1、p-2。
最后用代码实现移动的过程。
function patchChildren(n1, n2, children){if(typeof n2.children === "string"){//...}else if(Array.isArray(n2.children)){const oldChildren = n1.children;const newChildren = n2.children;let lastIndex = 0;for(let i=0; i<newChildren.length; i++){const newVnode = newChildren[i];let j=0;for(j; j< oldChildren.length; j++){const oldeVnode = oldChildren[j];if(newVnode.key === oldVnode.key){patch(oldVnode, newVnode, container);//先进行patch,然后在进行移动if(j<lastIndex){ //需要移动操作// 进入这个判断,说明newVnode对应的真实DOM需要移动// 先获取newVnode的前一个vnode,定义为preVnodeconst prevVnode = newChildren[i-1];// 如果prevVnode不存在,说明当前的newVnode是第一个节点,不需要移动if(prevVnode){// 将newVnode对应的真实DOM移动到prevVnode所对应的真实DOM后边// 需要获取prevVnode所对应真实DOM的下一个兄弟节点,并将其设为锚点cosnt anchor = prevVnode.el.nextSibling;// 调用insert方法,将newVnode对应的真实DOM 插入到锚点元素前面insert(newVnode.el, container, anchor);}}else{lastIndex = j;}break;}}}}}
更新渲染器的参数方法,新增insert方法;insert函数依赖浏览器原生的insertBefore函数。
const renderer = createRenderer({// ...insert(el, parent, anchor=null){// insertBefore需要锚点元素 anchorparent.insertBefore(el, anchor);}})
9.5添加新元素
如果在新子节点元素中新增了节点,多个p-4,它的key为4,该节点在旧的子节点中不存在,所以在更新时应该正确的将它挂载,主要分为2步:
- 找到新增节点
- 将新增节点挂载到正确位置

开始模拟执行简单的Diff算法:
- 新的子节点中第一个节点p-3,key为3,可以在旧的子节点中找到可复用节点,索引值为2。此时lastIndex的值为0,索引2不小于0,所以p-3不需要移动,需要将lastIndex的值更新为2;
- 新的子节点中第2个节点p-1,key为1,可以在旧的子节点中找到可复用节点,索引值为0。此时lastIndex的值为2,索引0小于2,所以p-1需要移动,将节点p-1移动到p-3后边,经过这步的移动,此时真实DOM顺序为p-2、p-3、p-1;

- 新的子节点中的第3个节点p-4,它的key为4,尝试在旧子节点中查询该节点,找不到可复用的节点,因此渲染器会把p-4看作新增节点并挂载它。节点p-4在新的一组子节点中的位置在p-1后边,所以应该把节点p-4挂载到p-1对应的真实DOM后边。 此时真实DOM顺序为p-2、p-3、p-1、p-4。

- 新的子节点中第4个节点p-2,key为2,可以在旧的子节点中找到可复用节点,索引值为1。此时lastIndex的值为2,索引1小于2,所以p-2需要移动,将节点p-2移动到p-1后边,经过这步的移动,此时真实DOM顺序为p-3、p-1、p-4、p-2;至此真实DOM顺序已经和新的一组子节点顺序相同。

接下来通过代码实现节点的移动和挂载。
在外层循环中定义了名为find的变量,代表渲染器能否在旧的一组子节点中找到可复用的节点。find的初始值为false,一旦找到可复用节点,find更新为true。如果内层循环结束后,find变量仍为false时,说明newVnode是全新的节点,需要挂载它,为了将节点挂载到正确的位置,需要先获取锚点元素,找到newVnode的前一个虚拟节点,即prevVnode。如果存在,则使用它对应的真实DOM的下一个兄弟节点作为锚点元素,如果不存在,则说明将挂载的newVnode节点是容器元素的第一个子节点,此时使用容器元素的container.firstChild作为锚点元素。最后将锚点元素anchor作为patch函数的第4个参数,调用patch函数完成节点的挂载。
function patchChildren(n1, n2, container) {if (typeof n2.children === 'string') {//...} else if (Array.isArray(n2.children)) {const oldChildren = n1.childrenconst newChildren = n2.childrenlet lastIndex = 0// 遍历新的 childrenfor (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i]let j = 0let find = false// 遍历旧的 childrenfor (j; j < oldChildren.length; j++) {const oldVNode = oldChildren[j]// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新if (newVNode.key === oldVNode.key) {find = truepatch(oldVNode, newVNode, container)if (j < lastIndex) {// 需要移动const prevVNode = newChildren[i - 1]if (prevVNode) {const anchor = prevVNode.el.nextSiblinginsert(newVNode.el, container, anchor)}} else {// 更新 lastIndexlastIndex = j}break // 这里需要 break}}// 在旧子节点中未找到,说明是新增的节点if (!find) {//为了将节点挂载到正确位置,需要先获取锚点元素。// 首先获取当前newVnode的前一个vnode节点。const prevVNode = newChildren[i - 1]let anchor = nullif (prevVNode) {// 如果有前一个vnode节点,则使用它的下一个兄弟节点作为锚点元素。anchor = prevVNode.el.nextSibling} else {// 如果没有前一个vnode节点,说明即将挂载的新节点是第一个子节点// 需要使用容器元素 container.firstChild作为锚点anchor = container.firstChild}patch(null, newVNode, container, anchor)}}} else {// ...}}
更新patch函数,。让其支持第4个参数。
function patch(n1, n2, container, anchor) {if (n1 && n1.type !== n2.type) {unmount(n1)n1 = null}const { type } = n2if (typeof type === 'string') {if (!n1) {mountElement(n2, container, anchor)} else {patchElement(n1, n2)}} else if (type === Text) {if (!n1) {const el = n2.el = createText(n2.children)insert(el, container)} else {const el = n2.el = n1.elif (n2.children !== n1.children) {setText(el, n2.children)}}} else if (type === Fragment) {if (!n1) {n2.children.forEach(c => patch(null, c, container))} else {patchChildren(n1, n2, container)}}}
9.6删除元素
更新子节点时,不仅会遇到新增元素,还会出现元素删除的情况。
在新的一组子节点中p-2节点已经不存在,说明该节点被删除。
模拟渲染器执行更新的过程
- 新的子节点中第一个节点p-3,key为3,可以在旧的子节点中找到可复用节点,索引值为2。此时lastIndex的值为0,索引2不小于0,所以p-3不需要移动,需要将lastIndex的值更新为2;
- 新的子节点中第2个节点p-1,key为1,可以在旧的子节点中找到可复用节点,索引值为0。此时lastIndex的值为2,索引0小于2,所以p-1需要移动,将节点p-1移动到p-3后边,经过这步的移动,此时真实DOM顺序为p-2、p-3、p-1;

此时已经更新结束,但是可以发现旧子节点中p-2仍然存在,所以需要增加额外的逻辑来删除遗留的节点。
当基本更新结束后,需要遍历一遍旧的子节点,然后去新的一组子节点中寻找具有相同key值的节点。如果找不到,则说明需要删除该节点。更新patchChild函数
function patchChildren(n1, n2, container){if(typeof n2.children === "string"){//...}else if(Array.isArray(n2.children)){const oldChildren = n1.children;const newChildren = n2.children;let lastIndex= 0;for(let i = 0; i<newChildren.length; i++){//..}//遍历旧的一组子节点for(let i=0;i<oldChildren.length; i++){const oldVnode = oldChildren[i];// 用旧子节点 oldVnode去新的一组子节点中寻找具有相同key值的节点const has = newChildren.find(vnode => vnode.key === oldVnode.key);// 如果新的子节点中不存在if(!has){// 在新的子节点中不存在,说明需要删除该节点,调用unmount函数将节点卸载unmount(oldVnode);}}}}
