vue.js 3设计与实现 -- 简单 Diff 算法

从本章开始,我们将介绍渲染器的核心 Diff 算法。简单来说,当新旧 vnode 的子节点都是一 组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。我们知道,操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。

一、减少 DOM 操作的性能开销

​ 核心 Diff 只关心新旧虚拟节点都存在一组子节点的情况。在上一章中,我们针对两组子节点的更新,采用了一种简单直接的手段,即卸载全部旧子节点,再挂载全部新子节点。这么做的确可以完成更新,但由于没有复用任何 DOM 元素,所以会产生极大的性能开销。

以下面的新旧虚拟节点为例:

// 旧 vnode 
const oldVNode = {
    
    type: 'div', 
    children: [ 
        {
    type: 'p', children: '1' }, 
        {
    type: 'p', children: '2' }, 
        {
    type: 'p', children: '3' } 
    ] 
} 

// 新 vnode 
const newVNode = {
    
    type: 'div', 
    children: [ 
        {
    type: 'p', children: '4' }, 
        {
    type: 'p', children: '5' }, 
        {
    type: 'p', children: '6' } 
    ] 
}

​ 按照之前的做法,当更新子节点时,我们需要执行 6 次 DOM 操作:

  • 卸载所有旧子节点,需要 3 次 DOM 删除操作;
  • 挂载所有新子节点,需要 3 次 DOM 添加操作。

​ 但是,通过观察上面新旧 vnode 的子节点,可以发现:

  • 更新前后的所有子节点都是 p 标签,即标签元素不变;
  • 只有 p 标签的子节点(文本节点)会发生变化。

​ 例如, oldVNode 的第一个子节点是一个 p 标签,且该 p 标签的子节点类型是文本节点,内容是 ‘1’。而 newVNode 的第一个子节点也是一个 p 标签,它的子节点的类型也是文本节点,内容是 ‘4’。可以发现,更新前后改变的只有 p 标签文本节点的内容。所以,最理想的更新方式是, 直接更新这个 p 标签的文本节点的内容。这样只需要一次 DOM 操作,即可完成一个 p 标签更新。 新旧虚拟节点都有 3 个 p 标签作为子节点,所以一共只需要 3 次 DOM 操作就可以完成全部节点的更新。相比原来需要执行 6 次 DOM 操作才能完成更新的方式,其性能提升了一倍。

​ 按照这个思路,我们可以重新实现两组子节点的更新逻辑,如下面 patchChildren 函数的代码所示:

function patchChildren(n1, n2, container) {
    
    if (typeof n2.children === 'string') {
    
    	// 省略部分代码
    } else if (Array.isArray(n2.children)) {
    
        // 重新实现两组子节点的更新方式
        // 新旧 children 
        const oldChildren = n1.children 
        const newChildren = n2.children 
        // 遍历旧的 children 
        for (let i = 0; i < oldChildren.length; i++) {
    
            // 调用 patch 函数逐个更新子节点
            patch(oldChildren[i], newChildren[i]) 
        } 
    } else {
    
    	// 省略部分代码
    } 
} 

​ 在这段代码中,oldChildren 和 newChildren 分别是旧的一组子节点和新的一组子节点。我们遍历前者,并将两者中对应位置的节点分别传递给 patch 函数进行更新。patch 函数在执行更新时,发现新旧子节点只有文本内容不同,因此只会更新其文本节点的内容。这样,我们就成功地将 6 次 DOM 操作减少为 3 次。图 1-1 是整个更新过程的示意图,其中菱形代表新子节点,矩形代表旧子节点,圆形代表真实 DOM 节点。

在这里插入图片描述

图 1-1 仅更新文本子节点

​ 这种做法虽然能够减少 DOM 操作次数,但问题也很明显。在上面的代码中,我们通过遍历旧的一组子节点,并假设新的一组子节点的数量与之相同,只有在这种情况下,这段代码才能正确地工作。但是,新旧两组子节点的数量未必相同。当新的一组子节点的数量少于旧的一组子节点的数量时,意味着有些节点在更新后应该被卸载,如图 1-2 所示。

在这里插入图片描述

图 1-2 卸载已经不存在的节点

​ 在图 1-2 中,旧的一组子节点中一共有 4 个 p 标签,而新的一组子节点中只有 3 个 p 标签。 这说明,在更新过程中,需要将不存在的 p 标签卸载。类似地,新的一组子节点的数量也可能比旧的一组子节点的数量多,如图 1-3 所示。

在这里插入图片描述

图 1-3 挂载新的节点

​ 在图 1-3 中,新的一组子节点比旧的一组子节点多了一个 p 标签。在这种情况下,我们应该挂载新增节点。

​ 通过上面的分析我们意识到,在进行新旧两组子节点的更新时,不应该总是遍历旧的一组子节点或遍历新的一组子节点,而是应该遍历其中长度较短的那一组。这样,我们才能够尽可能多地调用 patch 函数进行更新。接着,再对比新旧两组子节点的长度,如果新的一组子节点更长, 则说明有新子节点需要挂载,否则说明有旧子节点需要卸载。最终实现如下:

function patchChildren(n1, n2, container) {
    
	if (typeof n2.children === 'string') {
    
		// 省略部分代码
	} else if (Array.isArray(n2.children)) {
    
		const oldChildren = n1.children 
		const newChildren = n2.children 
		// 旧的一组子节点的长度
		const oldLen = oldChildren.length 
		// 新的一组子节点的长度
		const newLen = newChildren.length 
		// 两组子节点的公共长度,即两者中较短的那一组子节点的长度
		const commonLength = Math.min(oldLen, newLen) 
		// 遍历 commonLength 次
		for (let i = 0; i < commonLength; i++) {
    
			patch(oldChildren[i], newChildren[i], container) 
		} 
		// 如果 newLen > oldLen,说明有新子节点需要挂载
		if (newLen > oldLen) {
    
			for (let i = commonLength; i < newLen; i++) {
    
				patch(null, newChildren[i], container) 
			} 
		} else if (oldLen > newLen) {
    
		// 如果 oldLen > newLen,说明有旧子节点需要卸载
			for (let i = commonLength; i < oldLen; i++) {
    
				unmount(oldChildren[i]) 
			} 
		} 
	} else {
    
		// 省略部分代码
	} 
} 

​ 这样,无论新旧两组子节点的数量关系如何,渲染器都能够正确地挂载或卸载它们。

二、DOM 复用与 key 的作用

​ 在上一节中,我们通过减少 DOM 操作的次数,提升了更新性能。但这种方式仍然存在可优化的空间。举个例子,假设新旧两组子节点的内容如下:

// oldChildren 
[
    {
    type: 'p' }, 
	{
    type: 'div' }, 
	{
    type
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值