Vue diff算法

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

vnodechildren 数组中对应子节点的 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. }

从代码中可以看到,会判断新旧vnodekey , 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是否匹配上了,如果匹配上了,oldStartVnodenewStartVnode指针向右移动,真实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)