快速Diff算法

63 阅读2分钟

快速Diff算法

作者对比了快速Diff算法和双端Diff算法,发现快速Diff算法的性能更优。

预处理

快速Diff算法增加了预处理的步骤。预处理剩下的节点才进行核心Diff操作。

处理前置节点

function patchKeyedChildren(n1, n2, container) {
    // 快速Diff算法
    const oldChildren = n1.children
    const newChildren = n2.children
    // 处理前置节点
    let j = 0
    let oldVNode = oldChildren[i]
	let newVNode = newChildren[j]

    while (newVNode.key === oldVNode.key) {
        patch(oldVNode, newVNode, container)
        j++
        oldVNode = oldChildren[j]
        newVNode = newChildren[j]
    }
}

处理前后置置节点

function patchKeyedChildren(n1, n2, container) {
    // 快速Diff算法
    const oldChildren = n1.children
    const newChildren = n2.children
    // 处理前置节点...
    // ...
    // 处理后置节点
    let newEnd = newChildren.length - 1
	let oldEnd = oldChildren.length - 1
    oldVNode = oldChildren[oldEnd]
	newVNode = newChildren[newEnd]
    while (newVNode.key === oldVNode.key) {
        patch(oldVNode, newVNode, container)
        newEnd--
        oldEnd--
        oldVNode = oldChildren[oldEnd]
        newVNode = newChildren[newEnd]
    }
}

挂载新节点

当旧的一组子节点全部被预处理,而在新的一组子节点,还有未被处理的节点,则剩余的节点则是需要挂载的新节点:

  • oldEnd < j成立,说明预处理后,旧的一组子节点全部被处理
  • ```j <= newEnd``成立,说明预处理后,新的一组字节中还有未被处理的节点,这些节点就是需要被挂载的新节点
  • 找到需要挂载的节点:j <= newEnd之间的节点就是需要挂载的节点
  • 找到锚点元素
function patchKeyedChildren(n1, n2, container) {
    // 快速Diff算法
    const oldChildren = n1.children
    const newChildren = n2.children
    // 处理前置节点...
    let j = 0
    // ...
    // 处理后置节点
    let newEnd = newChildren.length - 1
	let oldEnd = oldChildren.length - 1
    // ...
    
    // 挂载新节点
    if (oldEnd < j && j <= newEnd) {
        // 锚点
        const anchorIndex = newEnd + 1
        const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
        while (j <= newEnd) {
           patch(null, newChildren[j++], container, anchor) 
        }
    }
}

卸载旧节点

当新的一组子节点全部被预处理,而在旧的一组子节点,还有未被处理的节点,则剩余的节点则是需要卸载的节点:

  • newEnd < j成立,说明预处理后,新的一组子节点全部被处理
  • ```j <= oldEnd``成立,说明预处理后,旧的一组字节中还有未被处理的节点,这些节点就是需要卸载的节点
  • 找到需要卸载的节点:j <= oldEnd之间的节点就是需要卸载的节点
function patchKeyedChildren(n1, n2, container) {
    // 快速Diff算法
    const oldChildren = n1.children
    const newChildren = n2.children
    // 处理前置节点...
    let j = 0
    // ...
    // 处理后置节点
    let newEnd = newChildren.length - 1
	let oldEnd = oldChildren.length - 1
    // ...
    if (oldEnd < j && j <= newEnd) {
        // 挂载新节点
        // ...
    } else if (j > newEnd && j <= oldEnd) {
        // 卸载
        while (j <= oldEnd) {
            unmount(oldChildren[j++])
        }
    }
}

判断是否需要进行DOM的移动操作

上面一节的例子比较理想,预处理后,只需要挂载或者卸载操作。但实际情况可能会很复杂:

let oldVNode = {
    type: 'div',
    children: [
        {type: 'p', key: 1},
        {type: 'p', key: 2},
        {type: 'p', key: 4},
        {type: 'p', key: 5},
        {type: 'p', key: 3},
    ]
}
let newVNode = {
    type: 'div',
    children: [
        {type: 'p', key: 5},
        {type: 'p', key: 4},
        {type: 'p', key: 9}
    ]
}

该例经过预处理后,新旧两组子节点中都有未被处理的节点。这时候需要额外的逻辑处理:

  • 判断是否有节点需要移动,以及如何移动
  • 找到那些需要被挂载或卸载的节点

构造source

source数组用来存储新一组子节点中未被处理子节点在旧的一组子节点中的位置索引,后面将会使用它计算出一个最长递增子序列,用于辅助完成DOM移动的操作。 source数组的长度等于新的一组子节点中经过预处理后剩余未处理节点的数量,并且source中每一项初始值都是-1

function patchKeyedChildren(n1, n2, container) {
    // 快速Diff算法
    const oldChildren = n1.children
    const newChildren = n2.children
    // 处理前置节点...
    let j = 0
    // ...
    // 处理后置节点
    let newEnd = newChildren.length - 1
	let oldEnd = oldChildren.length - 1
    // ...
    if (oldEnd < j && j <= newEnd) {
        // 挂载新节点...
    } else if (j > newEnd && j <= oldEnd) {
        // 卸载...
    } else {
        const count = newEnd - j + 1
        let source = new Array(count)
        source.fill(-1)

        let oldStart = j
		let newStart = j

        // 时间复杂度是O(n**2)
        for (let i = newStart; i < newEnd; i++) {
            for (let k = oldStart; k < oldEnd; k++) {
                if (newChildren[i].key === oldChildren[k].key) {
                    // 找到可复用的节点
                    patch(oldChildren[k], newChildren[i], container)
                    // prevCount是前置处理过的节点的个数
                    source[i - prevCount] = k
                    break
                }
            }
        }
    }
}

构造索引表

构造索引表(用来存储新的一组子节点中未被前置处理的子节点的key与节点在新的一组子节点中索引位置之间的映射)是为了优化上一步,因为两层for循环,时间复杂度为O(n**2),当新旧两组子节点很多的时候会带来性能问题。

function patchKeyedChildren(n1, n2, container) {
    // 快速Diff算法
    const oldChildren = n1.children
    const newChildren = n2.children
    // 处理前置节点...
    let j = 0
    // ...
    // 处理后置节点
    let newEnd = newChildren.length - 1
	let oldEnd = oldChildren.length - 1
    // ...
    if (oldEnd < j && j <= newEnd) {
        // 挂载新节点...
    } else if (j > newEnd && j <= oldEnd) {
        // 卸载...
    } else {
        const count = newEnd - j + 1
        let source = new Array(count)
        source.fill(-1)

        let oldStart = j
		let newStart = j
        // 构造索引表
        const keyIndex = {}
        for (let i = newStart; i <= newEnd; i++) {
            keyIndex[newChildren[i].key] = i
        }

        // 时间复杂度O(n)处理source
        for (let i = oldStart; i <= oldEnd; i++) {
            oldVNode = oldChildren[i]
            const k = keyIndex[oldVNode.key]
            if (typeof k !== 'undefined') {
                newVNode = newChildren[k]
                // 找到可复用的节点
                patch(oldVNode, newVNode, container)
                source[k - newStart] = i
            } else {
                // 旧子节点没有在索引表中找到对应的索引,说明它不存在与新子节点中,需要卸载
                unmount(oldVNode)
            }
        }
    }
}

DOM是否需要移动

判断节点是否需要移动 定义一个变量moved代表当前节点是否需要移动,pos代表遍历旧的子节点过程中遇到的最大索引值。判断是否需要移动,与简单Diff算法判断相似(如果在遍历过程中遇到的索引值呈递增趋势,则不需要移动;否则需要移动。

function patchKeyedChildren(n1, n2, container) {
    // 快速Diff算法
    const oldChildren = n1.children
    const newChildren = n2.children
    // 处理前置节点...
    let j = 0
    // ...
    // 处理后置节点
    let newEnd = newChildren.length - 1
	let oldEnd = oldChildren.length - 1
    // ...
    if (oldEnd < j && j <= newEnd) {
        // 挂载新节点...
    } else if (j > newEnd && j <= oldEnd) {
        // 卸载...
    } else {
        const count = newEnd - j + 1
        let source = new Array(count)
        source.fill(-1)

        let oldStart = j
		let newStart = j
        // 构造索引表
        const keyIndex = {}
        for (let i = newStart; i <= newEnd; i++) {
            keyIndex[newChildren[i].key] = i
        }

        // 是否需要移动
        let moved = false
        // 记录最大索引值k
		let pos = 0

        // 时间复杂度O(n)处理source
        for (let i = oldStart; i <= oldEnd; i++) {
            oldVNode = oldChildren[i]
            const k = keyIndex[oldVNode.key]
            if (typeof k !== 'undefined') {
                newVNode = newChildren[k]
                // 找到可复用的节点
                patch(oldVNode, newVNode, container)
                source[k - newStart] = i

                // 判断节点是否需要移动
                if (K < pos) {
                    // 非递增,需要移动
                    moved = true
                } else {
                    // 呈递增趋势,不需要移动,保证pos为当前最大索引值k
                    pos = k
                }
            } else {
                // 旧子节点没有在索引表中找到对应的索引,说明它不存在与新子节点中,需要卸载
                unmount(oldVNode)
            }
        }
    }
}

如何移动DOM

计算最长递增子序列

function getSequence(arr) {
	// 获取一个数组浅拷贝。注意 p 的元素改变并不会影响 arr
	// p 是一个最终的回溯数组,它会在最终的 result 回溯中被使用
	// 它会在每次 result 发生变化时,记录 result 更新前最后一个索引的值
	const p = arr.slice()
	// 定义返回值(最长递增子序列下标),因为下标从 0 开始,所以它的初始值为 0
	const result = [0]
	let i, j, left, right, mid
	// 当前数组的长度
	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]
			// 所以我们还需要确定当前的序列是递增的。
			// 计算方式就是通过:二分查找来进行的

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

重新编号索引

上一步得到了最长递增子序列,为了让子序列与新的索引值产生对应关系,需要对节点进行重新编号。

对预处理过后剩余的子节点进行编号。

最大递增子序列对应的重新编号的新的一组子节点处的节点是不需要移动的。

创建索引i、s

为了完成节点的移动,还需要创建两个索引i、s

  • 索引i指向新的一组子节点的最后一个节点
  • 索引s指向最长递增子序列中的最后一个元素
function patchKeyedChildren(n1, n2, container) {
    // ...
	if (oldEnd < j && j <= newEnd) {
        // ...
    } else if (j > newEnd && j <= oldEnd) {
        // ...
    } else {
        // ...
        const seq = getSequence(source)
        let s = seq.length - 1
        let i = count - 1
        for (i; i >= 0; i--) {
            if (source[i] === -1) {
                // 这里要先判断需要挂载的节点
                const pos = i + newStart
                const newVNode = newChildren[pos]
                const nextPos = pos + 1
                const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
                patch(null, newVNode, container, anchor)
            } else if (moved && i === seq[s]) {
                // 不需要移动
                // 更新s索引
                s--
            } else if (moved && i !== seq[s]) {
                // 需要移动
                const pos = i + newStart
                const newVNode = newChildren[pos]
                const nextPos = pos + 1
                const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
                insert(newVNode.el, container, anchor)
            }
        }
    }
}