前言: diff算法 可以看作是一种对比算法,对比的对象是新旧虚拟Dom 。顾名思义,diff算法 可以找到新旧虚拟Dom 之间的差异,但diff算法 中其实并不是只有对比虚拟DOM ,还有根据对比后的结果更新真实Dom
虚拟DOM 在进行进一步了解之前,我们需要先明确虚拟DOM 的概念:虚拟DOM就是一个用来描述真实DOM的 JS对象 ,它有6个属性:
sel:当前节点标签名 data:节点内的所有属性 childen:当前节点的子节点 elm:虚拟节点对应的真实节点 key:当前节点的唯一标识 text:当前节点的文本 结构类似这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 let vnode = { sel : 'ul' , data : {}, children : [ { sel : 'li' , data : { class : 'item' }, text : 'son1' }, { sel : 'li' , data : { class : 'item' }, text : 'son2' }, ], elm : undefined , key : undefined , text : undefined }
那么虚拟Dom有什么用呢。我们其实可以把虚拟Dom理解成对应真实Dom的一种状态。当真实Dom发生变化后,虚拟Dom可以为我们提供这个真实Dom变化之前和变化之后的状态,我们通过对比这两个状态,即可得出真实Dom真正需要更新的部分,即可实现最小量 更新。在一些比较复杂的Dom变化场景中,通过对比虚拟Dom后更新真实Dom会比直接更新真实Dom的效率高,这也就是虚拟Dom和diff算法真正存在的意义。
h函数 要生成虚拟Dom,我们可以使用 h函数 ,就是render函数里面传入的那个h函数 。它可以接受多种类型的参数,但其实它内部只干了一件事,就是执行vnode函数
。根据传入h函数 的参数来决定执行vnode函数
时传入的参数。那么vnode函数
又是干什么的呢?vnode函数
其实也只干了一件事,就是把传入h函数 的参数转化为一个对象,即虚拟Dom。
1 2 3 4 5 export default function (sel, data, children, text, elm ) { const key = data.key return {sel, data, children, text, elm, key} }
执行h函数 后,内部会通过vnode函数
生成虚拟Dom,然后h函数 返回这个虚拟Dom
diff对比规则 明确了h函数是干什么的 ,我们可以简单用h函数 生成两个不同的虚拟节点,我们将通过一个简易版的diff算法代码介绍diff对比的具体流程。
1 2 3 4 5 6 7 8 9 10 const myVnode1 = h ("h1" , {}, [ h ("p" , {key : "a" }, "a" ), h ("p" , {key : "b" }, "b" ), ]); const myVnode2 = h ("h1" , {}, [ h ("p" , {key : "c" }, "c" ), h ("p" , {key : "d" }, "d" ), ]);
patch 比较的第一步就是执行patch ,它相当于对比的入口。既然是对比两个虚拟Dom,那么就将两个虚拟Dom作为参数传入patch 中。patch 的主要作用是对比两个虚拟Dom的根节点,并根据对比结果操作真实Dom。
patch函数的核心代码如下,注意注释。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import vnode from "./vnode" import patchDetails from "./patchVnode" import createEle from "./createEle" export function patch (oldVnode, newVnode ) { if (!oldVnode.sel ) { oldVnode = vnode (oldVnode.tagName .toLowerCase (), {}, [], undefined , oldVnode) } if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel ) { console .log ('是同一个节点' ) patchDetails (oldVnode, newVnode) }else { console .log ('不是同一个节点' ) const newNode = createEle (newVnode) oldVnode.elm .parentNode .insertBefore (newNode, oldVnode.elm ) oldVnode.elm .parentNode .removeChild (oldVnode.elm ) } }
createEle:创建真实dom
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 export default function createEle (vnode ) { const realNode = document .createElement (vnode.sel ) if (vnode.text && (vnode.children == undefined || (vnode.children && vnode.children .length == 0 )) ) { realNode.innerText = vnode.text }else if (Array .isArray (vnode.children ) && vnode.children .length > 0 ) { for (let i = 0 ; i < vnode.children .length ; i++) { const childNode = createEle (vnode.children [i]) realNode.appendChild (childNode) } } vnode.elm = realNode return vnode.elm }
patchVnode patchVnode 用来比较两个虚拟节点的子节点并更新其子节点对应的真实Dom节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 import updateChildren from "./updateChildren" import createEle from "./createEle" export function patchDetails (oldVnode, newVnode ) { if (oldVnode == newVnode) return if (hasText (newVnode)) { if (newVnode.text !== oldVnode.text ) { oldVnode.elm .innerText = newVnode.text } }else if (hasChildren (newVnode)) { if (hasText (oldVnode)) { oldVnode.elm .innerText = '' for (let i = 0 ; i < newVnode.children .length ; i++) { oldVnode.elm .appendChild (createEle (newVnode.children [i])) } }else if (hasChildren (oldVnode)) { updateChildren (oldVnode.children , newVnode.children , oldVnode.elm ) } } } function hasChildren (node ) { return !node.text && (node.children && node.children .length > 0 ) } function hasText (node ) { return node.text && (node.children == undefined || (node.children && node.children .length == 0 )) }
updateChildren 该方法是diff算法中最复杂的方法(大的要来了)。对应上面patchVnode中oldVnode和newVnode都有children的情况。
首先我们需要介绍一下这里的对比规则。
对比过程中会引入四个指针,分别指向oldVnode子节点列表中的第一个节点和最后一个节点(后面我们简称为旧前和旧后)以及指向newVnode子节点列表中的第一个节点和最后一个节点(后面我们简称为新前和新后)
对比时,每一次对比按照以下顺序进行命中查找
旧前与新前节点对比(1) 旧后与新后节点对比(2) 旧前与新后节点对比(3) 旧后与新前节点对比(4) 上述四种情况,如果某一种情况两个指针对应的虚拟Dom相同,那么我们称之为命中。命中后就不会接着查找了,指针会移动,(还有可能会操作真实Dom,3或者4命中时会操作真实Dom移动节点)之后开始下一次对比。如果都没有命中,则去oldVnode子节点列表循环查找当前新前指针所指向的节点,如果查到了,那么操作真实Dom移动节点,没查到则新增真实Dom节点插入。
这种模式的对比会一直进行,直到满足了终止条件。即旧前指针移动到了旧后指针的后面或者新前指针移动到了新后指针的后面,我们可以理解为旧子节点先处理完毕和新子节点处理完毕。那么我们可以预想到新旧子节点中总会有其一先处理完,对比结束后,我们会根据没有处理完子节点的那一对前后指针决定是要插入真实Dom还是删除真实Dom。
如果旧子节点先处理完了,新子节点有剩余,说明有要新增的节点。将根据最终新前和新后之间的虚拟节点执行插入操作 如果新子节点先处理完了,旧子节点有剩余,说明有要删除的节点。将根据最终旧前和旧后之间的虚拟节点执行删除操作 下面将呈现代码,注意注释
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 import patchDetails from "./patchVnode" import createEle from "./createEle" ;export default function updateChildren (oldCh, newCh, parent ) { let oldStartIndex = 0 ; let oldEndIndex = oldCh.length - 1 ; let newStartIndex = 0 ; let newEndIndex = newCh.length - 1 ; let oldStartNode = oldCh[oldStartIndex]; let oldEndNode = oldCh[oldEndIndex]; let newStartNode = newCh[newStartIndex]; let newEndNode = newCh[newEndIndex]; const keyMap = new Map (); while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { if (oldStartNode == undefined ) { oldStartNode = oldCh[++oldStartIndex]; } else if (oldEndNode == undefined ) { oldEndNode = oldCh[--oldEndIndex]; } else if (newStartNode == undefined ) { newStartNode = newCh[++newStartIndex]; } else if (newEndNode == undefined ) { newEndNode = newCh[--newEndIndex]; } else if (isSame (oldStartNode, newStartNode)) { console .log ("method1" ); patchDetails (oldStartNode, newStartNode); oldStartNode = oldCh[++oldStartIndex]; newStartNode = newCh[++newStartIndex]; } else if (isSame (oldEndNode, newEndNode)) { console .log ("method2" ); patchDetails (oldEndNode, newEndNode); oldEndNode = oldCh[--oldEndIndex]; newEndNode = newCh[--newEndIndex]; } else if (isSame (oldStartNode, newEndNode)) { console .log ("method3" ); patchDetails (oldStartNode, newEndNode); parent.insertBefore (oldStartNode.elm , oldEndNode.elm .nextSibling ); oldStartNode = oldCh[++oldStartIndex]; newEndNode = newCh[--newEndIndex]; } else if (isSame (oldEndNode, newStartNode)) { console .log ("method4" ); patchDetails (oldEndNode, newStartNode); parent.insertBefore (oldEndNode.elm , oldCh[oldStartIndex].elm ); oldEndNode = oldCh[--oldEndIndex]; newStartNode = newCh[++newStartIndex]; } else { console .log ("does not match" ); if (keyMap.size == 0 ) { for (let i = oldStartIndex; i <= oldEndIndex; i++) { if (oldCh[i].key ) keyMap.set (oldCh[i].key , i); } } if (keyMap.has (newStartNode.key )) { const oldMoveNode = oldCh[keyMap.get (newStartNode.key )]; patchDetails (oldMoveNode, newStartNode); parent.insertBefore (oldMoveNode.elm , oldStartNode.elm ); oldCh[keyMap.get (newStartNode.key )] = undefined ; } else { parent.insertBefore (createEle (newStartNode), oldStartNode.elm ); } newStartNode = newCh[++newStartIndex]; } } if (oldStartIndex <= oldEndIndex) { for (let i = oldStartIndex; i <= oldEndIndex; i++) { if (oldCh[i]) parent.removeChild (oldCh[i].elm ); } } else if (newStartIndex <= newEndIndex) { for (let i = newStartIndex; i <= newEndIndex; i++) { parent.insertBefore (createEle (newCh[i]), oldCh[oldStartIndex] ? oldCh[oldStartIndex].elm : undefined ); } } } function isSame (a, b ) { return a.sel == b.sel && a.key == b.key ; }
这里的逻辑稍微比较复杂,需要大家多理几遍,必要的话,自己手画一张图自己移动一下指针。着重需要注意的地方是操作真实Dom时,插入、移动节点应该将节点从哪里插入或者移动到哪里,其实基本插入到oldStartIndex对应的真实Dom的前面,除了第三种命中后的移动节点操作,是移动到oldEndIndex所对应真实节点之后
总结 由于diff算法对比的是虚拟Dom,而虚拟Dom是呈树状的,所以我们可以发现,diff算法中充满了递归。总结起来,其实diff算法就是一个 patch —> patchVnode —> updateChildren —> patchVnode —> updateChildren —> patchVnode这样的一个循环递归的过程。
这里再提一嘴key,我们面试中经常会被问到vue中key的作用。根据上面我们分析的,key的主要作用其实就是对比两个虚拟节点时,判断其是否为相同节点。加了key以后,我们可以更为明确的判断两个节点是否为同一个虚拟节点,是的话判断子节点是否有变更(有变更更新真实Dom),不是的话继续比。如果不加key的话,如果两个不同节点的标签名恰好相同,那么就会被判定为同一个节点(key都为undefined),结果一对比这两个节点的子节点发现不一样,这样会凭空增加很多对真实Dom的操作,从而导致页面更频繁得进行重绘和回流。
所以我认为合理利用key可以有效减少真实Dom的变动,从而减少页面重绘和回流的频率,进而提高页面更新的效率。