从 Preact 源码一窥 React 原理(二):Diff 算法
系列文章:
前言
前一篇文章《从 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.
翻译一下:
- 两个不同类型的元素将会生成不同的树;
- 开发者可以通过 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 !=