Vue diff算法

在 vue 中会维护一个和 DOM 节点对应的 vnode 对象

vnode 的 children 数组中对应子节点的 vnode 对象,所以在 vue 中通过 vnode 和真实的 DOM 树进行映射,我们也称之为虚拟树。

正是有了虚拟树,当数据更新时。我们可以对比新数据构建的 vnode 和老数据构建的 oldVnode 的差异,来实现精准更新。

而我们对比差异的算法就是采用的 diff。

通过 diff 对比虚拟树的差异,将差异通过打补丁(patch)的方式更新到对应的真实 DOM 节点上。

patch函数

由上就可以知道patch函数是 diff 流程的入口函数

首先我们要知道,patch会先在页面渲染的时候加载一次,然后就在vnode改变的时候调用。

  1. // 首次渲染是DOM元素,后面是vnode
  2. function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
  3. let i: number, elm: Node, parent: Node
  4. const insertedVnodeQueue: VNodeQueue = []
  5. for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
  6. // 不是一个vnode就创建一个空的vnode并关联在DOM元素上
  7. if (!isVnode(oldVnode)) {
  8. // 创建一个空的vnode,并关联DOM元素
  9. oldVnode = emptyNodeAt(oldVnode)
  10. }
  11. if (sameVnode(oldVnode, vnode)) {
  12. // key、tag相同,说明是同一个vnode
  13. patchVnode(oldVnode, vnode, insertedVnodeQueue)
  14. } else {
  15. // key、tag不相同,说明是不同的vnode
  16. elm = oldVnode.elm!
  17. parent = api.parentNode(elm) as Node
  18. // 创建新的DOM元素
  19. createElm(vnode, insertedVnodeQueue)
  20. if (parent !== null) {
  21. // 插入新的DOM元素
  22. api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
  23. // 移除老的DOM元素
  24. removeVnodes(parent, [oldVnode], 0, 0)
  25. }
  26. }
  27. return vnode
  28. }

61Vue diff算法源码浅析 - 图1

整个逻辑就如下:

如果两个节点都是一样的,那么就深入检查他们的子节点。如果两个节点不一样那就说明Vnode完全被改变了,就可以直接替换oldVnode。

  1. // 判断新旧节点是否一样
  2. function sameVnode (a, b) {
  3. return (
  4. a.key === b.key && // key值
  5. a.tag === b.tag && // 标签名
  6. a.isComment === b.isComment && // 是否为注释节点
  7. // 是否都定义了data,data包含一些具体信息,例如onclick , style
  8. isDef(a.data) === isDef(b.data) &&
  9. sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  10. )
  11. }

从代码中可以看到,会判断新旧vnode的 key , tag等是否相等,如果相等就是同一个vnode.

patchVnode

如果2个节点是一样的,就会进入patchVnode的方法,判断他的子节点和文本节点。这里也就是对可复用节点进行打补丁,也就是派发更新。

  1. function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  2. ...
  3. // vonde为新的vnode oldvnnode为老的vnode
  4. // 设置vnode关联的DOM元素
  5. const elm = vnode.elm = oldVnode.elm!
  6. // 老children
  7. const oldCh = oldVnode.children as VNode[]
  8. // 新children
  9. const ch = vnode.children as VNode[]
  10. if (oldVnode === vnode) return
  11. ...
  12. // 新vnode 无text 有children
  13. if (isUndef(vnode.text)) {
  14. // 新vnode 有children 老vnode 有chidren
  15. if (isDef(oldCh) && isDef(ch)) {
  16. if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
  17. // 新vnode 有children 旧vnode 无children 有text
  18. } else if (isDef(ch)) {
  19. // 清空text
  20. if (isDef(oldVnode.text)) api.setTextContent(elm, '')
  21. // 添加children
  22. addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  23. // 新vnode 无children 旧vnode 有children
  24. } else if (isDef(oldCh)) {
  25. // 移除children
  26. removeVnodes(elm, oldCh, 0, oldCh.length - 1)
  27. // 老vnode 有text
  28. } else if (isDef(oldVnode.text)) {
  29. // 清空text
  30. api.setTextContent(elm, '')
  31. }
  32. // 新vnode 有text 无children
  33. // 老vnode text 不等于 新vnode text
  34. } else if (oldVnode.text !== vnode.text) {
  35. // 老vnode 有children
  36. if (isDef(oldCh)) {
  37. // 移除老vnode children
  38. removeVnodes(elm, oldCh, 0, oldCh.length - 1)
  39. }
  40. // 设置新vnode text
  41. api.setTextContent(elm, vnode.text!)
  42. }
  43. hook?.postpatch?.(oldVnode, vnode)
  44. }

patchVnode函数的逻辑:

  1. 找到对应的 DOM 节点 elm,并且赋值给新的vnode.elm
  2. 判断新节点类型(vnode.text),如果是文本节点,更新 elm 文本即可
  3. 非文本节点下,判断新老节点的子节点
  4. 如果新老节点都有子节点,走子节点的同层比较流程 updateChildren
  5. 如果只有新节点有子节点,直接使用 addVnodes 为 elm 添加子节点(先删除文本)
  6. 如果只有旧节点有子节点,使用 removeVnodes 移除即可
  7. 如果都没有子节点,判断旧数据是否有文本节点,有则清空。

updateChildren

  1. function updateChildren (parentElm: Node,
  2. oldCh: VNode[],
  3. newCh: VNode[],
  4. insertedVnodeQueue: VNodeQueue) {
  5. let oldStartIdx = 0
  6. let newStartIdx = 0
  7. let oldEndIdx = oldCh.length - 1
  8. let oldStartVnode = oldCh[0]
  9. let oldEndVnode = oldCh[oldEndIdx]
  10. let newEndIdx = newCh.length - 1
  11. let newStartVnode = newCh[0]
  12. let newEndVnode = newCh[newEndIdx]
  13. let oldKeyToIdx: KeyToIndexMap | undefined
  14. let idxInOld: number
  15. let elmToMove: VNode
  16. let before: any
  17. while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  18. if (oldStartVnode == null) {
  19. oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
  20. } else if (oldEndVnode == null) {
  21. oldEndVnode = oldCh[--oldEndIdx]
  22. } else if (newStartVnode == null) {
  23. newStartVnode = newCh[++newStartIdx]
  24. } else if (newEndVnode == null) {
  25. newEndVnode = newCh[--newEndIdx]
  26. // 老开始和新开始对比
  27. } else if (sameVnode(oldStartVnode, newStartVnode)) {
  28. patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
  29. oldStartVnode = oldCh[++oldStartIdx]
  30. newStartVnode = newCh[++newStartIdx]
  31. // 老结束和新结束对比
  32. } else if (sameVnode(oldEndVnode, newEndVnode)) {
  33. patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
  34. oldEndVnode = oldCh[--oldEndIdx]
  35. newEndVnode = newCh[--newEndIdx]
  36. // 老开始和新结束对比
  37. } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
  38. patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
  39. api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
  40. oldStartVnode = oldCh[++oldStartIdx]
  41. newEndVnode = newCh[--newEndIdx]
  42. // 老结束和新开始对比
  43. } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
  44. patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
  45. api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
  46. oldEndVnode = oldCh[--oldEndIdx]
  47. newStartVnode = newCh[++newStartIdx]
  48. // 以上四种情况都没有命中
  49. } else {
  50. if (oldKeyToIdx === undefined) {
  51. oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
  52. }
  53. // 拿到新开始的key,在老children里去找有没有某个节点有对应这个key
  54. idxInOld = oldKeyToIdx[newStartVnode.key as string]
  55. // 没有在老children里找到对应的
  56. if (isUndef(idxInOld)) { // New element
  57. api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
  58. // 在老children里找到了对应的
  59. } else {
  60. // 找到了对应key的元素(key相等)
  61. elmToMove = oldCh[idxInOld]
  62. // key相等 判断tag是否相等
  63. if (elmToMove.sel !== newStartVnode.sel) {
  64. // key相等 tag不相等
  65. api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
  66. } else {
  67. // key相等 tag相等
  68. patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
  69. oldCh[idxInOld] = undefined as any
  70. api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
  71. }
  72. }
  73. newStartVnode = newCh[++newStartIdx]
  74. }
  75. }
  76. if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
  77. if (oldStartIdx > oldEndIdx) {
  78. before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
  79. addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  80. } else {
  81. removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  82. }
  83. }
  84. }

61Vue diff算法源码浅析 - 图2

从图中可以看到 会有四个指针(oldStartVnode,oldEndVnode, newStartVnode, newEndVnode)分别指向,新老children节点的头尾。

判断逻辑是这样的。

第一步:判断(oldStartVnode, newStartVnode)他俩指正指的vnode是否匹配上了,如果匹配上了,oldStartVnode,newStartVnode指针向右移动,真实DOM的位置不变。
第二步:(oldEndVnode, newEndVnode) 他俩是否匹配上了,如果匹配上了,oldEndVnode, newEndVnode指针向左移动,真实DOM的位置不变。

第三步:(oldStartVnode, newEndVnode)判断是否匹配上了,如果匹配上了那么真实DOM中的第一个节点会移到最后

第四步:(oldEndVnode, newStartVnode)判断匹配上了,如果匹配上了那么真实DOM中的最后一个节点会移到最前,匹配上的两个指针向中间移动。

第五步:如果四种匹配没有一对是成功的,分为两种情况

  • 如果新旧子节点都存在key,那么会根据oldChild的key生成一张hash表,用newStartVnode的key与hash表做匹配,匹配成功就判断newStartVnode和匹配节点是否为sameNode,如果是,就在真实DOM中将成功的节点移到最前面,否则,将newStartVnode生成对应的节点插入到DOM中对应的oldS位置,newStartVnode指针向中间移动,被匹配old中的节点置为null。
  • 如果没有key,则直接将newStartVnode生成新的节点插入真实DOM(ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了)

参考文章

详解vue的diff算法 (juejin.cn)