react 对象渲染_轻烤 React 核心机制 Reconciliation

Reconciliation 是 React 的 Diff 算法,也是 React 的 核心机制。本文将会来研究 Reconciliation,看看 Reconciliation 这个算法是怎么工作的。由于是 React 的核心机制,所以涉及到很多的概念和逻辑,烧烤哥也只能“轻轻地”烤一下,总结一些主要的原理,具体到源码层面的实现细节,还有待深入研究(而且一篇文章的篇幅肯定是说不清也说不完)。

文章篇幅过长,建议收藏后观看

一、从 DOM 到 fiber 对象

首先来一道 “开胃前菜”,让我们来看看 DOM、Virtual DOM、React 元素、Fiber 对象的相关概念。

DOM:

文档对象模型,实际上就是一个树,树中的每一个节点就是 HTML 元素(Element),每个节点实际上是一个 JS 对象,这个对象除了包含了该元素的一些属性,还提供了一些接口(方法),以便编程语言可以插入和操作 DOM。但是 DOM 本身并没有针对动态 UI 的 Web 应用程序进行优化。因此,当一个页面有很多元素需要更新时,更新相对应的 DOM 会使得程序变得很慢。因为浏览器需要重新渲染所有的样式和 HTML 元素。这其实在页面没有任何变化的情况下也时常发生。

Virtual DOM:

为了优化“真实” DOM 的更新,衍生出了 「Virtual DOM」的概念。本质来说,Virtual DOM 是真实 DOM 的模拟,它实际上也是一棵树。真实的 DOM 树由真实的 DOM 元素组成,而 Virtual DOM 树是由 Virtual DOM 元素组成。

当 React 组件的状态发生变化时,会产生一棵新的 Virtual DOM 树,然后 React 会使用 diff 算法去比较新、旧两棵 Virtual DOM 树,得到其中的差异,然后将「差异」更新到真实的 DOM 树中,从而完成 UI 的更新。

要说明一点是:这里并不是说使用了 Virtual DOM 就可以加快 DOM  的操作速度,而是说 React 让页面在不同状态之间变化时,使用了次数尽量少的 DOM 操作来完成。

React 元素(React Element):

在 React 的世界中,React 给 Virtual DOM 元素取名为 React 元素(React Element)。也就是说,在 React 中的 Virtual DOM 树是由 React 元素组成的,树中每一个节点就是一个 React 元素。

我们从源码中(/package/shared/ReactElementType.js)来看看 React 元素的类型定义:

export type Source = {|
  fileName: string,
  lineNumber: number,
|};

export type ReactElement = {|
  $$typeof: any,
  type: any,
  key: any,
  ref: any,
  props: any,
  // ReactFiber
  _owner: any,
  ...
|};
  • $$typeof:React 元素的标志,是一个 Symbol 类型;
  • type:React 元素的类型。如果是自定义组件(composite component),那么 type 字段的值就是一个 class 或 function component;如果是原生 HTML (host component),如果 div、span 等,那么 type 的值就是一个字符串('div'、'span');
  • key:React 元素的 key,在执行 diff 算法时会用到;
  • ref:React 元素的 ref 属性,当 React 元素变为真实 DOM 后,返回真实 DOM 的引用;
  • props:React 元素的属性,是一个对象;
  • _owner:负责创建这个 React 元素的组件,另外从代码中的注释 “// ReactFiber” 可以知道,它里面包含了 React 元素关联的 fiber 对象实例;

当我们写 React 组件时,无论是 class 组件还是函数组件,return 的 JSX 会经过 JSX 编译器(JSX Complier)编译,在编译的过程中,会调用 React.createElement() 这个方法。

当调用 React.createElement() 的时候实际上调用的是 ReactElement.js(/packages/react/src/ReactElement.js) 中的 createElement() 方法。调用这个方法后,会创建一个 React 元素。

aa40242ff9a9b7696be5ba7bcaf62d20.png

在上面 ClickCounter 组件这个例子中, 的子组件。而   组件其实它本身其实也是一个组件。它是 组件的子组件:

class App extends React.Component {
    ...
    render() {
        return [
            
        ]
    }
}

所以在调用 render() 时,会创建 组件对应的 react element:

ca6ce464e0462b8fbf6a4e4b1adc67d7.png

当执行 ReactDOM.render() 后,创建的整棵 react rlement 树大致如下:

34c27873be85304caf947383642668a1.png

Fiber 对象:

每当我们创建一个 react element 时,还会创建一个与这个 react element 相关联的 fiber node。fiber node 为 Fiber 对象的实例。

Fiber 对象是一个用于保存「组件状态」、「组件对应的 DOM 的信息」、以及「工作任务 (work)」的数据结构,负责管理组件实例的更新、渲染任务、以及与其他 fiber node 的关系。每个组件(react element)都有一个与之对应关联的 Fiber 对象实例(fiber node),和 react element 不一样的是,fiber node 不需要再每一次界面更新的时候都重新创建一遍。

在执行 Reconciliation 这个算法的期间,组件 render 方法所返回的 react element 的信息(属性)都会被合并到对应的 fiber node 中。这些 fiber node 因此也组成了一棵与 react element tree 相对应的 fiber node tree。(我们要牢牢记住的是:每个 react element 都会有一个与之对应的 fiber node)。

Fiber 对象类型定义(/package/react-reconciler/src/ReactInternalTypes.js):

export type Fiber = {|
    tag: WorkTag;
    key: null | string;
    type: any;
    stateNode: any;
    updateQueue: mixed;
    memoizedState: any;
    memoizedProps: any,
    pendingProps: any;
    nextEffect: Fiber | null,
    firstEffect: Fiber | null,
    lastEffect: Fiber | null,
    return: Fiber | null;
    child: Fiber | null;
    sibling: Fiber | null;
    ...
|};
  • tag:这字段定义了 fiber node 的类型。在 Reconciliation 算法中,它被用于决定一个 fiber node 所需要完成的 work 是什么 ;
  • key:这个字段和 react element 的 key 的含义和内容有一样(因为这个 key 是从 react element 的key 那里直接拷贝赋值过来的),作为 children 列表中每一个 item 的唯一标识。它被用于帮助 React 去计算出哪个 item 被修改了,哪个 item 是新增的,哪个 item 被删除了。官方文档中有对 key 更详细的讲解;
  • type:这个字段表示与这个 fiber node 相关联的 react element 的类型。这个字段的值和 react element 的 type 字段值是一样的(因为这个 type 是从 react element 的 type 那里直接拷贝赋值过来的)。如果是自定义组件(composite component),那么 type 字段的值就是一个 class 或 function component;如果是原生 HTML (host component),如 div、span 等,那么 type 的值就是一个字符串('div'、'span');
  • updateQueue:这个字段用来存储组件状态更新、回调和 DOM 更新任务的队列,fiber node 正是通过这个字段,来管理 fiber node 所对应的 react element 的渲染、更新任务;(如果老铁们看过烧烤哥的那篇《烤透 React Hook》,就会知道,其实 updateQueue 存储的就是一个这个 fiber node 需要处理的 effect 链表);
  • memoizedState:已经被更新到真实 DOM 的 state(已经渲染到 UI 界面上的 state);
  • memoizedProps: 已经被更新到真实 DOM 的 props(已经渲染到 UI 界面上的 props),
  • pendingProps:等待被更新到真实 DOM 的 props;
  • return:这个字段相当于一个指针,指向父 fiber node;
  • child:这个字段相当于一个指针,指向子 fiber node;
  • sibling:这个字段相当于一个指针,指向兄弟 fiber node;
  • nextEffect:指向下一个带有 side-effect 的 fiber node;
  • firstEffect:指向第一个带有 side-effect 的 fiber node
  • lastEffect:指向最后一个带有 side-effect 的fiber node;

关于其他属性的解析,请看源码中的注释,或者这篇文章。

下面我们来看看上面例子中的 组件的 fiber node 长什么样吧:

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    alternate: null,
    key: null,
    updateQueue: null,
    memoizedState: {count: 0},
    pendingProps: {},
    memoizedProps: {},
    tag: 1,
    effectTag: 0,
    nextEffect: null
}
895c3997d822a487bb3bd476f3bf3020.png

的 fiber node:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    alternate: null,
    key: "2",
    updateQueue: null,
    memoizedState: null,
    pendingProps: {children: 0},
    memoizedProps: {children: 0},
    tag: 5,
    effectTag: 0,
    nextEffect: null
}
9b956d929571253b46e3c8e1be895c9f.png

在上述 的例子中,由于每个组件的 react element 都会有一个与之对应的 fiber node,因此我们会得到一棵 fiber node tree:

1c1443c892309f31b98fa96733b6ef5a.png
Side-effects 是啥?

在 fiber node 的类型定义中,有三个属性:firstEffect、lastEffect 和 nextEffect,他们指向的是带有“side-effects”的 fiber node。那 "side-effect" 到底是什么东西呢?写过 React 组件的老铁都知道,React 组件实际上就是一个函数,这个函数接收 props 和 state 作为输入,然后通过计算,最终返回 react element。在这个过程中,会进行一些操作,例如更改 DOM 结构、调用组件的生命周期等等,React 把这些“操作”统称为「side-effect」,简称 「effect」,也就是常说的“副作用”。官方文档中有对 effect 进行介绍。

大部分组件的 state 和 props 的更新都会导致 side-effect 的产生。此外,我们还可以通过 useEffect 这个 React Hook 来自定义一些 effect。在烧烤哥之前写的 《烤透 React Hook》一文中曾提到过,fiber node 的 effect 会以「循环链表」的形式存储,然后 fiber node 的 updateQueue 会指向这个 effect 循环链表。

9456d328b53d23fde219cc787856177a.png
Effects list:

在一个 fiber node tree 中,有一些 fiber node 是有 effect 需要处理的,而有一些 fiber node 是没有 effect 需要处理的。为了加快整棵 fiber node tree 的 effect 的处理速度,React 为那些带有 effect 需要处理的 fiber node 构建了一个链表,这个链表叫做 「effects list」。这个链表存储着那些带有 effect 的 fiber node。维护这个链表的原因是:因为遍历一个链表比遍历一整棵 fiber node tree 的速度要快得多,对于那些没有 effect 需要处理的 fiber node,我们没有必要花时间去迭代它。这个链表通过前面说过的 fiber node 的 firstEffect、lastEffect 和 nextEffect 三个属性来维护:firstEffect 指向第一个带有 effect 的 fiber node,lastEffect 指向最后一个带有 effect 的fiber node,nextEffect 指向下一个带有 effect 的 fiber node。

举个例子,下面有一个 fiber node tree,其中颜色高亮的节点时带有 effect 需要处理的 fiber node。假设我们的更新流程将会导致 H 被插入到 DOM 中,C 和 D 将会改变自身的属性(attribute),G将会调用自身的生命周期方法等等。

9a01a694db3d1ada2141745ff03cf932.png

那么,这个 fiber node tree 的 effect list 将会把这些节点连接到一起,这样,React 在遍历 fiber node tree 的时候可以跳过其他没有任何任务需要处理的 fiber node 了。

3f8638b6354729a61a9ca21d85d50e0a.png

小结:

我们再来回顾一下,React 对于页面上 UI 的一步步抽象转化:

0af9382652f01fab5b42f79db64ed912.png

二、React 架构

fddba68edeac57957b703c68a190ac2f.png

1、任务调度器 Scheduler:决定渲染(更新)任务优先级,将高优的更新任务优先交给 Reconciler;当 class 组件调用 render() 方法(或者 function 组件 return)时,实际上并不会马上就开始这个组件的渲染工作,此时只是会返回「渲染信息」(该渲染什么的描述),该描述包含了用户自己写的 React 组件(如 ),还有平台特定的组件(如浏览器的

)。然后 React 会通过 Scheduler 来决定在未来的某个时间点再来执行这个组件渲染任务。

2、协调器 Reconciler:负责找出前后两个 Virtual DOM(React Element)树的「差异」,并把「差异」告诉 Renderer。关于 协调器是怎么运作的,我们后面再来详细研究;

3、渲染器 Renderer:负责将「差异」更新到真实的 DOM 上,从而更新 UI;不同的平台配会有不同的 renderer。DOM 只是 React 能够适配的的渲染平台之一。其他主要的渲染平台还有 IOS 和安卓的视图层(通过 React Native 这个 renderer 来完成)。这种分离的设计意味着 React DOM 和 React Native 能使用独立的 renderer 的同时共用相同的,由 React core 提供的 reconciler。React Fiber 重写了 reconciler,但这事大致跟 rendering 无关。不过,无论怎么,众多 renderer 肯定是需要作出一些调整来整合新的架构。

三、Reconciliation 是啥?

React 是一个用于构建用户界面的 JavaScript 类库。React 的核心机制是跟踪组件状态变化,然后将更新的状态映射到用户界面上。

使用 React 时,组件中 render() 函数的作用就是创建一棵 react element tree(React 元素树)。当调用 setState(),即下一个 state 或 props 更新时,render() 函数将会返回一棵不同的 react element tree。接下来,React 将会使用 Diff 算法去高效地更新 UI,来匹配最近时刻的 React 元素树。这个 Diff 算法就是 Reconciliation 算法。

Reconciliation 算法主要做了两件事情:

找出两棵 react element tree 的差异;将差异更新到真实 DOM,从而完成 UI 的更新;

Stack Reconciler

在 React 15.x 版本以及之前的版本,Reconciliation 算法采用了栈调和器( Stack Reconciler )来实现,但是这个时期的栈调和器存在一些缺陷:不能暂停渲染任务,不能切分任务,无法有效平衡组件更新渲染与动画相关任务的执行顺序,即不能划分任务的优先级(这样有可能导致重要任务卡顿、动画掉帧等问题)。Stack Reconciler 的实现。

Fiber Reconciler

为了解决 Stack Reconciler 中固有的问题,以及一些历史遗留问题,在 React 16 版本推出了新的 Reconciliation 算法的调和器—— Fiber 调和器(Fiber Reconciler)来替代栈调和器。Fiber Reconciler 将会利用调度器(Scheduler)来帮忙处理组件渲染/更新的工作。此外,引入 fiber 这个概念后,原来的 react element tree 有了一棵对应的 fiber node tree。在 diff 两棵 react element tree 的差异时,Fiber Reconciler 会基于 fiber node tree 来使用 diff 算法,通过 fiber node 的 return、child、sibling 属性能更方便的遍历 fiber node tree,从而更高效地完成 diff 算法。

Fiber Reconciler 功能(优点)

能够把可中断的任务切片处理;能够调整任务优先级,重置并复用任务;可以在父子组件任务间前进后退切换任务;render 方法可以返回多个元素(即可以返回数组);支持异常边界处理异常;

四、Reconciliation 工作流程

上文提到,Reconciliation 算法主要做了两件事情:

找出两棵 react element tree 的差异;将差异更新到真实 DOM,从而完成 UI 的更新;

下面将围绕上述的“两件事情”,来看看 Reconciliation 算法是怎么运作的。

1、找出两棵 react element tree 的差异

三个策略

在对比两棵 react element tree 的时,React 制定了 3 个策略:

只对同级的 react element 进行对比。如果一个 DOM 节点在前后两次更新中跨越了层级,那么 React 不会尝试复用它;两个不同类型(type 字段不一样)的 react element 会产生不同的 react element tree。例如元素 div 变为 p,React 会销毁 div 及其子孙节点,并新建 p 及其子孙节点;开发者可以通过 key 属性来暗示哪些子元素在不同的渲染下能保持稳定。下面用一个例子说明一下 key 的作用:
// 更新前

"qianduan">前端

"shaokaotan">烧烤摊


// 更新后

"shaokaotan">烧烤摊

"qianduan">前端


假如没有 key,React 会认为 div 的第一个节点由 p 变为 h3,第二个子节点由 h3 变为 p。这符合第 2 个原则,因此会销毁并新建相应的 DOM 节点。

但当我们加上了 key 属性后,便指明了节点前后的对应关系,React 知道 key 为 “shaokao” 的 p 在更新后还存在,所以 DOM 节点可以复用,只是需要交换一下顺序而已。

Diff 具体过程

(关于 Diff 的源码:/packages/react-reconciler/src/ReactChildFiber.new.js)

根据上述的第一个策略“只对同级的 react element 对比”,意思就是只对同级的节点做对比。那“同级”的意思是什么呢?——直属于同一父节点的那些节点即为同级节点。举个例子:

b9173e19160786ee80ce46db54860a0f.png

如上图所示,新旧两棵树的根节点默认为同级节点:

旧 react element tree 的 节点 B、C、D 的父节点为 A;新 react element tree 的 节点 B、C、D 的父节点也为 A;所以旧 react element tree 的节点 B、C、D 和新 react element tree 的节点 B、C、D 属于同级节点。在 Diff 的过程中,将会对比新、旧 react element tree 的 B、C、D 的差异;同理,新旧 tree 的 E、F 节点的父节点均为 B,所以新旧 tree 的 E、F 节点为同级节点;

具体到对比某个节点时,可分 2 种情况:

若新旧节点(react element)的类型(type)或 key(如果没有 key,则看 index) 有其中一个不相同,则 DOM 不会被复用,直接销毁;若新旧节点(react element)的类型(type)和 key(如果没有 key,则看 index) 都相同,则会复用该 DOM;
对比不同类型(type)的 react element

当对比得出节点(react element) 的 type 不相同时 ,React 会销毁原来的节点及其子孙节点,然后重新创建一个新的节点及其子孙节点。例如:

// 旧

// 新

上面的例子中,原来的节点类型为 div,更新后节点的类型变为了 span,React 发现了这其中的不同后,会销毁 div 节点及其子节点(B 和 C),然后重新创建一个类型为 span 的节点及其子节点(B 和 C)。

f669685d9ee3686ac77b654a921a6050.png
对比相同类型(type)的 react element(Composite Component、Host Component)

当对比得出节点(react element)的类型(type)相同时,React 会保留该 react element 对应的 DOM 节点(复用该 DOM),然后仅比对及更新有改变的属性(attribute)。例如:

// 旧
"before" title="stuff" />
// 新
"after" title="stuff" />

通过对比,React 知道只需要修改 DOM 元素上的 className 属性即可。

当更新 style 属性时,React 仅更新有所改变的属性,例如:

// 旧

// 新

通过对比,React 知道只需要修改 DOM 元素上的 color 样式,而无需修改 fontWeight。

以上所举的例子都是 Host Component(原生 HTML 元素),假如是对比相同类型的 Composite Component(自己写的 React 组件),此时主要看的是组件的 props 和 state 有没有改变,假如有改变,则更新组件及其子组件。

在对比同级节点(react element)时,有以下 2 种情况考虑:

同级只有一个节点(例如上图的 G);同级有多个节点(例如上图的 B、C、D);
同级只有一个节点

这种情况相对简单,就是对比新旧两个节点而已,根据上面说的两种情况(节点类型相同、类型不同)判断处理即可。

同级有多个节点

当同级有多个节点时,需要处理 3 种情况:

节点更新(类型、属性更新)节点新增或删除节点移动位置

对于同级有多个节点的 Diff,一定属于以上三种情况中的一种或多种。React 团队发现,在日常开发中,相对于增加和删除,更新组件发生的频率更高,所以 React 的 Diff 算法会优先判断并处理节点的更新。

针对同级的多个节点,我们可以将其看做是一个链表(因为实际上同级的 react element  它们各自对应的 fiber node 会通过 sibling 字段来连接成一个单向链表)。Diff 算法将会对「新同级节点链表」进行 2 次遍历:

第一轮遍历:处理更新的节点(节点对应的 DOM 可复用,只需更新其中的一些属性就可以了);第二轮遍历:处理新增、删除、移动的节点;
第一轮遍历

(为了方便说明,以下将会分别把「旧 react element tree 的同级节点」和 「新 react element tree 的同级节点」称为「旧同级节点链表」和「新同级节点链表」)

遍历「新同级节点链表」和 「旧同级节点链表」,从第一个节点开始遍历(i = 0),判断新、旧节点的类型(type)是否相同和 key 是否相同,如果 type 和 key 都相同,则说明对应的 DOM 可复用;如果这个节点对应的 DOM 可复用,则 i++,去判断下一组新、旧节点的 type 和 key,看它们对应的 DOM 是否可复用,如果可以复用,则重复步骤 2;如果发现某一组新、旧节点对应的 DOM 不可复用,则结束遍历;如果「新同级节点链表」遍历完了 或者 「旧同级节点链表」遍历完了,则结束遍历;

以上流程的简单模拟代码如下(注意只是“简单模拟”的代码,和源码的具体实现还是有区别的,如果想看源码具体的实现,请看 /packages/react-reconciler/srcReactChildFiber.new.js 的 reconcileChildArray() 函数):

// newNodeList 为 新同级节点链表
// oldNodeList 为 旧同级节点链表
for (let i = 0; i     if (!oldNodeList[i]) break;  // 如果「旧同级节点链表」已经遍历完了,则结束遍历
    if (newNodeList[i].key=== oldNodeList[i].key && 
        newNodeList[i].type === oldNodeList[i].type) {
        continue;  // 对应的 DOM 可复用,则继续遍历
    } else {
        break; // 对应的 DOM 不可复用,则结束遍历
    }
}

对于上述流程,当我们结束遍历时,会有两种结果:

「结果一」:在步骤 3 结束了遍历,此时「新同级节点链表」和「旧同级节点链表」都没有遍历完。

看个例子:

// 旧
"0">0
"1">1
"2">2
// 新
"0">0
"1">1
"2">2

"3">3
a4b11911fb8ce069d08ce18435a3dff3.png

前面 key === 0,key === 1 的节点都可以复用,但是 到了 key === 2 时,由于节点的 type 发生了改变,因此对应的 DOM 不可复用,直接结束遍历。此时相当于「旧同级节点链表」中 key === 2 的节点未被遍历处理、「新同级节点链表」中 key ===2、 key === 3 的节点也没有被遍历处理。

「结果二」:如果是在步骤 4 结束遍历,那么可能是 「新同级节点链表」遍历完、或者「旧同级节点链表」遍历完,又或者他们同时遍历完。例如:

// 旧
"0" className="a">0
"1" className="b">1
            
// 新
// 「新同级节点链表」和「旧同级节点链表」同时遍历完
"0" className="aa">0
"1" className="bb">1
1f61efa955720c03429bf3debec07463.png
// 旧
"0" className="a">0
"1" className="b">1
// 新 
//「新同级节点链表」没遍历完,「旧同级节点链表」就遍历完了
"0" className="aa">0
"1" className="bb">1
"2" className="cc">2
813bbf07724897dc252f81131995162d.png
// 旧
"0" className="a">0
"1" className="b">1
// 新
//「新同级节点链表」遍历完了,「旧同级节点链表」还没遍历完
"0" className="aa">0
18b42947fd9296522feeb9652f99ebbf.png
第二轮遍历

第二轮遍历时,主要是遍历「新同级节点链表」中剩下还没被遍历处理过的节点。

假如上一轮遍历结果为 「结果二」

1、如果是「新同级节点链表」没有遍历完,「旧同级节点链表」已经遍历完的这种情况,则说明有节点新增,即将要新增的这个节点将会被打上一个 Placement 的标记 (newFiber.flags = Placement)。

9cdcf23de219902519cd794c9f2c453d.png

2、如果是「新同级节点链表」已经遍历完,「旧同级节点链表」没有遍历完的这种情况,则说明有节点需要被删除,这个即将要被删除的节点将会被打上一个 Deletion 的标记(returnFiber.flags |= Deletion)。

5cb7f67ee87e86d13e14885501668d0c.png

假如上一轮遍历结果为 「结果一」

假如为结果一,说明新、旧同级节点链表都没有遍历完,这意味着有的节点在这次更新中可能改变了位置!接下来是处理位置变换的节点。处理节点位置变换的 2 个主要思想就是:“剩下的节点中,哪些节点需要「右」移动?”“移动到什么位置?”

由于有节点交换了位置,所以我们不能再通过节点的索引来对比新旧的节点了。不要慌,问题不大,我们还可以利用 key 来将新旧的节点对应上。

在遍历「新同级节点链表」时,为了能快速在「旧同级节点链表」中找到对应的旧节点,React 会将「旧同级节点链表」中还没被处理过的节点以 map 的形式存放起来,其中 key 属性为 key,fiber node 为 value,这个 map 叫做 existingChildren

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

existingChildren 是如何发挥作用的呢?在第二轮遍历时:

1、假如遍历到的「新同级节点」A 的 key 在 existingChildren 中可以找到,则说明在「旧同级节点链表」中可以找到一个和 A 的key 相同的「旧同级节点」A1。由于是通过 map 的实行来匹配的,很明确的一点就是 A 和 A1 的 key 是相同的,接下来就是判断它们的 type 是否相同:

假如 key 相同、type 也相同,说明该节点对应的 DOM 可复用,只是位置发生了变化;假如 key 相同、type 不同,则该节点对应的 DOM 不可复用,需要销毁原来的节点,并重新插入一个新的节点;

2、假如遍历到的「新同级节点」A 的 key 在 existingChildren 中找不到,则说明在「旧同级节点链表」中找不到和 A 的 key 相同的「旧同级节点」A1,那就说明 A 是一个新增节点;

解决了 “新节点如何对应找到旧节点的问题” 后。接下来我们来看看具体在第二轮循环的时候如何处理节点新增、删除、移动的。

其实新增和删除节点的情况很好理解,其实上面讲“两种结果”的时候已经说明了新增、删除的情况了。下面我们重点来研究一下节点移动的情况。在前面曾经说过,处理节点的位置变化,主要抓住两个点:

哪个节点需要向右移?向右移动到哪个位置?

以上两个问题实际上涉及到的是 方向位移,如果想要明确这两个东西,就需要一个「基准点」,或者说「参考点」。React 使用 lastPlacedIndex 这个变量来存放「参考点」。我们可以在源码的 reconcileChildrenArray() 函数的开头,看到:

let lastPlacedIndex = 0;

lastPlacedIndex 这个变量表示当前最后一个可复用的节点,对应在「旧同级节点链表」中的索引。初始值为 0。(这个定义理解起来可能有点绕,不过没关系,等下看两个例子就知道它究竟存的什么东西了)

在遍历剩下的「新同级节点链表」时,每一个新节点会通过 existingChildren 找到对应的旧节点,然后就可以得到旧节点的索引 oldIndex(即在「旧同级节点链表」中的位置)。

接下来会进行以下判断:

假如 oldIndex >= lastPlacedIndex,代表该复用节点不需要移动位置,并将 lastPlacedIndex = oldIndex;假如 oldIndex < lastPlacedIndex,代表该节点需要向右移动,并且该节点需要移动到上一个遍历到的新节点的后面;

上述就是处理节点移动的逻辑。看完之后可能还是有点懵,此时就需要配合一些栗子来服用,效果会更佳~

栗子1:

假设现有新旧两个同级节点列表(下列图中所有圆圈代表的节点的 type 均为 li,圈圈中的字母就是该节点的 key):

// 旧
"a">a
"b">b
"c">c
"d">d
// 新
"a">a
"c">c
"d">d
"b">b
70da761b33321598127eba7193471617.png

首先是第一轮循环:

d050864ce3f0d82a5b53459cf73e4d02.png

第二轮循环:

刚刚第一遍循环只处理了第一个节点 a,目前「旧同级节点链表」中还有 b、c、d 还未被遍历处理,「新同级节点列表」中还有 c、d、b 还未被遍历处理。新、旧同级节点链表均没有完成遍历,也就是说,没有节点新增或删除,说明有节点变化了位置。因此接下来的第二轮循环,主要是处理节点的位置移动。在开始处理之前,先把「旧同级节点链表」中未被遍历处理的的 b、c、d 节点以 map 的形式存放到 existingChildren 中。

「新同级节点链表」遍历到节点 c:

e929db4ca631016f3a9a5159a1f80158.png

「新同级节点链表」遍历到节点 d:

848b9774a41af768887df26cadd82ff0.png

「新同级节点链表」遍历到节点 b:

0a217498821f6a5dc0fe2808a3441761.png

第二轮遍历到此结束,最终,节点 a、c、d 对应的 DOM 节点都没有移动,而节点 b 对应的 DOM 则会被标记为“需要移动”。

于是,经过两轮循环后,React 就知道了,想要从「旧同级节点链表」变成「新同级节点链表」那样子,需要「旧同级节点链表」经过以下每个节点的操作:

节点 a 位置不变;节点 b 向右移动到节点 d 的后面;节点 c 位置不变;节点 d 位置不变;
fe5c57e838bf3ae83dece9bde5b79426.png

什么?感觉只举一个栗子有点意犹未尽?我们再来一个栗子~

假设现有新旧两个同级节点列表:

// 旧
"a">a
"b">b
"c">c
"d">d

// 新
"d">d
"a">a
"b">b

"c">c
3ad57a9d6c849ceb946a6d1aaa702b5d.png

第一轮循环:

930282202d0959537da5974c36221f86.png

第二轮循环:

「新同级节点链表」遍历到节点 d:

b46f38e40453937e86064c782f9a15b8.png

「新同级节点链表」遍历到节点 a:

445447c032c25d5c2cd896dfa2f7cd94.png

「新同级节点链表」遍历到节点 b:

dee931abd8b6affe0cdd3a6fae5920c6.png

「新同级节点链表」遍历到节点 c:

e5ed71b5695997359814ccd24d865c0f.png

第二轮遍历到此结束。

经过两轮循环后,React 就知道了,想要从「旧同级节点链表」变成「新同级节点链表」那样子,需要「旧同级节点链表」经过以下每个节点的操作:

节点 d 位置不变;节点 a 向右移动到节点 d 的后面;在节点 a 后面插入一个新的节点 b,其类型为 div,然后删除原来的节点 b;节点 c 向右移动到节点 b 的后面;
9849a0a211fcbcac8960f5124e6e80f0.png

上述每个节点各自的“操作”(work)—— “移动到哪里”、“位置不变”、“插入新的,删掉旧的” 等等,会存放到节点各自对应的 fiber node 中。等到渲染阶段(Render phase)时,React 会读取并执行这些“操作”,从而完成 DOM 的更新。

小结:

我们通过下面这样图来回顾一下整个 diff 流程:

b4fd66c8cd760ed0ff37237f600c9726.png

2、将差异更新到真实 DOM,从而完成 UI 的更新

经过上面的对比找出了「差异」之后,React 知道了“哪些 react element 要被删除”、“哪些 react element 需要添加子节点”、“哪些 react element 位置需要移动”、“哪些 react element 的属性需要更新”等等的一系列操作,这些操作会被看作一个个更新任务(work)。每个 react element 自身的更新任务(work)会存储在与这个 react element 对应的 fiber node 中。

渲染阶段(Render phase),Reconciliation 会从 fiber node tree 最顶端的节点开始,重新对整棵 fiber node tree 进行 深度优先遍历,遍历树中的每一个 fiber node,处理 fiber node 中存储的 work。遍历一次 fiber node tree 的执行其中的 work 的这个过程被称作一次 work loop。当一个 fiber node 自己和其所有子节点(child)分支上的 work 都被完成了,此时这个 fiber node 的 work 才算完成。一旦一个 fiber node 的 work 完成了,也就是说这个 fiber node 被结束了,然后 React 会接着去处理它的兄弟节点(silbing 字段所指向的 fiber node)的 work,在完成这个兄弟节点(sibling)的 work 后,就会继续移步到下一个兄弟节点......以此类推。当所有的 sibling 节点的 work 都处理完成后,React 才会回溯到 parent 节点(通过 return 字段一步步回溯)。这个过程发生在 completeUnitOfWork 函数(/packages/react-reconciler/src/ReactFiberWorkLoop.new.js)中。

React 的开发者在这里做了一个优化(也就是前面提到过的 「Effect List」),React 会跳过那些已经处理过的 fiber node,只会去处理那些带有未完成 work 的 fiber node。举个例子,如果你在组件树的深层去调用 setState() 方法的话,那么 React 虽然还是会从 fiber node tree 的顶部的节点开始遍历,但是它会跳过前面所有的父节点,直奔那个调用了 setState() 方法的子节点。

work loop 结束后(也就是遍历完整棵 fiber node tree 后),就会准备进入 commit 阶段(Commit phase)。在 commit 阶段,React 会去更新真实 DOM 树,从而完成 UI 的更新渲染。

(PS:由于篇幅有限,关于在 Render phase 和 Commit phase 两个阶段中更具体的流程以及在这个过程 fiber node 的每个字段的作用和变化、还有 Scheduler、Renderer 的原理等等细节,完全可以再写几篇文章了[笑哭],后面有机会在来作更深一步的研究和总结)

五、源码查看思路

(基于 React v17.0.1 源码)

ReactElement 类型定义:package/shared/ReactElementType.jsfiber 类型定义:packages/react-reconciler/src/ReactInternalTypes.js创建 fiber:packages/react-reconciler/src/ReactFiber.new.jsdiff 过程:reconcileChildFibers 函数(/packages/react-reconciler/src/ReactChildFiber.new.js)work loop 过程:completeUnitOfWork 函数(/packages/react-reconciler/src/ReactFiberWorkLoop.new.js)

六、后记

本文是烧烤哥基于 React 源码和网络上的一些文章总结而来,鉴于 React 内部机制复杂庞大和烧烤哥的能力有限,文中可能会出现错误或者总结得不够到位的地方,希望各位老铁吃完烧烤之后在评论区指出,大家一起交流探讨,期待通过和老铁们的交流来加深对前端知识的理解。

七、参考文献

Reconciliation 官方文档React Virtual DOM 官方文档React Fiber ArchitectureInside Fiber图解 React Virtual DOMReact源码揭秘3 Diff算法详解

关注「前端烧烤摊」 掘金 or 微信公众号, 第一时间获取烧烤哥前的总结与发现。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值