vue3源码学习-diff算法

259 阅读7分钟

当新旧子节点类型都为Array_Children类型时,就会触发diff算法,该方法内部主要被分为了五个场景。

1.自前向后对比

2.自后向前对比

3.新节点多余旧节点,需要挂载

4.旧节点多余新节点,需要卸载

5.乱序

自前向后diff,自后向前diff,新节点多于旧节点,旧节点多于新节点

/**
 * diff
 */
const patchKeyedChildren = (oldChildren, newChildren, container, parentAnchor) => {
	/**
	 * 索引
	 */
	let i = 0
	/**
	 * 新的子节点的长度
	 */
	const newChildrenLength = newChildren.length
	/**
	 * 旧的子节点最大(最后一个)下标
	 */
	let oldChildrenEnd = oldChildren.length - 1
	/**
	 * 新的子节点最大(最后一个)下标
	 */
	let newChildrenEnd = newChildrenLength - 1

	// 1. 自前向后的 diff 对比。经过该循环之后,从前开始的相同 vnode 将被处理
	while (i <= oldChildrenEnd && i <= newChildrenEnd) {
		const oldVNode = oldChildren[i]
		const newVNode = normalizeVNode(newChildren[i])
		// 如果 oldVNode 和 newVNode 被认为是同一个 vnode,则直接 patch 即可
		if (isSameVNodeType(oldVNode, newVNode)) {
			patch(oldVNode, newVNode, container, null)
		}
		// 如果不被认为是同一个 vnode,则直接跳出循环
		else {
			break
		}
		// 下标自增
		i++
	}
}
// 2. 自后向前的 diff 对比。经过该循环之后,从后开始的相同 vnode 将被处理
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
	const oldVNode = oldChildren[oldChildrenEnd]
	const newVNode = normalizeVNode(newChildren[newChildrenEnd])
	if (isSameVNodeType(oldVNode, newVNode)) {
		patch(oldVNode, newVNode, container, null)
	} else {
		break
	}
	oldChildrenEnd--
	newChildrenEnd--
}
// 3. 新节点多余旧节点时的 diff 比对。
if (i > oldChildrenEnd) {
	if (i <= newChildrenEnd) {
		const nextPos = newChildrenEnd + 1
		const anchor =
			nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor
		while (i <= newChildrenEnd) {
			patch(null, normalizeVNode(newChildren[i]), container, anchor)
			i++
		}
	}
}
// 4. 旧节点多与新节点时的 diff 比对。
else if (i > newChildrenEnd) {
	while (i <= oldChildrenEnd) {
		unmount(oldChildren[i])
		i++
	}
}

当新旧节点是同类型节点时,直接patch,否则跳出循环。自前向后的对比和自后向前的对比都是这样。当旧节点多与新节点时,需要判断一次是向前插入还是向后插入,然后在patch。当旧节点多余新节点时,卸载多的旧节点。

乱序diff

最长增长子序列

  function getSequence(arr) {
        // 获取一个数组浅拷贝。注意 p 的元素改变并不会影响 arr
        // p 是一个最终的回溯数组,它会在最终的 result 回溯中被使用
        // 它会在每次 result 发生变化时,记录 result 更新前最后一个索引的值
        const p = arr.slice()
        // 定义返回值(最长递增子序列下标),因为下标从 0 开始,所以它的初始值为 0
        const result = [0]
        let i, j, u, v, c
        // 当前数组的长度
        const len = arr.length
        // 对数组中所有的元素进行 for 循环处理,i = 下标
        for (i = 0; i < len; i++) {
          // 根据下标获取当前对应元素
          const arrI = arr[i]
          //
          if (arrI !== 0) {
            // 获取 result 中的最后一个元素,即:当前 result 中保存的最大值的下标
            j = result[result.length - 1]
            // arr[j] = 当前 result 中所保存的最大值
            // arrI = 当前值
            // 如果 arr[j] < arrI 。那么就证明,当前存在更大的序列,那么该下标就需要被放入到 result 的最后位置
            if (arr[j] < arrI) {
              p[i] = j
              // 把当前的下标 i 放入到 result 的最后位置
              result.push(i)
              continue
            }
            // 不满足 arr[j] < arrI 的条件,就证明目前 result 中的最后位置保存着更大的数值的下标。
            // 但是这个下标并不一定是一个递增的序列,比如: [1, 3] 和 [1, 2]
            // 所以我们还需要确定当前的序列是递增的。
            // 计算方式就是通过:二分查找来进行的

            // 初始下标
            u = 0
            // 最终下标
            v = result.length - 1
            // 只有初始下标 < 最终下标时才需要计算
            while (u < v) {
              // (u + v) 转化为 32 位 2 进制,右移 1 位 === 取中间位置(向下取整)例如:8 >> 1 = 4;  9 >> 1 = 4; 5 >> 1 = 2
              // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Right_shift
              // c 表示中间位。即:初始下标 + 最终下标 / 2 (向下取整)
              c = (u + v) >> 1
              // 从 result 中根据 c(中间位),取出中间位的下标。
              // 然后利用中间位的下标,从 arr 中取出对应的值。
              // 即:arr[result[c]] = result 中间位的值
              // 如果:result 中间位的值 < arrI,则 u(初始下标)= 中间位 + 1。即:从中间向右移动一位,作为初始下标。 (下次直接从中间开始,往后计算即可)
              if (arr[result[c]] < arrI) {
                u = c + 1
              } else {
                // 否则,则 v(最终下标) = 中间位。即:下次直接从 0 开始,计算到中间位置 即可。
                v = c
              }
            }
            // 最终,经过 while 的二分运算可以计算出:目标下标位 u
            // 利用 u 从 result 中获取下标,然后拿到 arr 中对应的值:arr[result[u]]
            // 如果:arr[result[u]] > arrI 的,则证明当前  result 中存在的下标 《不是》 递增序列,则需要进行替换
            if (arrI < arr[result[u]]) {
              if (u > 0) {
                p[i] = result[u - 1]
              }
              // 进行替换,替换为递增序列
              result[u] = i
            }
          }
        }
        // 重新定义 u。此时:u = result 的长度
        u = result.length
        // 重新定义 v。此时 v = result 的最后一个元素
        v = result[u - 1]
        // 自后向前处理 result,利用 p 中所保存的索引值,进行最后的一次回溯
        while (u-- > 0) {
          result[u] = v
          v = p[v]
        }
        return result
      }

该算法的实现主要是贪心+二分查找,最后回溯。以[10,9,2,5,3,7,101,6]举例。

arr = [10, 9, 2, 5, 3, 7, 101, 6]

result = [0] // 初始包含第一个元素的索引

p = arr.slice() // p数组用于回溯

// 完整处理过程:

// 1. i = 1, arrI = 9 // 9 < 10,通过二分查找替换 result = [1] // [9] p[1] = 0

// 2. i = 2, arrI = 2 // 2 < 9,通过二分查找替换 result = [2] // [2] p[2] = 0

// 3. i = 3, arrI = 5 // 5 > 2,直接追加 result = [2, 3] // [2, 5] p[3] = 2

// 4. i = 4, arrI = 3 // 3 > 2 且 3 < 5,替换索引1的值 result = [2, 4] // [2, 3] p[4] = 2

// 5. i = 5, arrI = 7 // 7 > 3,直接追加 result = [2, 4, 5] // [2, 3, 7] p[5] = 4

// 6. i = 6, arrI = 101 // 101 > 7,直接追加 result = [2, 4, 5, 6] // [2, 3, 7, 101] p[6] = 5

// 7. i = 7, arrI = 6 // 6 < 101,通过二分查找找到第一个大于6的位置 // 找到7(索引5),进行替换 result = [2, 4, 5, 7] // [2, 3, 7, 6] p[7] = 4

// 最后进行回溯 u = result.length // u = 4 v = result[u-1] // v = 6 (最后一个索引)

while (u-- > 0) { result[u] = v
v = p[v]
}

// 最终结果 result = [2, 4, 5, 6] 最终输出:[2, 4, 5, 6],对应的值序列是 [2, 3, 7, 101]关键点解释:

  • 虽然在处理最后一个元素6时,确实替换了一个位置,但在最终回溯时,算法选择了能形成最长递增子序列的路径

  • 回溯过程通过p数组找到真正的前驱节点,重建了完整的最长递增子序列

  • 最终结果 [2, 4, 5, 6] 代表的索引序列,对应的实际值 [2, 3, 7, 101] 确实是原数组中的最长递增子序列

这个算法在Vue的diff过程中用于优化节点移动,通过找到最长递增子序列,可以最小化需要移动的节点数量

//乱序的diff对比
    else {
      const oldStartIndex = i
      const newStartIndex = i
      const keyToNewIndexMap = new Map()
      for (i = newStartIndex; i <= newChildrenEnd; i++) {
        const nextChild = normalizeVNode(newChildren[i])
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }
      let j
      let patched = 0
      const toBePatched = newChildrenEnd - newStartIndex + 1
      let moved = false
      let maxNewIndexSoFar = 0
      const newIndexToOldIndexMap = new Array(toBePatched)
      for (i = 0; i < toBePatched; i++) {
        newIndexToOldIndexMap[i] = 0
      }
      for (i = oldStartIndex; i <= oldChildrenEnd; i++) {
        const prevChild = oldChildren[i]
        if (patched >= toBePatched) {
          unmount(prevChild)
          continue
        }
        let newIndex
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        }
        if (newIndex === undefined) {
          unmount(prevChild)
        } else {
          newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            moved = true
          }
          patch(prevChild, newChildren[newIndex], container, null)
          patched++
        }
      }

      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : []
      j = increasingNewIndexSequence.length - 1
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = i + newStartIndex
        const nextChild = newChildren[nextIndex]
        const anchor =
          nextIndex + 1 < newChildrenLength
            ? newChildren[nextIndex + 1].el
            : parentAnchor
        if (newIndexToOldIndexMap[i] === 0) {
          patch(null, nextChild, container, anchor)
        } else if (moved) {
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor)
          } else {
            j--
          }
        }
      }
    }

首先建立起新节点到旧节点的map映射,然后遍历旧节点,将不在于在新节点中的旧节点删除,并记录存在于新节点中的旧节点的位置的数组(用于找最长递增子序列),并patch更新。找到最长增长子序列,然后从后向前遍历新节点,(如果是最后一个节点,那么父节点是parent,如果不是最后一个节点,那么anchor是后面一个节点),新增的节点通过patch方法新增,在最长递增子序列的节点保持不动,不在最长递增子序列的节点进行移动。