likes
comments
collection
share

7-Vue源码之【Diff算法】

作者站长头像
站长
· 阅读数 39

为什么需要有 Diff 算法?

由于构建 DOM 的消耗远比 JS 消耗大的多,所以为了尽可能的减少 DOM 操作,框架的前辈们利用 VNode 和 Diff 算法,通过对比前后两个 Vnode 的不同,找出最少 DOM 操作的方式。

这里有个点,Vue2 的 Diff 算法时间复杂度为 O(n) 而 Vue3 的时间复杂度为 O(nlogN) ,那么为何还要换呢?

我们这里讲的时间复杂度,一直都是 JS 层面上的,但实际上 DOM 操作远比 JS 计算来的可怕。 而 Vue3 使用了最长递增子序列,虽然看似时间复杂度高了,但换来的却是更小的 DOM 操作方式。 算法能尽可能的找到 最少量的 DOM 操作,这相当于是用更精确的 JS 计算, 来抵消更大量 DOM 消耗。

key 值的作用

在 Vue3 源码中,会根据 Key 来决定是使用什么方法去更新节点。如果没有 Key ,那么默认会去更新所有节点,如果有 key 会尽可能的去复用节点。

Vue3 Diff

  1. 从前向后开始遍历
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // c1 的结束下标
let e2 = l2 - 1 // c2 的结束下标

// 从前向后遍历,如果一直进入 patch
while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = (c2[i] = optimized ? cloneIfMounted(c2[i] as VNode) : normalizeVNode(c2[i]))
  // 如果类型 和 key 都相似, 那么进入 patch
  if (isSameVNodeType(n1, n2)) {
    // patch()
  } else {
    break
  }
  i++
}

如果上一步中提前 break 出去,则带着 i 进入下一步操作。

  1. 从后向前遍历
while (i <= e1 && i <= e2) {
  const n1 = c1[e1]
  const n2 = (c2[e2] = optimized ? cloneIfMounted(c2[e2] as VNode) : normalizeVNode(c2[e2]))
  if (isSameVNodeType(n1, n2)) {
    // patch()
  } else {
    break
  }
  e1--
  e2--
}

前两步骤分别从前向后,在从后向前遍历了一遍。如果都执行到了 patch() 那么则说明前后两个节点数组基本一致。

如果有提前 break 出去,那么 ie1 , e2 的数值就会呈现下列三种情况:

  1. 初次遍历结束,再根据不同情况选择不同做法
// 1. 旧节点数组都遍历完了,新节点还没遍历完 `e1 < i <= e2`
if (i > e1) {
  if (i <= e2) {
    const nextPos = e2 + 1
    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
    while (i <= e2) {
      // 接下来的都是新增的节点,直接 patch(null, c2[i], ...args)
      patch(
        null,
        (c2[i] = optimized ? cloneIfMounted(c2[i] as VNode) : normalizeVNode(c2[i])),
        // ...
      )
      i++
    }
  }
}

// 2. 新节点数组都遍历完了,但是旧节点数组还存在
else if (i > e2) {
  while (i <= e1) {
    // 那么久需要删除多余节点
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

// 3. 如果新旧节点都没有遍历完
// 这便属于未知情况了,也是Vue3 Diff算法的核心。
else {
  // ...详见后续
}

前两种情况好理解,第三种情况,两边都有没遍历完的节点,Vue3 里是采用了 最长递增子序列 来确定 如何移动 DOM 能达到最优的性能

eg:比如新旧节点数组为: -- old 节点数组: ABCDE -- new 节点数组: EABCD

  1. 在没有 key 的情况下,vue 调用了 5 次 setElementText 去修改节点的文本

  2. 在有 key 的情况下,头 和 尾都不相同,则属于未知情况 当然我们可以一目了然只需要将 E 插入到 A 前面即可。但 JS 可不能一目了然, 所以需要我们用算法告诉他,算法发现: ABCD 属于最长的一个子序列, 这些子序列我们可以不做任何操作,而 E 不属于这序列, 那么只需要移动它即可(调用move方法)

这么算下来,用第二种方法,虽然多了 JS 层级的计算,但是 DOM 却只执行了一次,非常显著的提升

最长递增子序列

要想了解未知情况要如何处理,那么首先需要先了解下 最长递增子序列

const list = [3, 2, 8, 9, 5, 6, 7, 11, 15, 4]
const temp = [0, 1, 2, 3, 4, 5, 6, '7', 8, 9] // 下标

const getSequence = (list) => {
  let i = 0
  // 【注】 result 存放的是 最长递增序列对应 的 “下标”
  // result 中的这些下标在diff算法后续不需要做 move 处理
  const result = [0] // 先将下标 0 存储至 result 中
  while (i++ <= list.length) {
    // 在 Vue 3 Diff 中,0 表示该新节点不在旧节点的中,是需要进行新增的节点
    // 所以必须排除 数值为0 的节点
    if (list[i] != 0) {
      const lastIndex = result[result.length - 1]
      // 因为是找最长递增子序列,那么从 0 开始,遇到比 result 最后一个大的即添加
      if (list[i] > list[lastIndex]) {
        result.push(i)
        continue
      }
    }
  }

  return result
}

const result = getSequence(list)
console.log({ result }) // { result: [ 0, 2, 3, 7, 8 ] }  【注】是下标,具体值看 temp 数组上面的

上面的操作是将 list 中,比 result 里保存的最后一个下标对应的数 大的下标 都存储进 result 中。 上面的做法可以获得一条 递增 序列,但不一定是最长的序列

这时候就 Vue3 往里添加了二分法以保证长度最长

const getSequence = (list) => {
  let i = 0
  // 【注】 result 存放的是 最长递增序列对应 的 “下标”
  // result 中的这些下标在diff算法后续不需要做 move 处理
  const result = [0] // 先将下标 0 存储至 result 中
  while (i++ <= list.length) {
    // 在 Vue 3 Diff 中,0 表示该新节点不在旧节点的中,是需要进行新增的节点
    // 所以必须排除 数值为0 的节点
    if (list[i] != 0) {
      const lastIndex = result[result.length - 1]
      // 因为是找最长递增子序列,那么从 0 开始,遇到比 result 最后一个大的即添加
      if (list[i] > list[lastIndex]) {
        result.push(i)
        continue
      }

      // 如果 list[i] 小于则用二分法找出合适的位置(刚好由于 result 是递增的序列,所以可以使用二分法)
+      let start = 0, // 下标
+        end = result.length - 1 // 下标

+      while (start < end) {
+        const min = (start + end) >>> 1
+        if (list[i] > list[result[min]]) {
+          start = min + 1
+        } else {
+          end = min
+        }
+      }

      // 上面二分法循环结束,如果 end 位置上对应的数值更大,那么就应该被 i 替换掉
+      if (list[result[end]] > list[i]) {
+        result[end] = i
+      }
    }
  }

  return result
}

// 序列值变化过程
// 3
// 2
// 2 8
// 2 8 9
// 2 5 9
// 2 5 6
// 2 5 6 7
// 2 5 6 7 11
// 2 5 6 7 11 15
// 2 4 6 7 11 15  <--- 不对劲

OK,用上述分别先后获取到了 递增最长 的序列。

但是,我们会注意到,最后那个 4 很不对劲,因为他明明是最后一位,却因为 二分法 跑到了前面,破坏了正确性。

为了保证正确性,我们可以 回溯 我们的序列。

我们假设知道 list 中最大的一个数 15 的前一个数(这里的前一个数指的是 result 中的递增序列)的下标。

比如,正确的顺序 2 5 6 7 11 15 ,那么 15下标8,前一个数 11下标7,以此类推,参考下面的 回溯数组

const back = [*, *, 1, 2, 1, 4, 5, '6', 7, 1] // 回溯
const list = [3, 2, 8, 9, 5, 6, 7, 11, 15, 4]
const temp = [0, 1, 2, 3, 4, 5, 6, '7', 8, 9] // 下标

看得出来,只要我们能有 back 数组存储每个数值的前一个下标。那么我们就可以通过 回溯 ,从最大的 15 开始依次往前推。这样就不怕 二分法替换 导致序列不正确。因为我们前一个总是是正确的。

比如 list6 记录的前一个数值的下标为 下标4,对应的数值为 5,只要 6 是正确的,那么 5 肯定是正确的,以此类推,只要我们最后一个数 15 是正确的,那么整个序列就是正确的了~

4 就是一个错误的典型,他对应的下标是 下标9 ,但是我们所有数都没有对应 下标9 的下标,所以这个错误的就不会被考虑进来。

那么问题就是如何构建这个回溯表了。

const getSequence = (list) => {
+  let back = list.slice()
  let i = 0
  // 【注】 result 存放的是 最长递增序列对应 的 “下标”
  // result 中的这些下标在diff算法后续不需要做 move 处理
  const result = [0] // 先将下标 0 存储至 result 中
  while (i++ <= list.length) {
    // 在 Vue 3 Diff 中,0 表示该新节点不在旧节点的中,是需要进行新增的节点
    // 所以必须排除 数值为0 的节点
    if (list[i] != 0) {
      const lastIndex = result[result.length - 1]
      // 因为是找最长递增子序列,那么从 0 开始,遇到比 result 最后一个大的即添加
      if (list[i] > list[lastIndex]) {
        result.push(i)
+        back[i] = lastIndex // back中存储result下标数组中的前一个
        continue
      }

      // 如果 list[i] 小于则用二分法找出合适的位置(刚好由于 result 是递增的序列,所以可以使用二分法)
      let start = 0, // 下标
        end = result.length - 1 // 下标

      while (start < end) {
        const min = (start + end) >>> 1
        if (list[i] > list[result[min]]) {
          start = min + 1
        } else {
          end = min
        }
      }

      // 上面二分法循环结束,如果 end 位置上对应的数值更大,那么就应该被 i 替换掉
      if (list[result[end]] > list[i]) {
        result[end] = i
+        back[i] = result[end - 1] // 因为 result 存储的是 end, 所以back要存储 result[end - 1]
      }
    }
  }

  // 开始回溯
+  let len = result.length
+  let last = result[len - 1] // 取出 result 中,最大的数值对应的下标
  // 遍历,从后往前改写 result
+  while (len-- > 0) {
+    result[len] = last // 将 last 从后往前存储到 result 中,改写 result 使其正确
+    last = back[last] // 修改 last 为回溯数组对应的下标
+  }

  return result
}

可以看到只需要在每次 result 变化的地方,将其前一个值存储到 back 数组里,在到最后从 result 最大值开始回溯即可获取到 正确 的下标数组

这便是最长递增子序列,那么 Vue 又是如何通过这个来优化的呢?

我们再回到源码里,前面我们说了,Vue 的 diff 是先 从前往后再从后往前,遍历一次。如果两个数组 c1 c2 中存在 type,key 不同的节点(isSameVNodeType),那么就提前退出循环。

如果 c1 已经遍历完成,c2 还存在剩余,那么就遍历 c2 剩余的节点 patch 进去

如果 c2 已经遍历完成,c1 还存在剩余,那么将 c1 的那些多余节点都卸载掉

如果 c1 c2 都没遍历完成,则……

// 【注】 这里的代码,包裹在上面写的 else { // ...详见后续 } 里

// 排除 c1 c2 已遍历过的节点,重新调整 start下标
const s1 = i // c1 的新start下标
const s2 = i // c2 的新start下标

// 5.1 为剩余的新节点数组 c2 构建 key: index 的 Map
// 构建这个Map的目的:如果 后面 c1 能在这里找到对应的 i ,则说明该节点还保留着,且可能发生了位移
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
  const nextChild = (c2[i] = optimized ? cloneIfMounted(c2[i] as VNode) : normalizeVNode(c2[i]))
  if (nextChild.key != null) {
    keyToNewIndexMap.set(nextChild.key, i)
  }
}

// 5.2 循环遍历需要patch的旧节点,并尝试 patch 合适的节点 或者 remove不在的节点
let j
let patched = 0 // 记录已执行patch的新节点数量,用于处理如果在更新时更新过的数量大于需要更新的节点数量,就卸载对应旧节点
// 新数组的节点数量为 e2 - s2 + 1
const toBePatched = e2 - s2 + 1
let moved = false
// 用于跟踪是否有节点已移动 move
let maxNewIndexSoFar = 0

// 创建一个 新旧节点 对应关系的数组,这里全部赋值为 0
// 【注】 0 在这里表示的意思是: 该节点为新增的, 我们先假设 新数组节点 都是新增的
// 在根据后面的判断,来修改 newIndexToOldIndexList
const newIndexToOldIndexList = new Array(toBePatched).fill(0)

for (i = s1; i <= e1; i++) {
  const prevChild = c1[i]

  // 因为遍历是用旧数组下标 e1 遍历,所以,如果已patched节点数量 大于 新节点应有的节点数量时
  if (patched >= toBePatched) {
    // 依次卸载掉后续的旧节点
    unmount(prevChild, parentComponent, parentSuspense, true)
    continue
  }
  let newIndex

  // 如果 旧节点有 key,那么就去 keyToNewIndexMap(新节点MAP) 中找找看有没有对应的 index
  if (prevChild.key != null) {
    newIndex = keyToNewIndexMap.get(prevChild.key) // 有可能没找到,返回 undefined
  } else {
    // 没有key的节点,尝试在 新节点数组中 匹配相同类型的无key节点
    for (j = s2; j <= e2; j++) {
      // 如果 prevChild 和 c2 中的某个节点的 key, type 相同,那么尝试用该节点作为 newIndex
      // 注意要有 newIndexToOldIndexList[j - s2] === 0 作为前置条件,不等于 0 表示 j - s2 位置上的
      // 已经被修改过了,就不需要去判断这个位置
      if (newIndexToOldIndexList[j - s2] === 0 && isSameVNodeType(prevChild, c2[j] as VNode)) {
        newIndex = j
        break
      }
    }
  }
  if (newIndex === undefined) {
    // 如果没有对应key,且没找到相似的节点,那么卸载该 prevChild
    unmount(prevChild, parentComponent, parentSuspense, true)
  } else {
    // 如果 newIndex 有值,则说明发生了节点移动
    // 这里为啥是 i + 1 ?
    // 【注】 不能是 i ,因为 i 有可能为 0,而在 Vue 的代码里会把 0 作为 需要新增的节点 标志
    // 【注2】 不一定需要 i + 1 这里只是作为一个 递增标志 传递给 getSequence 的
    newIndexToOldIndexList[newIndex - s2] = i + 1

    // 这里用了 max 来判断,如果 newIndex 大于 max,则需要移动,
    // 否则给 moved 设为true ,表示之前移动过
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex
    } else {
      moved = true
    }

    // 只要有 newIndex 就需要构建,所以先 patch,至于 newIndexToOldIndexList 是用来处理 节点移动 的
    // Vue 的做法是,先 patch 给新的vnode赋值上 el ,位置啥的后面再处理~
    patch(prevChild, c2[newIndex] as VNode)
    patched++
  }
}

这里可能有 keyToNewIndexMapnewIndexToOldIndexList 不太好了解,这里举一个例子。

首先我们上面有写过 最长递增子序列 能够帮助 Vue 去判断如何做到最小 DOM 操作。那么这个 最长递增子序列父序列要从哪里来?

我们用 ['A', 'B', 'C', 'D', 'E'][A', 'B', 'E', 'C', 'D'] 来做例子。

// 旧节点: A B C D E
// 新节点: A B E C D

// 经过前两轮判断之后,只剩下
// 旧节点:C D E
// 新节点:E C D
// 需要判断,我们其实一眼就可以看出 C D 在新旧节点中的位置是一致的,符合我们对 最长递增子序列 的判断。
// 那么我们就可以传递给 getSequence([5, 3, 4]) // [5, 3, 4] 是假设值,只是用数字来表示我们的节点
// 这里, 5 是 对应 E节点
// 3 对应 C节点
// 4 对应 D节点

// 这样 getSequence 就会返回 [1, 2],【注】前面也提过 getSequence 返回的是 递增的子序列 的下标。
// 这里 C 和 D 是递增,分别在 下标1 和 下标2 的位置。所以 getSequence 返回 [1, 2]

// 那么我们就知道了这两个是递增的,当我们在后面遍历的过程中,遇到这2个节点,就不去处理。
// 而因为 E 节点不在这里,所以就需要移动(move)

// 5.3 移动 或 挂载
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexList) : []
j = increasingNewIndexSequence.length - 1
// 从最后面开始遍历,以便我们可以从最后一个 patch 的节点作为 anchor 往前遍历
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i
  const nextChild = c2[nextIndex] as VNode
  // 如果 nextIndex + 1 超过最大 length,则需要赋值 parentAnchor
  const anchor = nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor

  // 0 则表示是新增的节点,再次 patch
  if (newIndexToOldIndexList[i] === 0) {
    patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized)
  } else if (moved) {
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      move(nextChild, container, anchor, MoveType.REORDER)
    } else {
      j--
    }
  }
}

总结

Diff 算法的一切都是为了用相对高效的 JS 计算 取代 低效的 DOM 操作

所以哪怕时间复杂度相对高了些,也不用太在意(取舍,不可兼得)

不过,现在有两个流行的 无虚拟DOM 的框架,solidjsselvetjs ,Vue 也要做 无虚拟DOM 了,感慨技术之发展,后面也去学习学习,瞅瞅原理。

真好奇,没有虚拟 DOM 如何做到那种 DOM 改动的优化,模板感觉还好,毕竟有一层 AST 层了,应该可以好好操作一番, solidjs 是一种跟 React 一样的 jsx 语法,难道是通过 jsx 去弄。。。

(虽然可能虚拟 DOM 在未来会被淘汰,不过我们学的主要是那种设计,思路。。。其次就是这种知识从脑子穿过没留下什么痕迹的感觉很棒,(ㄒ o ㄒ))

转载自:https://juejin.cn/post/7237520441555370039
评论
请登录