从 Preact 源码一窥 React 原理(二):Diff 算法

本文深入探讨 Preact 的 Diff 算法,讲解如何通过对比两个 DOM 树来确定最小更新操作,降低渲染成本。文章详细介绍了渲染过程、diffChildren 函数、diffElementNodes 和 diffProps 函数,揭示了 Preact 中虚拟 DOM 与真实 DOM 的同步策略。通过对关键函数的分析,帮助读者理解 React 的启发式 Diff 算法及其在 Preact 中的实现。
摘要由CSDN通过智能技术生成

系列文章:

  1. 从 Preact 源码一窥 React 原理(一):JSX 渲染
  2. 从 Preact 源码一窥 React 原理(二):Diff 算法(本文)
  3. 从 Preact 源码一窥 React 原理(三):组件

前言

前一篇文章《从 Preact 源码一窥 React 原理(二):JSX 渲染》作为铺垫,简单介绍了 JSX 转化为 Preact 中虚拟 DOM 节点 VNode 的相关函数以及数据结构。
本文则将走进 Preact 中最为核心的部分之一:Diff 算法,介绍 Preact 如何渲染并更新得到的 VNode 树。
为了简单起见,本文主要内容仅集中于非组件节点的 Diff 算法,对于函数组件或类组件的相关操作,将在后续文章中进行介绍。

Diff 算法

什么是 Diff 算法?为什么我们需要 Diff 算法?
我们知道 Web 页面是由 DOM 树构成的(事实上,浏览器对于 Web 页面的渲染还会生成 CSSOM 树、渲染树等结构,但是直接和前端开发者打交道的就是 DOM 树了),而 DOM 树更新所带来的操作代价(重排、重绘)是昂贵的。
Diff 算法用于比较两个 DOM 树之间的差异,并确定最小的 DOM 更新操作。给定两个需要比较的 DOM 树,标准的 Diff 算法时间复杂度为 O(n^3),这一复杂度在实际应用中是不可接受的。为此,React 提出了一种启发式算法将算法复杂度降低为 O(N)。该启发式算法基于一些假设,放宽了计算结果最小操作的限制,转向较优的结果,其内容为:

  • Two elements of different types will produce different trees.
  • The developer can hint at which child elements may be stable across different renders with a key prop.

来自Reactjs.org - reconciliation

翻译一下:

  • 两个不同类型的元素将会生成不同的树;
  • 开发者可以通过 key 值来指定在不同的树中可能稳定的子节点。

根据以上的假设,React 会对两个 DOM 树执行平级的比较,并通过 key 值来确定可能相同的节点进行递归比较;不存在 key 值时,则比较子节点中类型是否相同,对于相同类型的子节点进行递归比较,不同类型的旧的子节点将直接被删除并在父节点上插入新的子节点。

Preact 所采用的 Diff 算法基本思路与 React 一致,不同之处在于 Preact 仅仅在内存中维护一棵虚拟 DOM 树,并添加了真实 DOM 与虚拟 DOM 之间的相互引用。因此每次执行 Diff 操作时,比较的事实上是新的虚拟 DOM 树与真实 DOM 树,并在比较过程中同时执行 DOM 操作。具体的算法细节参见下文。

渲染

上一篇文章中使用了这样一个示例作为引子:

import {
    h, render } from 'preact';

render((
	<div id="foo">
		<span>Hello, world!</span>
		<button onClick={
    e => alert("hi!") }>Click Me</button>
	</div>
), document.body);

我们已经对其中的 h函数进行了分析,了解了其如何与 Babel 相结合生成 VNode 树。
接下来就该看看 render函数是如何执行渲染操作的了:

// src/render.js
export function render(vnode, parentDom) {
   
	if (options.root) options.root(vnode, parentDom);
	let oldVNode = parentDom._prevVNode;
	vnode = createElement(Fragment, null, [vnode]);

	let mounts = [];
	diffChildren(parentDom, parentDom._prevVNode = vnode, oldVNode, 
		EMPTY_OBJ, parentDom.ownerSVGElement!==undefined, 
		oldVNode ? null : EMPTY_ARR.slice.call(parentDom.childNodes), 
		mounts, vnode, EMPTY_OBJ);
	commitRoot(mounts, vnode);
}

略去第一句(options 为 Preact 提供了一些调试相关的钩子,与功能实现不想关联,略去不谈),首先 render 函数获取挂载在容器节点中的 _prevVNode 然后将新的 vnode 包裹为 Fragment 节点的子节点(Fragment节点本身并没有意义,只是作为子节点集合的占位符)。
然后将新旧两个节点传入 diffChildren 函数中,通过其进行新 vnode 的渲染。其中,mount 用于保存新挂载的节点,并在 diff 执行结束后,通过调用 commitRoot 函数对新挂载的组件节点调用 componentDidMount 钩子。

render 函数的逻辑较为简单,核心就在于调用 diffChildren 函数对新的虚拟 DOM 进行渲染,也就是说:对空树和虚拟 DOM 树执行 Diff 操作,事实上等价于渲染该虚拟 DOM 树。

diffChildren 函数

render 函数中所调用的 diffChildren 函数包含大量的参数,光是看一句函数调用就能够拆成好几行教人看的迷糊。
因此,在介绍 diffChildren 具体逻辑之前,有必要对其参数进行介绍:

  • parentDom:对两个 children 进行比较的父真实 DOM 节点;
  • newParentVNode:新的父虚拟 DOM 节点;
  • oldParentVNode:旧的父虚拟 DOM 节点;
  • context:Legacy Context API 所向下传递的 context 值;
  • isSvg:标示真实 DOM 是否为 SVG 节点;
  • excessDomChildren:多余的真实 DOM 子节点,部分节点将在子节点的 DOM 操作中进行复用,剩余的部分则会在 diffChildren 执行结束后卸载;
  • mounts:存储新挂载的组件,用于在 Diff 操作执行结束后对其调用 componentDidMount 钩子;
  • ancestorComponent:Diff 发生的最近的父组件,Diff 操作中出现的错误将由该父组件进行捕获;
  • oldDom:新的真实 DOM 节点将被挂载在 oldDom 附近,当首次渲染时,其值为 null,并且在大多数情况下,其会从 oldChildren[0]._dom 值开始。

列出 diffChildren 诸多参数作为参考,能够更好的帮助我们理解函数实现中的各部分的含义。
diffChildren 的实现如下所示:

export function diffChildren(parentDom, newParentVNode, oldParentVNode, context, isSvg, excessDomChildren, mounts, ancestorComponent, oldDom) {
   
	let childVNode, i, j, p, index, oldVNode, newDom,
		nextDom, sibDom, focus;

	// PART 1
	// 展平所有 props.children 中的数组节点并提取到 _children 中
	let newChildren = newParentVNode._children || toChildArray(newParentVNode.props.children, newParentVNode._children=[], coerceToVNode);
	let oldChildren = oldParentVNode!=null && oldParentVNode!=EMPTY_OBJ && oldParentVNode._children || EMPTY_ARR;

	let oldChildrenLength = oldChildren.length;

	// PART 2
	// 只有在 render 函数和 diffElementNodes 函数调用 diffChildren 时,oldDom 才会等于 EMPTY_OBJ
	// 当 excessDomChildren 不为空时,将 excessDomChildren 第一个非空元素作为 oldDom;
	// 否则将 oldChildren 中第一个非空的 _dom 作为 oldDom
	if (oldDom == EMPTY_OBJ) {
   
		oldDom = null;
		if (excessDomChildren!=null) {
   
			for (i = 0; i < excessDomChildren.length; i++) {
   
				if (excessDomChildren[i]!=null) {
   
					oldDom = excessDomChildren[i];
					break;
				}
			}
		}
		else {
   
			for (i = 0; i < oldChildrenLength; i++) {
   
				if (oldChildren[i] && oldChildren[i]._dom) {
   
					oldDom = oldChildren[i]._dom;
					break;
				}
			}
		}
	}
	
	// PART 3
	for (i=0; i<newChildren.length; i++) {
   
		// 拷贝包含 dom 的 VNode 并将非 VNode 的节点转化为 VNode
		childVNode = newChildren[i] = coerceToVNode(newChildren[i]);
		oldVNode = index = null;
		
		// PART 4
		// 首先判断相同下标的 VNode 是否拥有相同的 key / VNode 类型
		p = oldChildren[i];
		if (p != 
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值