什么是虚拟DOM

讲Diff算法前,我先给大家讲一讲什么是虚拟DOM吧。这有利于后面大家对Diff算法的理解加深。
虚拟DOM是一个对象,一个什么样的对象呢?一个用来表示真实DOM的对象,要记住这句话。我举个例子,请看以下真实DOM:

  1. <ul id="list">
  2. <li class="item">哈哈</li>
  3. <li class="item">呵呵</li>
  4. <li class="item">嘿嘿</li>
  5. </ul>

对应的虚拟DOM为:

  1. let oldVDOM = { // 旧虚拟DOM
  2. tagName: 'ul', // 标签名
  3. props: { // 标签属性
  4. id: 'list'
  5. },
  6. children: [ // 标签子节点
  7. {
  8. tagName: 'li', props: { class: 'item' }, children: ['哈哈']
  9. },
  10. {
  11. tagName: 'li', props: { class: 'item' }, children: ['呵呵']
  12. },
  13. {
  14. tagName: 'li', props: { class: 'item' }, children: ['嘿嘿']
  15. },
  16. ]
  17. }

这时候,我修改一个li标签的文本:

  1. <ul id="list">
  2. <li class="item">哈哈</li>
  3. <li class="item">呵呵</li>
  4. <li class="item">林三心哈哈哈哈哈</li> // 修改
  5. </ul>

这时候生成的新虚拟DOM为:

  1. let newVDOM = { // 新虚拟DOM
  2. tagName: 'ul', // 标签名
  3. props: { // 标签属性
  4. id: 'list'
  5. },
  6. children: [ // 标签子节点
  7. {
  8. tagName: 'li', props: { class: 'item' }, children: ['哈哈']
  9. },
  10. {
  11. tagName: 'li', props: { class: 'item' }, children: ['呵呵']
  12. },
  13. {
  14. tagName: 'li', props: { class: 'item' }, children: ['林三心哈哈哈哈哈']
  15. },
  16. ]
  17. }

这就是咱们平常说的新旧两个虚拟DOM,这个时候的新虚拟DOM是数据的最新状态,那么我们直接拿新虚拟DOM去渲染成真实DOM的话,效率真的会比直接操作真实DOM高吗?那肯定是不会的,看下图:
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图1
由上图,一看便知,肯定是第2种方式比较快,因为第1种方式中间还夹着一个虚拟DOM的步骤,所以虚拟DOM比真实DOM快这句话其实是错的,或者说是不严谨的。那正确的说法是什么呢?虚拟DOM算法操作真实DOM,性能高于直接操作真实DOM,虚拟DOM和虚拟DOM算法是两种概念。虚拟DOM算法 = 虚拟DOM + Diff算法

什么是Diff算法

上面咱们说了虚拟DOM,也知道了只有虚拟DOM + Diff算法才能真正的提高性能,那讲完虚拟DOM,我们再来讲讲Diff算法吧,还是上面的例子(这张图被压缩的有点小,大家可以打开看,比较清晰):
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图2
上图中,其实只有一个li标签修改了文本,其他都是不变的,所以没必要所有的节点都要更新,只更新这个li标签就行,Diff算法就是查出这个li标签的算法。
总结:Diff算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM,进而提高效率。
使用虚拟DOM算法的损耗计算: 总损耗 = 虚拟DOM增删改+(与Diff算法效率有关)真实DOM差异增删改+(较少的节点)排版与重绘
直接操作真实DOM的损耗计算: 总损耗 = 真实DOM完全增删改+(可能较多的节点)排版与重绘

Diff算法的原理

Diff同层对比

新旧虚拟DOM对比的时候,Diff算法比较只会在同层级进行, 不会跨层级比较。 所以Diff算法是:深度优先算法。 时间复杂度:O(n)
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图3

Diff对比流程

当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher,订阅者们就会调用patch方法,给真实DOM打补丁,更新相应的视图。对于这一步不太了解的可以看一下我之前写Vue源码系列
newVnode和oldVnode:同层的新旧虚拟节点
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图4

patch方法

这个方法作用就是,对比当前同层的虚拟节点是否为同一种类型的标签(同一类型的标准,下面会讲):

  • 是:继续执行patchVnode方法进行深层比对
  • 否:没必要比对了,直接整个节点替换成新虚拟节点

来看看patch的核心原理代码

  1. function patch(oldVnode, newVnode) {
  2. // 比较是否为一个类型的节点
  3. if (sameVnode(oldVnode, newVnode)) {
  4. // 是:继续进行深层比较
  5. patchVnode(oldVnode, newVnode)
  6. } else {
  7. // 否
  8. const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
  9. const parentEle = api.parentNode(oldEl) // 获取父节点
  10. createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
  11. if (parentEle !== null) {
  12. api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
  13. api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
  14. // 设置null,释放内存
  15. oldVnode = null
  16. }
  17. }
  18. return newVnode
  19. }

sameVnode方法

patch关键的一步就是sameVnode方法判断是否为同一类型节点,那问题来了,怎么才算是同一类型节点呢?这个类型的标准是什么呢?
咱们来看看sameVnode方法的核心原理代码,就一目了然了

  1. function sameVnode(oldVnode, newVnode) {
  2. return (
  3. oldVnode.key === newVnode.key && // key值是否一样
  4. oldVnode.tagName === newVnode.tagName && // 标签名是否一样
  5. oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
  6. isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
  7. sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
  8. )
  9. }

patchVnode方法

这个函数做了以下事情:

  • 找到对应的真实DOM,称为el
  • 判断newVnode和oldVnode是否指向同一个对象,如果是,那么直接return
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为newVnode的文本节点。
  • 如果oldVnode有子节点而newVnode没有,则删除el的子节点
  • 如果oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化之后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要

    1. function patchVnode(oldVnode, newVnode) {
    2. const el = newVnode.el = oldVnode.el // 获取真实DOM对象
    3. // 获取新旧虚拟节点的子节点数组
    4. const oldCh = oldVnode.children, newCh = newVnode.children
    5. // 如果新旧虚拟节点是同一个对象,则终止
    6. if (oldVnode === newVnode) return
    7. // 如果新旧虚拟节点是文本节点,且文本不一样
    8. if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
    9. // 则直接将真实DOM中文本更新为新虚拟节点的文本
    10. api.setTextContent(el, newVnode.text)
    11. } else {
    12. // 否则
    13. if (oldCh && newCh && oldCh !== newCh) {
    14. // 新旧虚拟节点都有子节点,且子节点不一样
    15. // 对比子节点,并更新
    16. updateChildren(el, oldCh, newCh)
    17. } else if (newCh) {
    18. // 新虚拟节点有子节点,旧虚拟节点没有
    19. // 创建新虚拟节点的子节点,并更新到真实DOM上去
    20. createEle(newVnode)
    21. } else if (oldCh) {
    22. // 旧虚拟节点有子节点,新虚拟节点没有
    23. //直接删除真实DOM里对应的子节点
    24. api.removeChild(el)
    25. }
    26. }
    27. }

    其他几个点都很好理解,我们详细来讲一下updateChildren

    updateChildren方法

    这是patchVnode里最重要的一个方法,新旧虚拟节点的子节点对比,就是发生在updateChildren方法中,接下来就结合一些图来讲,让大家更好理解吧
    是怎么样一个对比方法呢?就是首尾指针法,新的子节点集合和旧的子节点集合,各有首尾两个指针,举个例子: ```vue

    • a
    • b
    • c

修改数据后

  • b
  • c
  • e
  • a
那么新旧两个子节点集合以及其首尾指针为:<br />![](https://cdn.nlark.com/yuque/0/2022/webp/27272831/1659690172156-27279199-eec1-4691-9a57-558168c3f2d2.webp#clientId=u5fe9d6bc-968e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=ufc983653&margin=%5Bobject%20Object%5D&originHeight=363&originWidth=600&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u6c150e47-30cf-4809-a948-5f54b2d5103&title=)<br />然后会进行互相进行比较,总共有五种比较情况: - 1、oldS 和 newS 使用sameVnode方法进行比较,sameVnode(oldS, newS) - 2、oldS 和 newE 使用sameVnode方法进行比较,sameVnode(oldS, newE) - 3、oldE 和 newS 使用sameVnode方法进行比较,sameVnode(oldE, newS) - 4、oldE 和 newE 使用sameVnode方法进行比较,sameVnode(oldE, newE) - 5、如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index 表,然后用新 vnode 的 key 去找出在旧节点中可以复用的位置。 ![](https://cdn.nlark.com/yuque/0/2022/webp/27272831/1659690172227-66ca442a-58db-4e79-8b9d-7020616add7e.webp#clientId=u5fe9d6bc-968e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u38b49aee&margin=%5Bobject%20Object%5D&originHeight=385&originWidth=558&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=uca71bc6e-cc21-4c77-86e6-8a384d79d41&title=)<br />**接下来就以上面代码为例,分析一下比较的过程**<br />分析之前,请大家记住一点,最终的渲染结果都要以newVDOM为准,这也解释了为什么之后的节点移动需要移动到newVDOM所对应的位置<br />![](https://cdn.nlark.com/yuque/0/2022/webp/27272831/1659690172188-c198ac45-4331-4848-8e77-65047b2488eb.webp#clientId=u5fe9d6bc-968e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=uea4f66b0&margin=%5Bobject%20Object%5D&originHeight=298&originWidth=650&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u971a3b53-0a35-4565-a954-1de60c78323&title=) - 第一步javascript oldS = a, oldE = c newS = b, newE = a 比较结果:oldS 和 newE 相等,需要把节点a移动到newE所对应的位置,也就是末尾,同时oldS++,newE--<br />![](https://cdn.nlark.com/yuque/0/2022/webp/27272831/1659690367311-4f15a641-610c-41ab-a2c4-d572f4c3463d.webp#clientId=u5fe9d6bc-968e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u9a24e967&margin=%5Bobject%20Object%5D&originHeight=347&originWidth=675&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u300563ff-1636-4afe-810b-0bc8d4b8e62&title=) - 第二步javascript oldS = b, oldE = c newS = b, newE = e 比较结果:oldS 和 newS相等,需要把节点b移动到newS所对应的位置,同时oldS++,newS++<br />![](https://cdn.nlark.com/yuque/0/2022/webp/27272831/1659690451266-6158d77f-6e3e-4e4a-959d-57f7630485c3.webp#clientId=u5fe9d6bc-968e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=uf6d84a04&margin=%5Bobject%20Object%5D&originHeight=333&originWidth=637&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u9c9821b4-cc19-4cb4-9b3d-559ed28c902&title=) - 第三步javascript oldS = c, oldE = c newS = c, newE = e 比较结果:oldS、oldE 和 newS相等,需要把节点c移动到newS所对应的位置,同时oldS++,newS++<br />![](https://cdn.nlark.com/yuque/0/2022/webp/27272831/1659690484310-731fabad-95f5-4269-b7d6-59af28fe800a.webp#clientId=u5fe9d6bc-968e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u1cf3cc14&margin=%5Bobject%20Object%5D&originHeight=342&originWidth=710&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u3cf03d92-5f08-4197-b54c-4da324aa640&title=) - 第四步 oldS > oldE,则oldCh先遍历完成了,而newCh还没遍历完,说明newCh比oldCh多,所以需要将多出来的节点,插入到真实DOM上对应的位置上 ![](https://cdn.nlark.com/yuque/0/2022/webp/27272831/1659690484349-3fff1e9d-83ee-4383-a173-291a2f291164.webp#clientId=u5fe9d6bc-968e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=ud4120ec1&margin=%5Bobject%20Object%5D&originHeight=327&originWidth=710&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u8a185245-9d21-482a-903d-b9dbc8cd3b2&title=) - 思考题 我在这里给大家留一个思考题哈。上面的例子是newCh比oldCh多,假如相反,是oldCh比newCh多的话,那就是newCh先走完循环,然后oldCh会有多出的节点,结果会在真实DOM里进行删除这些旧节点。大家可以自己思考一下,模拟一下这个过程,像我一样,画图模拟,才能巩固上面的知识。 附上updateChildren的核心原理代码javascript function updateChildren(parentElm, oldCh, newCh) { let oldStartIdx = 0, 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 let idxInOld let elmToMove let before while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (oldStartVnode == null) { oldStartVnode = oldCh[++oldStartIdx] } else if (oldEndVnode == null) { oldEndVnode = oldCh[—oldEndIdx] } else if (newStartVnode == null) { newStartVnode = newCh[++newStartIdx] } else if (newEndVnode == null) { newEndVnode = newCh[—newEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode) oldEndVnode = oldCh[—oldEndIdx] newEndVnode = newCh[—newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { patchVnode(oldStartVnode, newEndVnode) api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[—newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { patchVnode(oldEndVnode, newStartVnode) api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el) oldEndVnode = oldCh[—oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 使用key时的比较 if (oldKeyToIdx === undefined) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表 } idxInOld = oldKeyToIdx[newStartVnode.key] if (!idxInOld) { api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el) newStartVnode = newCh[++newStartIdx] } else { elmToMove = oldCh[idxInOld] if (elmToMove.sel !== newStartVnode.sel) { api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el) } else { patchVnode(elmToMove, newStartVnode) oldCh[idxInOld] = null api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el) } newStartVnode = newCh[++newStartIdx] } } } if (oldStartIdx > oldEndIdx) { before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx) } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } } <a name="N5SVi"></a> ## 用index做key 平常v-for循环渲染的时候,为什么不建议用index作为循环项的key呢?<br />我们举个例子,左边是初始数据,然后我在数据前插入一个新数据,变成右边的列表vue
    • a
    • 林三心
    • b
    • a
    • c
    • b
    • c
  1. 按理说,最理想的结果是:只插入一个li标签新节点,其他都不动,确保操作DOM效率最高。但是我们这里用了index来当key的话,真的会实现我们的理想结果吗?废话不多说,实践一下:
  2. ```javascript
  3. <ul>
  4. <li v-for="(item, index) in list" :key="index">{{ item.title }}</li>
  5. </ul>
  6. <button @click="add">增加</button>
  7. list: [
  8. { title: "a", id: "100" },
  9. { title: "b", id: "101" },
  10. { title: "c", id: "102" },
  11. ]
  12. add() {
  13. this.list.unshift({ title: "林三心", id: "99" });
  14. }

点击按钮我们可以看到,并不是我们预想的结果,而是所有li标签都更新了
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图5
为什么会这样呢?还是通过图来解释
按理说,a,b,c三个li标签都是复用之前的,因为他们三个根本没改变,改变的只是前面新增了一个林三心
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图6
但是我们前面说了,在进行子节点的 diff算法 过程中,会进行 旧首节点和新首节点的sameNode对比,这一步命中了逻辑,因为现在新旧两次首部节点 的 key 都是 0了,同理,key为1和2的也是命中了逻辑,导致相同key的节点会去进行patchVnode更新文本,而原本就有的c节点,却因为之前没有key为4的节点,而被当做了新节点,所以很搞笑,使用index做key,最后新增的居然是本来就已有的c节点。所以前三个都进行patchVnode更新文本,最后一个进行了新增,那就解释了为什么所有li标签都更新了。
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图7
那我们可以怎么解决呢?其实我们只要使用一个独一无二的值来当做key就行了

  1. <ul>
  2. <li v-for="item in list" :key="item.id">{{ item.title }}</li>
  3. </ul>

现在再来看看效果
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图8
为什么用了id来当做key就实现了我们的理想效果呢,因为这么做的话,a,b,c节点的key就会是永远不变的,更新前后key都是一样的,并且又由于a,b,c节点的内容本来就没变,所以就算是进行了patchVnode,也不会执行里面复杂的更新操作,节省了性能,而林三心节点,由于更新前没有他的key所对应的节点,所以他被当做新的节点,增加到真实DOM上去了。
15张图,20分钟吃透Diff算法核心原理,我说的!!! - 图9