• Contents

已知有一个列表:

  1. <ul>
  2. <li key="a">a</li>
  3. <li key="b">b</li>
  4. <li key="c">c</li>
  5. <li key="d">d</li>
  6. </ul>

然后在中间插入一行,得到一个新列表:

  1. <ul>
  2. <li key="a">a</li>
  3. <li key="b">b</li>
  4. <li key="e">e</li> <!-- 新增 -->
  5. <li key="c">c</li>
  6. <li key="d">d</li>
  7. </ul>

在插入操作的前后,它们对应渲染生成的 vnode 可以用一张图表示:(差异主要在新子节点中的 b 节点后面多了一个 e 节点)


将例子稍微修改一下,增加一个 e 节点

  1. <ul>
  2. <li key="a">a</li>
  3. <li key="b">b</li>
  4. <li key="c">c</li>
  5. <li key="d">d</li>
  6. <li key="e">e</li>
  7. </ul>

然后在删除中间一项,得到一个新列表:

  1. <ul>
  2. <li key="a">a</li>
  3. <li key="b">b</li>
  4. <!-- 删除 -->
  5. <li key="d">d</li>
  6. <li key="e">e</li>
  7. </ul>

在删除操作的前后,它们对应渲染生成的 vnode 可以用一张图表示:(差异主要在新子节点中的 b 节点后面少了一个 c 节点)

通过这两个例子,可以发现新旧 children 拥有相同的头尾节点。对于相同的节点,我们只需要做对比更新即可,所以 diff 算法的第一步从头部开始同步

同步头部节点

  1. const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. let i = 0
  3. const l2 = c2.length
  4. // 旧子节点的尾部索引
  5. let e1 = c1.length - 1
  6. // 新子节点的尾部索引
  7. let e2 = l2 - 1
  8. // 1. 从头部开始同步
  9. // i = 0, e1 = 3, e2 = 4
  10. // (a b) c d
  11. // (a b) e c d
  12. while (i <= e1 && i <= e2) {
  13. const n1 = c1[i]
  14. const n2 = c2[i]
  15. if (isSameVNodeType(n1, n2)) {
  16. // 相同的节点,递归执行 patch 更新节点
  17. patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
  18. }
  19. else {
  20. break
  21. }
  22. i++
  23. }
  24. }

在整个 diff 的过程,我们需要维护几个变量:头部的索引 i、旧子节点的尾部索引 e1 和新子节点的尾部索引 e2

同步头部节点就是从头部开始,依次对比新节点和旧节点,如果它们相同的则执行 patch 更新节点;如果不同或者索引 i 大于索引 e1 或者 e2,则同步过程结束。

同步头部节点后的结果:(完成头部节点同步后:i2e13e24

同步尾部节点

  1. const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. let i = 0
  3. const l2 = c2.length
  4. // 旧子节点的尾部索引
  5. let e1 = c1.length - 1
  6. // 新子节点的尾部索引
  7. let e2 = l2 - 1
  8. // 1. 从头部开始同步
  9. // i = 0, e1 = 3, e2 = 4
  10. // (a b) c d
  11. // (a b) e c d
  12. // 2. 从尾部开始同步
  13. // i = 2, e1 = 3, e2 = 4
  14. // (a b) (c d)
  15. // (a b) e (c d)
  16. while (i <= e1 && i <= e2) {
  17. const n1 = c1[e1]
  18. const n2 = c2[e2]
  19. if (isSameVNodeType(n1, n2)) {
  20. patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
  21. }
  22. else {
  23. break
  24. }
  25. e1--
  26. e2--
  27. }
  28. }

同步尾部节点就是从尾部开始,依次对比新节点和旧节点,如果相同的则执行 patch 更新节点;如果不同或者索引 i 大于索引 e1 或者 e2,则同步过程结束。

同步尾部节点后的结果:(完成尾部节点同步后:i2e11e22

接下来只有 3 种情况要处理:

  • 新子节点有剩余要添加的新节点;
  • 旧子节点有剩余要删除的多余节点;
  • 未知子序列。

添加新节点

首先要判断新子节点是否有剩余的情况,如果满足则添加新子节点。

  1. const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. let i = 0
  3. const l2 = c2.length
  4. // 旧子节点的尾部索引
  5. let e1 = c1.length - 1
  6. // 新子节点的尾部索引
  7. let e2 = l2 - 1
  8. // 1. 从头部开始同步
  9. // i = 0, e1 = 3, e2 = 4
  10. // (a b) c d
  11. // (a b) e c d
  12. // ...
  13. // 2. 从尾部开始同步
  14. // i = 2, e1 = 3, e2 = 4
  15. // (a b) (c d)
  16. // (a b) e (c d)
  17. // 3. 挂载剩余的新节点
  18. // i = 2, e1 = 1, e2 = 2
  19. if (i > e1) {
  20. if (i <= e2) {
  21. const nextPos = e2 + 1
  22. const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor
  23. while (i <= e2) {
  24. // 挂载新节点
  25. patch(null, c2[i], container, anchor, parentComponent, parentSuspense, isSVG)
  26. i++
  27. }
  28. }
  29. }
  30. }

如果索引 i 大于尾部索引 e1i 小于 e2,那么从索引 i 开始到索引 e2 之间,我们直接挂载新子树这部分的节点。

对我们的例子而言,同步完尾部节点后 i2e11e22,此时满足条件需要添加新的节点

添加完 e 节点后,旧子节点的 DOM 和新子节点对应的 vnode 映射一致,也就完成了更新。

删除多余节点

如果不满足添加新节点的情况,我就要接着判断旧子节点是否有剩余,如果满足则删除旧子节点

  1. const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. let i = 0
  3. const l2 = c2.length
  4. // 旧子节点的尾部索引
  5. let e1 = c1.length - 1
  6. // 新子节点的尾部索引
  7. let e2 = l2 - 1
  8. // 1. 从头部开始同步
  9. // i = 0, e1 = 4, e2 = 3
  10. // (a b) c d e
  11. // (a b) d e
  12. // ...
  13. // 2. 从尾部开始同步
  14. // i = 2, e1 = 4, e2 = 3
  15. // (a b) c (d e)
  16. // (a b) (d e)
  17. // 3. 普通序列挂载剩余的新节点
  18. // i = 2, e1 = 2, e2 = 1
  19. // 不满足
  20. if (i > e1) {
  21. }
  22. // 4. 普通序列删除多余的旧节点
  23. // i = 2, e1 = 2, e2 = 1
  24. else if (i > e2) {
  25. while (i <= e1) {
  26. // 删除节点
  27. unmount(c1[i], parentComponent, parentSuspense, true)
  28. i++
  29. }
  30. }
  31. }

如果索引 i 大于尾部索引 e2,那么从索引 i 开始到索引 e1 之间,我们直接删除旧子树这部分的节点。

第二个例子是就删除节点的情况,我们从同步头部节点开始,用图的方式演示这一过程。

首先从头部同步节点:(i2e14e23

接着从尾部同步节点:

此时 i2e12e21,满足删除条件,因此删除子节点中的多余节点

除完 c 节点后,旧子节点的 DOM 和新子节点对应的 vnode 映射一致,也就完成了更新。

处理未知子序列

单纯的添加和删除节点操作起来很容易,但更多的是遇到比较复杂的未知子序列。

已知一个字母表排列的列表

  1. <ul>
  2. <li key="a">a</li>
  3. <li key="b">b</li>
  4. <li key="c">c</li>
  5. <li key="d">d</li>
  6. <li key="e">e</li>
  7. <li key="f">f</li>
  8. <li key="g">g</li>
  9. <li key="h">h</li>
  10. </ul>

然后打乱之前的顺序得到一个新列表:

  1. <ul>
  2. <li key="a">a</li>
  3. <li key="b">b</li>
  4. <li key="e">e</li>
  5. <li key="d">c</li>
  6. <li key="c">d</li>
  7. <li key="i">i</li>
  8. <li key="g">g</li>
  9. <li key="h">h</li>
  10. </ul>

它们对应渲染生成的 vnode:

从头部同步节点

同步头部节点后的结果:i2e17e27

接着从尾部同步节点

同步尾部节点后的结果:i2e15e25。可以看出它既不满足添加新节点的条件,也不满足删除旧节点的条件。那么对于这种情况,该怎么处理呢?

结合上图可以知道,要把旧子节点的 cdef 转变成新子节点的 ecdi。从直观上看,我们把 e 节点移动到 c 节点前面,删除 f 节点,然后在 d 节点后面添加 i 节点即可。

无论多复杂的情况,最终无非都是通过更新、删除、添加、移动这些动作来操作节点,而我们要做的就是找到相对优的解。

  • 当两个节点类型相同时,执行更新操作;
  • 当新子节点中没有旧子节点中的某些节点时,执行删除操作;
  • 当新子节点中多了旧子节点中没有的节点时,执行添加操作;
  • 相对来说这些操作中最麻烦的就是移动,我们既要判断哪些节点需要移动也要清楚如何移动。

移动子节点

当子节点排列顺序发生变化的时候需要移动子节点。

  1. const prev = [1, 2, 3, 4, 5, 6]
  2. const next = [1, 3, 2, 6, 4, 5]

prev 变成 next,数组里的一些元素的顺序发生了变化,我们可以把子节点类比为元素,现在问题就简化为我们如何用最少的移动使元素顺序从 prev 变化为 next

一种思路是在 next 中找到一个递增子序列,比如 [1, 3, 6][1, 2, 4, 5]。之后对 next 数组进行倒序遍历,移动所有不在递增序列中的元素即可。

  1. 如果选择了 [1, 3, 6] 作为递增子序列,那么在倒序遍历的过程中,遇到 631 不动,遇到 542 移动即可,如下图所示:
  2. 如果选择了 [1, 2, 4, 5] 作为递增子序列,那么在倒序遍历的过程中,遇到 5421 不动,遇到 63 移动即可,如下图所示:
    DOM Diff - 图1

可以看到第一种移动了 3 次,而第二种只移动了 2 次,递增子序列越长,所需要移动元素的次数越少,所以如何移动的问题就回到了求解最长递增子序列的问题。

现在要做的是在新旧子节点序列中找出相同节点并更新,找出多余的节点删除,找出新的节点添加,找出是否有需要移动的节点,如果又该如何移动。

在查找过程中需要对比新旧子序列,那么我们就要遍历某个序列,如果在遍历旧子序列的过程中需要判断某个节点是否在新子序列中存在,这就需要双重循环,而双重循环的复杂度是 O(n2 ) ,为了优化这个复杂度,我们可以用一种空间换时间的思路,建立索引图,把时间复杂度降低到 O(n)

建立索引图

处理未知子序列的第一步,就是建立索引图。

通常我们在开发过程中, 会给 v-for 生成的列表中的每一项分配唯一 key 作为项的唯一 ID,这个 key 在 diff 过程中起到很关键的作用。对于新旧子序列中的节点,我们认为 key 相同的就是同一个节点,直接执行 patch 更新即可

  1. const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. let i = 0
  3. const l2 = c2.length
  4. // 旧子节点的尾部索引
  5. let e1 = c1.length - 1
  6. // 新子节点的尾部索引
  7. let e2 = l2 - 1
  8. // 1. 从头部开始同步
  9. // i = 0, e1 = 7, e2 = 7
  10. // (a b) c d e f g h
  11. // (a b) e c d i g h
  12. // 2. 从尾部开始同步
  13. // i = 2, e1 = 7, e2 = 7
  14. // (a b) c d e f (g h)
  15. // (a b) e c d i (g h)
  16. // 3. 普通序列挂载剩余的新节点, 不满足
  17. // 4. 普通序列删除多余的旧节点,不满足
  18. // i = 2, e1 = 4, e2 = 5
  19. // 旧子序列开始索引,从 i 开始记录
  20. const s1 = i
  21. // 新子序列开始索引,从 i 开始记录
  22. const s2 = i //
  23. // 5.1 根据 key 建立新子序列的索引图
  24. const keyToNewIndexMap = new Map()
  25. for (i = s2; i <= e2; i++) {
  26. const nextChild = c2[i]
  27. keyToNewIndexMap.set(nextChild.key, i)
  28. }
  29. }

新旧子序列是从 i 开始的,所以我们先用 s1s2 分别作为新旧子序列的开始索引,接着建立一个 keyToNewIndexMapMap<key, index> 结构,遍历新子序列,把节点的 keyindex 添加到这个 Map 中,注意我们这里假设所有节点都是有 key 标识的。

keyToNewIndexMap 存储的就是新子序列中每个节点在新子序列中的索引。

得到了一个值为 {e:2,c:3,d:4,i:5} 的新子序列索引图

更新和移除旧节点

接下来就需要遍历旧子序列,有相同的节点就通过 patch 更新,并且移除那些不在新子序列中的节点,同时找出是否有需要移动的节点

  1. const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. let i = 0
  3. const l2 = c2.length
  4. // 旧子节点的尾部索引
  5. let e1 = c1.length - 1
  6. // 新子节点的尾部索引
  7. let e2 = l2 - 1
  8. // 1. 从头部开始同步
  9. // i = 0, e1 = 7, e2 = 7
  10. // (a b) c d e f g h
  11. // (a b) e c d i g h
  12. // 2. 从尾部开始同步
  13. // i = 2, e1 = 7, e2 = 7
  14. // (a b) c d e f (g h)
  15. // (a b) e c d i (g h)
  16. // 3. 普通序列挂载剩余的新节点,不满足
  17. // 4. 普通序列删除多余的旧节点,不满足
  18. // i = 2, e1 = 4, e2 = 5
  19. // 旧子序列开始索引,从 i 开始记录
  20. const s1 = i
  21. // 新子序列开始索引,从 i 开始记录
  22. const s2 = i
  23. // 5.1 根据 key 建立新子序列的索引图
  24. // 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
  25. // 新子序列已更新节点的数量
  26. let patched = 0
  27. // 新子序列待更新节点的数量,等于新子序列的长度
  28. const toBePatched = e2 - s2 + 1
  29. // 是否存在要移动的节点
  30. let moved = false
  31. // 用于跟踪判断是否有节点移动
  32. let maxNewIndexSoFar = 0
  33. // 这个数组存储新子序列中的元素在旧子序列节点的索引,用于确定最长递增子序列
  34. const newIndexToOldIndexMap = new Array(toBePatched)
  35. // 初始化数组,每个元素的值都是 0
  36. // 0 是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明这个新节点没有对应的旧节点
  37. for (i = 0; i < toBePatched; i++)
  38. newIndexToOldIndexMap[i] = 0
  39. // 正序遍历旧子序列
  40. for (i = s1; i <= e1; i++) {
  41. // 拿到每一个旧子序列节点
  42. const prevChild = c1[i]
  43. if (patched >= toBePatched) {
  44. // 所有新的子序列节点都已经更新,剩余的节点删除
  45. unmount(prevChild, parentComponent, parentSuspense, true)
  46. continue
  47. }
  48. // 查找旧子序列中的节点在新子序列中的索引
  49. let newIndex = keyToNewIndexMap.get(prevChild.key)
  50. if (newIndex === undefined) {
  51. // 找不到说明旧子序列已经不存在于新子序列中,则删除该节点
  52. unmount(prevChild, parentComponent, parentSuspense, true)
  53. }
  54. else {
  55. // 更新新子序列中的元素在旧子序列中的索引,这里加 1 偏移,是为了避免 i 为 0 的特殊情况,影响对后续最长递增子序列的求解
  56. newIndexToOldIndexMap[newIndex - s2] = i + 1
  57. // maxNewIndexSoFar 始终存储的是上次求值的 newIndex,如果不是一直递增,则说明有移动
  58. if (newIndex >= maxNewIndexSoFar) {
  59. maxNewIndexSoFar = newIndex
  60. }
  61. else {
  62. moved = true
  63. }
  64. // 更新新旧子序列中匹配的节点
  65. patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, optimized)
  66. patched++
  67. }
  68. }
  69. }

建立了一个 newIndexToOldIndexMap 的数组,来存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,用于确定最长递增子序列,这个数组的长度为新子序列的长度,每个元素的初始值设为 0, 它是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明遍历旧子序列的过程中没有处理过这个节点,这个节点是新添加的。

具体的操作过程:正序遍历旧子序列,根据前面建立的 keyToNewIndexMap 查找旧子序列中的节点在新子序列中的索引,如果找不到就说明新子序列中没有该节点,就删除它;如果找得到则将它在旧子序列中的索引更新到 newIndexToOldIndexMap 中。

注意:这里索引加了长度为 1 的偏移,是为了应对 i0 的特殊情况,如果不这样处理就会影响后续求解最长递增子序列。

遍历过程中,我们用变量 maxNewIndexSoFar 跟踪判断节点是否移动,maxNewIndexSoFar 始终存储的是上次求值的 newIndex,一旦本次求值的 newIndex 小于 maxNewIndexSoFar,这说明顺序遍历旧子序列的节点在新子序列中的索引并不是一直递增的,也就说明存在移动的情况。

除此之外,我们也会更新新旧子序列中匹配的节点,另外如果所有新的子序列节点都已经更新,而对旧子序列遍历还未结束,说明剩余的节点就是多余的,删除即可。

至此,我们完成了新旧子序列节点的更新、多余旧节点的删除,并且建立了一个 newIndexToOldIndexMap 存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,并确定是否有移动。

cde 节点被更新,f 节点被删除,newIndexToOldIndexMap 的值为 [5, 3, 4 ,0],此时 moved 也为 true,也就是存在节点移动的情况

移动和挂载新节点

处理未知子序列的最后一个流程

  1. const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. let i = 0
  3. const l2 = c2.length
  4. // 旧子节点的尾部索引
  5. let e1 = c1.length - 1
  6. // 新子节点的尾部索引
  7. let e2 = l2 - 1
  8. // 1. 从头部开始同步
  9. // i = 0, e1 = 6, e2 = 7
  10. // (a b) c d e f g
  11. // (a b) e c d h f g
  12. // 2. 从尾部开始同步
  13. // i = 2, e1 = 6, e2 = 7
  14. // (a b) c (d e)
  15. // (a b) (d e)
  16. // 3. 普通序列挂载剩余的新节点, 不满足
  17. // 4. 普通序列删除多余的节点,不满足
  18. // i = 2, e1 = 4, e2 = 5
  19. // 旧子节点开始索引,从 i 开始记录
  20. const s1 = i
  21. // 新子节点开始索引,从 i 开始记录
  22. const s2 = i //
  23. // 5.1 根据 key 建立新子序列的索引图
  24. // 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
  25. // 5.3 移动和挂载新节点
  26. // 仅当节点移动时生成最长递增子序列
  27. const increasingNewIndexSequence = moved
  28. ? getSequence(newIndexToOldIndexMap)
  29. : EMPTY_ARR
  30. let j = increasingNewIndexSequence.length - 1
  31. // 倒序遍历以便我们可以使用最后更新的节点作为锚点
  32. for (i = toBePatched - 1; i >= 0; i--) {
  33. const nextIndex = s2 + i
  34. const nextChild = c2[nextIndex]
  35. // 锚点指向上一个更新的节点,如果 nextIndex 超过新子节点的长度,则指向 parentAnchor
  36. const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
  37. if (newIndexToOldIndexMap[i] === 0) {
  38. // 挂载新的子节点
  39. patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG)
  40. }
  41. else if (moved) {
  42. // 没有最长递增子序列(reverse 的场景)或者当前的节点索引不在最长递增子序列中,需要移动
  43. if (j < 0 || i !== increasingNewIndexSequence[j]) {
  44. move(nextChild, container, anchor, 2)
  45. }
  46. else {
  47. // 倒序递增子序列
  48. j--
  49. }
  50. }
  51. }
  52. }

前面已经判断了是否移动,如果 movedtrue 就通过 getSequence(newIndexToOldIndexMap) 计算最长递增子序列

接着采用倒序的方式遍历新子序列,因为倒序遍历可以方便我们使用最后更新的节点作为锚点。

在倒序的过程中,锚点指向上一个更新的节点

  • 然后判断 newIndexToOldIndexMap[i] 是否为 0,如果是则表示这是新节点,就需要挂载它;
  • 接着判断是否存在节点移动的情况,如果存在的话则看节点的索引是不是在最长递增子序列中,如果在则倒序最长递增子序列,否则把它移动到锚点的前面。

用前面的例子展示一下这个过程,此时 toBePatched 的值为 4j 的值为 1,最长递增子序列 increasingNewIndexSequence 的值是 [1, 2]。在倒序新子序列的过程中:

  • 首先遇到节点 i,发现它在 newIndexToOldIndexMap 中的值是 0,则说明它是新节点,我们需要挂载它;
  • 然后继续遍历遇到节点 d,因为 movedtrue,且 d 的索引存在于最长递增子序列中,则执行 j-- 倒序最长递增子序列,j 此时为 0
  • 接着继续遍历遇到节点 c,它和 d 一样,索引也存在于最长递增子序列中,则执行 j--j 此时为 -1
  • 接着继续遍历遇到节点 e,此时 j-1 并且 e 的索引也不在最长递增子序列中,所以做一次移动操作,把 e 节点移到上一个更新的节点,也就是 c 节点的前面。

新子序列倒序完成,即完成了新节点的插入和旧节点的移动操作,也就完成了整个核心 diff 算法对节点的更新。

可以看到新子序列中的新节点 i 被挂载,旧子序列中的节点 e 移动到了 c 节点前面,至此在已知旧子节点 DOM 结构和 vnode、新子节点 vnode 的情况下,求解出生成新子节点的 DOM 的更新、移动、删除、新增等系列操作,并且以一种较小成本的方式完成 DOM 更新

子节点更新调用的是 patch 方法, Vue.js 正是通过这种递归的方式完成了整个组件树的更新。

最长递增子序列

最长递增子序列