2018-Depth in React,详谈Fiber架构

原文地址

Depth in React,详谈Fiber架构

前言

2016年都已经透露出来的概念,这都9102年了,我才开始写Fiber的文章,表示惭愧呀。不过现在好的是关于Fiber的资料已经很丰富了,在写文章的时候参考资料比较多,比较容易深刻的理解。

React作为我最喜欢的框架,没有之一,我愿意花很多时间来好好的学习他,我发现对于学习一门框架会有四种感受,刚开始没使用过,可能有一种很神奇的感觉;然后接触了,遇到了不熟悉的语法,感觉这是什么垃圾东西,这不是反人类么;然后当你熟悉了之后,真香,设计得挺好的,这个时候它已经改变了你编程的思维方式了;再到后来,看过他的源码,理解他的设计之后,设计得确实好,感觉自己也能写一个的样子。

所以我今年(对,没错,就是一年)就是想完全的学透React,所以开了一个Deep In React的系列,把一些新手在使用API的时候不知道为什么的点,以及一些为什么有些东西要这么设计写出来,与大家共同探讨React的奥秘。

我的思路是自上而下的介绍,先理解整体的Fiber架构,然后再细挖每一个点,所以这篇文章主要是谈Fiber架构的。

介绍

在详细介绍Fiber之前,先了解一下Fiber是什么,以及为什么React团队要话两年时间重构协调算法。

React的核心思想

内存中维护一颗虚拟DOM树,数据变化时(setState,自动更新虚拟DOM,得到一颗新树,然后Diff新老虚拟DOM树,找到有变化的部分,得到一个Change(Patch),将这个Patch加入队列,最终批量更新这些PatchDOM

React 16之前的不足

首先我们了解一下React的工作过程,当我们通过render() setState() 进行组件渲染和更新的时候,React主要有两个阶段:

调和阶段(Reconciler)官方解释React会自顶向下通过递归,遍历新数据生成新的Virtual DOM,然后通过Diff算法,找到需要变更的元素(Patch),放到更新队列里面去。

渲染阶段(Renderer):遍历更新队列,通过调用宿主环境的API,实际更新渲染对应元素。宿主环境,比如DOM、Native、WebGL等。

在协调阶段阶段,由于是采用的递归的遍历方式,这种也被成为Stack Reconciler,主要是为了区别Fiber Reconciler取的一个名字。这种方式有一个特点:一旦任务开始进行,就无法中断,那么js将一直占用主线程, 一直要等到整棵Virtual DOM树计算完成之后,才能把执行权交给渲染引擎,那么这就会导致一些用户交互、动画等任务无法立即得到处理,就会有卡顿,非常的影响用户体验。

如何解决之前的不足

之前的问题主要的问题是任务一旦执行,就无法中断,js线程一直占用主线程,导致卡顿。

可能有些接触前端不久的不是特别理解上面为什么js一直占用主线程就会卡顿,我这里还是简单的普及一下。

浏览器每一帧都需要完成哪些工作?

页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到60时,页面是流畅的,小于这个值时,用户会感觉到卡顿。

1s 60帧,所以每一帧分到的时间是1000/60 ≈ 16 ms。所以我们书写代码时力求不让一帧的工作量超过16ms

image-20190603163205451

浏览器一帧内的工作

通过上图可看到,一帧内需要完成如下六个步骤的任务:

  • 处理用户的交互
  • JS解析执行
  • 帧开始。窗口尺寸变更,页面滚去等的处理
  • rAF(requestAnimationFrame)
  • 布局
  • 绘制

如果这六个步骤中,任意一个步骤所占用的时间过长,总时间超过16ms了之后,用户也许就能看到卡顿。

而在上一小节提到的调和阶段花的时间过长,也就是js执行的时间过长,那么就有可能在用户有交互的时候,本来应该是渲染下一帧了,但是在当前一帧里还在执行JS,就导致用户交互不能麻烦得到反馈,从而产生卡顿感。

解决方案

**把渲染更新过程拆分成多个子任务,每次只做一小部分,做完看是否还有剩余时间,如果有继续下一个任务;如果没有,挂起当前任务,将时间控制权交给主线程,等主线程不忙的时候在继续执行。**这种策略叫做 Cooperative Scheduling(合作式调度),操作系统常用任务调度策略之一。

补充知识,操作系统常用任务调度策略:先来先服务(FCFS)调度算法、短作业(进程)优先调度算法(SJ/PF、最高优先权优先调度算法(FPF、高响应比优先调度算法(HRN、时间片轮转法(RR、多级队列反馈法。

合作式调度主要就是用来分配任务的,当有更新任务来的时候,不会马上去做Diff操作,而是先把当前的更新送入一个Update Queue中,然后交给Scheduler去处理,Scheduler会根据当前主线程的使用情况去处理这次Update。为了实现这种特性,使用了requestIdelCallbackAPI。对于不支持这个API的浏览器,React会加上pollyfill

在上面我们已经知道浏览器是一帧一帧执行的,在两个执行帧之间,主线程通常会有一小段空闲时间,requestIdleCallback可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback,执行一些任务。

image-20190625225130226

  • 低优先级任务由requestIdleCallback处理;

  • 高优先级任务,如动画相关的由requestAnimationFrame处理;

  • requestIdleCallback 可以在多个空闲期调用空闲期回调,执行任务;

  • requestIdleCallback 方法提供deadline,即任务执行限制时间,以切分任务,避免长时间执行,阻塞UI渲染而导致掉帧;

这个方案看似确实不错,但是怎么实现可能会遇到几个问题:

  • 如何拆分成子任务?
  • 一个子任务多大合适?
  • 怎么判断是否还有剩余时间?
  • 有剩余时间怎么去调度应该执行哪一个任务?
  • 没有剩余时间之前的任务怎么办?

接下里整个Fiber架构就是来解决这些问题的。

什么是Fiber

为了解决之前提到解决方案遇到的问题,提出了以下几个目标:

  • 暂停工作,稍后再回来。
  • 为不同类型的工作分配优先权。
  • 重用以前完成的工作。
  • 如果不再需要,则中止工作。

为了做到这些,我们首先需要一种方法将任务分解为单元。从某种意义上说,这就是FiberFiber代表一种工作单元

但是仅仅是分解为单元也无法做到中断任务,因为函数调用栈就是这样,每个函数为一个工作,每个工作被称为堆栈帧,它会一直工作,直到堆栈为空,无法中断。

所以我们需要一种增量渲染的调度,那么就需要重新实现一个堆栈帧的调度,这个堆栈帧可以按照自己的调度算法执行他们。另外由于这些堆栈是可以自己控制的,所以可以加入并发或者错误边界等功能。

因此Fiber就是重新实现的堆栈帧,本质上Fiber也可以理解为是一个虚拟的堆栈帧,将可中断的任务拆分成多个子任务,通过按照优先级来自由调度子任务,分段更新,从而将之前的同步渲染改为异步渲染。

所以我们可以说Fiber是一种数据结构(堆栈帧),也可以说是一种解决可中断的调用任务的一种解决方案,它的特性就是时间分片(time slicing)暂停(supense)

如果了解协程的可能会觉得Fiber的这种解决方案,跟协程有点像(区别还是很大的),是可以中断的,可以控制执行顺序。在JS里的generator其实就是一种协程的使用方式,不过颗粒度更小,可以控制函数里面的代码调用的顺序,也可以中断。

Fiber是如何工作的

  1. ReactDOM.render()setState 的时候开始创建更新。
  2. 将创建的更新加入任务队列,等待调度。
  3. requestIdleCallback空闲时执行任务。
  4. 从根节点开始遍历Fiber Node,并且构建WokeInProgress Tree
  5. 生成effectList
  6. 根据EffectList更新DOM

下面是一个详细的执行过程图:

  1. 第一部分从 ReactDOM.render() 方法开始,把接收的React Element转换为Fiber节点,并为其设置优先级,创建Update,加入到更新队列,这部分主要是做一些初始数据的准备。
  2. 第二部分主要是三个函数:scheduleWorkrequestWorkperformWork,即安排工作、申请工作、正式工作三部曲,React 16新增的异步调用的功能则在这部分实现,这部分就是Schedule阶段,前面介绍的Cooperative Scheduling就是在这个阶段,只有在这个解决获取到可执行的时间片,第三部分才会继续执行。具体是如何调度的,后面文章再介绍,这是React调度的关键过程。
  3. 第三部分是一个大循环,遍历所有的Fiber节点,通过Diff算法计算所有更新工作,产出EffectList给到commit阶段使用,这部分的核心是beginWork函数,这部分基本就是Fiber Reconciler ,包括reconciliationcommit阶段

Fiber Node

FIber Node,承载了非常关键的上下文信息,可以说是贯彻整个创建和更新的流程,下来分组列了一些重要的Fiber字段。

{
  ...
  // 跟当前Fiber相关本地状态(比如浏览器环境就是DOM节点)
  stateNode: any,

    // 单链表树结构
  return: Fiber | null,// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
  child: Fiber | null,// 指向自己的第一个子节点
  sibling: Fiber | null,  // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点

  // 更新相关
  pendingProps: any,  // 新的变动带来的新的props
  memoizedProps: any,  // 上一次渲染完成之后的props
  updateQueue: UpdateQueue<any> | null,  // 该Fiber对应的组件产生的Update会存放在这个队列里面
  memoizedState: any, // 上一次渲染的时候的state

  // Scheduler 相关
  expirationTime: ExpirationTime,  // 代表任务在未来的哪个时间点应该被完成,不包括他的子树产生的任务
  // 快速确定子树中是否有不在等待的变化
  childExpirationTime: ExpirationTime,

 // 在Fiber树更新的过程中,每个Fiber都会有一个跟其对应的Fiber
  // 我们称他为`current <==> workInProgress`
  // 在渲染完成之后他们会交换位置
  alternate: Fiber | null,

  // Effect 相关的
  effectTag: SideEffectTag, // 用来记录Side Effect
  nextEffect: Fiber | null, // 单链表用来快速查找下一个side effect
  firstEffect: Fiber | null,  // 子树中第一个side effect
  lastEffect: Fiber | null, // 子树中最后一个side effect
  ....
};

Fiber Reconciler

在第二部分,进行Schedule完,获取到时间片之后,就开始进行reconcile

Fiber ReconcilerReact里的调和器,这也是任务调度完成之后,如何去执行每个任务,如何去更新每一个节点的过程,对应上面的第三部分。

reconcile过程分为2个阶段(phase

  1. (可中断)render/reconciliation通过构造WorkInProgress Tree得出Change
  2. (不可中断)commit应用这些DOM change

reconciliation阶段

reconciliation阶段的每个工作循环中,每次处理一个Fiber,处理完可以中断/挂起整个工作循环。通过每个节点更新结束时向上归并Effect List来收集任务结果,reconciliation结束后,根节点Effect List里记录了包括DOM change在内的所有Side Effect

render阶段可以理解为就是Diff的过程,得出Change(Effect List),会执行声明如下的声明周期方法:

  • [UNSAFE_]componentWillMount(弃用)
  • [UNSAFE_]componentWillReceiveProps(弃用)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate(弃用)
  • render

由于reconciliation阶段是可中断的,一旦中断之后恢复的时候又会重新执行,所以很可能reconciliation阶段的生命周期方法会被多次调用,所以在reconciliation阶段的生命周期的方法是不稳定的,我想这也是React为什么要废弃 componentWillMountcomponentWillReceiveProps方法而改为静态方法 getDerivedStateFromProps 的原因吧。

commit阶段

commit阶段可以理解为就是将Diff的结果反映到真实DOM的过程。

commit阶段,在commitRoot里会根据 effect effectTag,具体effectTag源码 ,进行对应的插入、更新、删除操作,根据 tag 不同,调用不同的更新方法。

commit阶段会执行如下的声明周期方法:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

P.S:注意区别reconcilerreconcilereconciliationreconciler是调和器,是一个名词,可以说是React工作的一个模块,协调模块;reconcile是调和器调和的动作,是一个动词;而reconciliation只是reconcile过程的第一个阶段。

Fiber TreeWorkInProgress Tree

Reactrender第一次渲染时,会通过React.createElement创建一颗Element树,可以称之为Virtual DOM Tree,由于要记录上下文信息,加入了Fiber,每一个Element会对应一个Fiber Node,将Fiber Node链接起来的结构成为Fiber Tree。它反映了用于渲染UI的应用程序的状态。这棵树通常被称为current树(当前树,记录当前页面的状态

在后续的更新过程中(setState,每次重新渲染都会重新创建Element,但是Fiber不会,Fiber只会使用对应的Element中的数据来更新自己必要的属性,

Fiber Tree一个重要的特点是链表结构,将递归遍历编程循环遍历,然后配合requestIdleCallback API,实现任务拆分、中断与恢复。

这个链接的结构是怎么构成的呢,这就要主要到之前Fiber Node的节点的这几个字段:

// 单链表树结构
{
   return: Fiber | null, // 指向父节点
   child: Fiber | null,// 指向自己的第一个子节点
   sibling: Fiber | null,// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
}

每一个Fiber Node节点与Virtual Dom一一对应,所有Fiber Node连接起来形成Fiber tree,是个单链表树结构,如下图所示:

对照图来看,是不是可以知道Fiber Node是如何联系起来的呢,Fiber Tree就是这样一个单链表。

render的时候有了这么一条单链表,当调用 setState 的时候又是如何Diff得到change的呢?

采用的是一种叫双缓冲技术(double buffering,这个时候就需要另外一颗树:WorkInProgress Tree,它反映了要刷新到屏幕的未来状态。

WorkInProgress Tree构造完毕,得到的就是新的Fiber Tree,然后喜新厌旧(把current指针指向WorkInProgress Tree,丢掉旧的Fiber Tree)就好了。

这样做的好处:

  • 能够复用内部对象(fiber)
  • 节省内存分配、GC的时间开销
  • 就算运行中有错误,也不会影响View上的数据

每个Fiber上都有个alternate属性,也指向一个Fiber,创建WorkInProgress节点时优先取alternate,没有的话就创建一个。

创建WorkInProgress Tree的过程也是一个Diff的过程,Diff完成之后会生成一个Effect List,这个Effect List就是最终Commit阶段用来处理副作用的阶段。

后记

本开始想一篇文章把Fiber讲透的,但是写着写着发现确实太多了,想写详细,估计要写几万字,所以我这篇文章的目的仅仅是在没有涉及到源码的情况下梳理了大致React的工作流程,对于细节,比如如何调度异步任务、如何去做Diff等等细节将以小节的方式一个个的结合源码进行分析。

说实话,自己不是特别满意这篇,感觉头重脚轻,在讲协调之前写得还挺好的,但是在讲协调这块文字反而变少了,因为我是专门想写一篇文章讲协调的,所以这篇仅仅用来梳理整个流程。

但是梳理整个流程又发现Schedule这块基本没什么体现,哎,不想写了,这篇文章拖太久了,请继续后续的文章。

可以关注我的githubDeep In React

一些问题

接下来留一些思考题。

  • 如何去划分任务优先级?
  • reconcile过程的render阶段是如何去遍历链表,如何去构建workInProgress的?
  • 当任务被打断,如何恢复?
  • 如何去收集EffectList
  • 针对不同的组件类型如何进行更新?

参考

详解Diff过程

前言

我相信在看这篇文章的读者一般都已经了解过React 16以前的Diff算法了,这个算法也算是React跨时代或者说最有影响力的一点了,使React在保持了可维护性的基础上性能大大的提高,但Diff过程不仅不是免费的,而且对性能影响很大,有时候更新页面的时候往往Diff所花的时间js运行时间比RenderingPainting花费更多的时间,所以我一直传达的观念是React或者说框架的意义是为了提高代码的可维护性,而不是为了提高性能的,现在所做的提升性能的操作,只是在可维护性的基础上对性能的优化。具体可以参考我公众号以前发的这两篇文章:

如果你对标题不满意,请把文章看完,至少也得把文章最后的结论好好看下

在上一篇将React Fiber架构中,已经说到过,React现在将整体的数据结构从树改为了链表结构。所以相应的Diff算法也得改变,以为以前的Diff算法就是基于树的。

老的Diff算法提出了三个策略来保证整体界面构建的性能,具体是:

  1. Web UIDOM节点跨层级的移动操作特别少,可以忽略不计。
  2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
  3. 对于同一层级的一组子节点,它们可以通过唯一id进行区分。

基于以上三个前提策略,React分别对tree diffcomponent diff以及element diff进行算法优化。

具体老的算法可以见这篇文章:React源码剖析系列 - 不可思议的react diff

说实话,老的Diff算法还是挺复杂的,你仅仅看上面这篇文章估计一时半会都不能理解,更别说看源码了。对于React 16Diff算法(我觉得都不能把它称作算法,最多叫个Diff策略)其实还是蛮简单的,React 16是整个调度流程感觉比较难,我在前面将Fiber的文章已经简单的梳理过了,后面也会慢慢的逐个攻破。

接下来就开始正式的讲解React 16Diff策略吧!

Diff简介

Diff的目的就是为了复用节点。

链表的每一个节点是Fiber,而不是在16之前的虚拟DOM节点。

我这里说的虚拟DOM节点是指React.createElement方法所产生的节点。虚拟DOM tree只维护了组件状态以及组件与DOM树的关系,Fiber Node承载的东西比 虚拟DOM节点多很多。

Diff就是新旧节点的对比,在上一篇中也说道了,这里面的Diff主要是构建currentInWorkProgress的过程,同时得到Effect List,给下一个阶段commit做准备。

React16diff策略采用从链表头部开始比较的算法,是层次遍历,算法是建立在一个节点的插入、删除、移动等操作都是在节点树的同一层级中进行的。

对于Diff, 新老节点的对比,我们以新节点为标准,然后来构建整个currentInWorkProgress,对于新的children会有四种情况。

  • TextNode(包含字符串和数字)
  • 单个React Element(通过该节点是否有$$typeof区分)
  • 数组
  • 可迭代的children,跟数组的处理方式差不多

那么我们就来一步一步的看这四种类型是如何进行diff的。

前置知识介绍

这篇文章主要是从React的源码的逻辑出发介绍的,所以介绍之前了解下只怎么进入到这个diff函数的,reactdiff算法是从 reconcileChildren 开始的

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderExpirationTime: ExpirationTime
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderExpirationTime
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderExpirationTime
    );
  }
}

reconcileChildren 只是一个入口函数,如果首次渲染,currentnull,就通过 mountChildFibers 创建子节点的Fiber实例。如果不是首次渲染,就调用 reconcileChildFibers去做diff,然后得出effect list

接下来再看看mountChildFibersreconcileChildFibers有什么区别:

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

他们都是通过 ChildReconciler 函数来的,只是传递的参数不同而已。这个参数叫shouldTrackSideEffects,他的作用是判断是否要增加一些effectTag,主要是用来优化初次渲染的,因为初次渲染没有更新操作。

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime
): Fiber | null {
  // 主要的 Diff 逻辑
}

reconcileChildFibers 就是Diff部分的主体代码,这个函数超级长,是一个包装函数,下面所有的diff代码都在这里面,详细的源码注释可以见这里

参数介绍

  • returnFiber 是即将Diff的这层的父节点。
  • currentFirstChild是当前层的第一个Fiber节点。
  • newChild 是即将更新的vdom节点(可能是TextNode、可能是ReactElement,可能是数组),不是Fiber节点
  • expirationTime 是过期时间,这个参数是跟调度有关系的,本系列还没讲解,当然跟Diff也没有关系。

再次提醒,reconcileChildFibersreconcile(diff)的一层。

前置知识介绍完毕,就开始详细介绍每一种节点是如何进行Diff的。

Diff TextNode

首先看TextNode,因为它是最简单的,担心直接看到难的,然后就打击你的信心。

看下面两个小demo

// demo1:当前 ui 对应的节点的 jsx
return (
  <div>
    // ...
    <div>
      <xxx></xxx>
      <xxx></xxx>
    </div>
    //...
  </div>
);

// demo2:更新成功后的节点对应的 jsx

return (
  <div>
    // ...
    <div>前端桃园</div>
    //...
  </div>
);

对应的单链表结构图:

image-20190714223931338

对于diff TextNode会有两种情况。

  1. currentFirstNodeTextNode
  2. currentFirstNode不是TextNode

currentFirstNode是当前该层的第一个节点,reconcileChildFibers传进来的参数。

**为什么要分两种情况呢?**原因就是为了复用节点

第一种情况xxx是一个TextNode,那么就代表这这个节点可以复用,有复用的节点,对性能优化很有帮助。既然新的child只有一个TextNode,那么复用节点之后,就把剩下的aaa节点就可以删掉了,那么divchild就可以添加到workInProgress中去了。

源码如下:

if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
  // We already have an existing node so let's just update it and delete
  // the rest.
  deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
  const existing = useFiber(currentFirstChild, textContent, expirationTime);
  existing.return = returnFiber;
  return existing;
}

在源码里 useFiber 就是复用节点的方法,deleteRemainingChildren 就是删除剩余节点的方法,这里是从 currentFirstChild.sibling 开始删除的。

**第二种情况。**xxx不是一个TextNode,那么就代表这个节点不能复用,所以就从 currentFirstChild开始删掉剩余的节点,对应到上面的图中就是删除掉xxx节点和aaa节点。

对于源码如下:

deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(
  textContent,
  returnFiber.mode,
  expirationTime
);
created.return = returnFiber;

其中 createFiberFromText 就是根据 textContent 来创建节点的方法。

注意:删除节点不会真的从链表里面把节点删除,只是打一个deletetag,当commit的时候才会真正的去删除。

Diff React Element

有了上面TextNodeDiff经验,那么来理解React ElementDiff就比较简单了,因为他们的思路是一致的:先找有没有可以复用的节点,如果没有就另外创建一个。

那么就有一个问题,如何判断这个节点是否可以复用呢?

有两个点:1. key相同。 2.节点的类型相同。

如果以上两点相同,就代表这个节点只是变化了内容,不需要创建新的节点,可以复用的。

对应的源码如下:

if (child.key === key) {
  if (
    child.tag === Fragment
    ? element.type === REACT_FRAGMENT_TYPE
    : child.elementType === element.type
  ) {
    // 为什么要删除老的节点的兄弟节点?
    // 因为当前节点是只有一个节点,而老的如果是有兄弟节点是要删除的,是多于的。删掉了之后就可以复用老的节点了
    deleteRemainingChildren(returnFiber, child.sibling);
    // 复用当前节点
    const existing = useFiber(
      child,
      element.type === REACT_FRAGMENT_TYPE
      ? element.props.children
      : element.props,
      expirationTime,
    );
    existing.ref = coerceRef(returnFiber, child, element);
    existing.return = returnFiber;
    return existing;
}

相信这些代码都很好理解了,除了判断条件跟前面TextNode的判断条件不一样,其余的基本都一样,只是React Element多了一个跟新ref的过程。

同样,如果节点的类型不相同,就将节点从当前节点开始把剩余的都删除。

deleteRemainingChildren(returnFiber, child);

到这里,可能你们就会觉得接下来应该就是讲解当没有可以复用的节点的时候是如果创建节点的。

不过可惜你们猜错了。因为Facebook的工程师很厉害,另外还做了一个工作来优化,来找到复用的节点。

我们现在来看这种情况:

image-20190714232052778

这种情况就是有可能更新的时候删除了一个节点,但是另外的节点还留着。

那么在对比xxx节点和AAA节点的时候,它们的节点类型是不一样,按照我们上面的逻辑,还是应该把xxxAAA节点删除,然后创建一个AAA节点。

但是你看,明明xxxslibling有一个AAA节点可以复用,但是被删了,多浪费呀。所以还有另外有一个策略来找xxx的所有兄弟节点中有没有可以复用的节点。

这种策略就是从div下面的所有子节点去找有没有可以复用的节点,而不是像TextNode一样,只是找第一个child是否可以复用,如果当前节点的key不同,就代表肯定不是同一个节点,所以把当前节点删除,然后再去找当前节点的兄弟节点,直到找到key相同,并且节点的类型相同,否则就删除所有的子节点。

你有木有这样的问题:为什么TextNode不采用这样的循环策略来找可以复用的节点呢?这个问题留给你思考,欢迎在评论区留下你的答案。

对应的源码逻辑如下:

// 找到 key 相同的节点,就会复用当前节点
while (child !== null) {
  if (child.key === key) {
    if (
      child.tag === Fragment
        ? element.type === REACT_FRAGMENT_TYPE
        : child.elementType === element.type
    ) {
      // 复用节点逻辑,省略该部分代码,和上面复用节点的代码相同
      // code ...
      return existing;
    } else {
      deleteRemainingChildren(returnFiber, child);
      break;
    }
  } else {
    // 如果没有可以复用的节点,就把这个节点删除
    deleteChild(returnFiber, child);
  }
  child = child.sibling;
}

在上面这段代码我们需要注意的是,当key相同,React会认为是同一个节点,所以当key相同,节点类型不同的时候,React会认为你已经把这个节点重新覆盖了,所以就不会再去找剩余的节点是否可以复用。只有在key不同的时候,才会去找兄弟节点是否可以复用。

接下来才是我们前面说的,如果没有找到可以复用的节点,然后就重新创建节点,源码如下:

// 前面的循环已经把该删除的已经删除了,接下来就开始创建新的节点了
if (element.type === REACT_FRAGMENT_TYPE) {
  const created = createFiberFromFragment(
    element.props.children,
    returnFiber.mode,
    expirationTime,
    element.key
  );
  created.return = returnFiber;
  return created;
} else {
  const created = createFiberFromElement(
    element,
    returnFiber.mode,
    expirationTime
  );
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

对于Fragment节点和一般的Element节点创建的方式不同,因为Fragment本来就是一个无意义的节点,他真正需要创建Fiber的是它的children,而不是它自己,所以 createFiberFromFragment 传递的不是 element ,而是 element.props.children

Diff Array

Diff Array算是Diff中最难的一部分了,比较的复杂,因为做了很多的优化,不过请你放心,认真看完我的讲解,最难的也会很容易理解,废话不多说,开始吧!

因为Fiber树是单链表结构,没有子节点数组这样的数据结构,也就没有可以供两端同时比较的尾部游标。所以React的这个算法是一个简化的两端比较法,只从头部开始比较。

前面已经说了,Diff的目的就是为了复用,对于Array就不能像之前的节点那样,仅仅对比一下元素的key或者 元素类型就行,因为数组里面是好多个元素。你可以在头脑里思考两分钟如何进行复用节点,再看React是怎么做的,然后对比一下孰优孰劣。

1.相同位置(index)进行比较

相同位置进行对比,这个是比较容易想到的一种方式,还是举个例子加深一下印象。

image-20190721212259855

这已经是一个非常简单的例子了,divchild是一个数组,有AAABBB然后还有其他的兄弟节点,在做diff的时候就可以从新旧的数组中按照索引一一对比,如果可以复用,就把这个节点从老的链表里面删除,不能复用的话再进行其他的复用策略。

那如果判断节点是否可以复用呢?有了前面的ReactElementTextNode复用的经验,这个也类似,因为是一一对比嘛,相当于是一个节点一个节点的对比。

不过对于newChild可能会有很多种类型,简单的看下源码是如何进行判断的。

const key = oldFiber !== null ? oldFiber.key : null;

前面的经验可得,判断是否可以复用,常常会根据key是否相同来决定,所以首先获取了老节点的key是否存在。如果不存在老节点很可能是TextNode或者是Fragment

接下来再看newChild为不同类型的时候是如何进行处理的。

newChildTextNode的时候

if (typeof newChild === "string" || typeof newChild === "number") {
  // 对于新的节点如果是 string 或者 number,那么都是没有 key 的,
  // 所有如果老的节点有 key 的话,就不能复用,直接返回 null。
  // 老的节点 key 为 null 的话,代表老的节点是文本节点,就可以复用
  if (key !== null) {
    return null;
  }

  return updateTextNode(returnFiber, oldFiber, "" + newChild, expirationTime);
}

如果key不为null,那么就代表老节点不是TextNode,而新节点又是TextNode,所以返回null,不能复用,反之则可以复用,调用 updateTextNode 方法。

注意,updateTextNode里面包含了首次渲染的时候的逻辑,首次渲染的时候回插入一个TextNode,而不是复用。

newChildObject的时候

newChildObject的时候基本上走的就是ReactElement的逻辑了,判断key和 元素的类型是否相等来判断是否可以复用。

if (typeof newChild === "object" && newChild !== null) {
  // 有 $$typeof 代表就是 ReactElement
  switch (newChild.$$typeof) {
    case REACT_ELEMENT_TYPE: {
      // ReactElement 的逻辑
    }
    case REACT_PORTAL_TYPE: {
      // 调用 updatePortal
    }
  }

  if (isArray(newChild) || getIteratorFn(newChild)) {
    if (key !== null) {
      return null;
    }

    return updateFragment(
      returnFiber,
      oldFiber,
      newChild,
      expirationTime,
      null
    );
  }
}

首先判断是否是对象,用的是 typeof newChild === 'object' && newChild !== null ,注意要加 !== null,因为 typeof null 也是object

然后通过$$typeof判断是REACT_ELEMENT_TYPE还是REACT_PORTAL_TYPE,分别调用不同的复用逻辑,然后由于数组也是Object ,所以这个if里面也有数组的复用逻辑。

我相信到这里应该对于应该对于如何相同位置的节点如何对比有清晰的认识了。另外还有问题,那就是如何循环一个一个对比呢?

这里要注意,新的节点的children是虚拟DOM,所以这个children是一个数组,而对于之前提到的老的节点树是链表。

那么循环一个一个对比,就是遍历数组的过程。

let newIdx = 0; // 新数组的索引
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 遍历老的节点
  nextOldFiber = oldFiber.sibling;
  // 返回复用节点的函数,newFiber 就是复用的节点。
  // 如果为空,就代表同位置对比已经不能复用了,循环结束。
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    expirationTime
  );

  if (newFiber === null) {
    break;
  }

  // 其他 code,比如删除复用的节点
}

这并不是源码的全部源码,我只是把思路给贴出来了。

这是第一次遍历新数组,通过调用 updateSlot 来对比新老元素,前面介绍的如何对比新老节点的代码都是在这个函数里。这个循环会把所以的从前面开始能复用的节点,都复用到。比如上面我们画的图,如果两个链表里面的**???**节点,不相同,那么newFibernull,这个循环就会跳出。

跳出来了,就会有两种情况。

  • 新节点已经遍历完毕
  • 老节点已经遍历完毕

2.新节点已经遍历完毕

如果新节点已经遍历完毕的话,也就是没有要更新的了,这种情况一般就是从原来的数组里面删除了元素,那么直接把剩下的老节点删除了就行了。还是拿上面的图的例子举例,老的链表里**???还有很多节点,而新的链表???已经没有节点了,所以老的链表???**不管是有多少节点,都不能复用了,所以没用了,直接删除。

if (newIdx === newChildren.length) {
  // 新的 children 长度已经够了,所以把剩下的删除掉
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

注意这里是直接 return 了哦,没有继续往下执行了。

3.老节点已经遍历完毕

如果老的节点在第一次循环的时候就被复用完了,新的节点还有,很有可能就是新增了节点的情况。那么这个时候只需要根据把剩余新的节点直接创建Fiber就行了。

if (oldFiber === null) {
  // 如果老的节点已经被复用完了,对剩下的新节点进行操作
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(
      returnFiber,
      newChildren[newIdx],
      expirationTime
    );
  }
  return resultingFirstChild;
}

oldFiber === null 就是用来判断老的Fiber节点变量完了的代码,Fiber链表是一个单向链表,所以为null的时候代表已经结束了。所以就直接把剩余的newChild通过循环创建Fiber

到这里,目前简单的对数组进行增、删节点的对比还是比较简单,接下来就是移动的情况是如何进行复用的呢?

4.移动的情况如何进行节点复用

对于移动的情况,首先要思考,怎么能判断数组是否发生过移动操作呢?

如果给你两个数组,你是否能判断出来数组是否发生过移动。

答案是:老的数组和新的数组里面都有这个元素,而且位置不相同。

从两个数组中找到相同元素(是指可复用的节点),方法有很多种,来看看React是如何高效的找出来的。

把所有老数组元素按key或者是indexMap里,然后遍历新数组,根据新数组的key或者index快速找到老数组里面是否有可复用的。

function mapRemainingChildren(
  returnFiber: Fiber,
  currentFirstChild: Fiber
): Map<string | number, Fiber> {
  const existingChildren: Map<string | number, Fiber> = new Map();

  let existingChild = currentFirstChild; // currentFirstChild 是老数组链表的第一个元素
  while (existingChild !== null) {
    // 看到这里可能会疑惑怎么在 Map 里面的key 是 fiber 的key 还是 fiber 的 index 呢?
    // 我觉得是根据数据类型,fiber 的key 是字符串,而 index 是数字,这样就能区分了
    // 所以这里是用的 map,而不是对象,如果是对象的key 就不能区分 字符串类型和数字类型了。
    if (existingChild.key !== null) {
      existingChildren.set(existingChild.key, existingChild);
    } else {
      existingChildren.set(existingChild.index, existingChild);
    }
    existingChild = existingChild.sibling;
  }
  return existingChildren;
}

这个 mapRemainingChildren 就是将老数组存放到Map里面。元素有keyMap的键就存key,没有key就存indexkey一定是字符串,index一定是number,所以取的时候是能区分的,所以这里用的是Map,而不是对象,如果是对象,属性是字符串,就没办法区别是key还是index了。

现在有了这个Map,剩下的就是循环新数组,找到Map里面可以复用的节点,如果找不到就创建,这个逻辑基本上跟 updateSlot 的复用逻辑很像,一个是从老数组链表中获取节点对比,一个是从Map里获取节点对比。

// 如果前面的算法有复用,那么 newIdx 就不从 0 开始
for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    expirationTime
  );
  // 省略删除 existingChildren 中的元素和添加 Placement 副作用的情况
}

到这里新数组遍历完毕,也就是同一层Diff过程完毕,接下来进行总结一下。

效果演示

以下效果动态演示来自于文章:React Diff源码分析,我觉得这个演示非常的形象,有助于理解。

这里渲染一个可输入的数组。

1

当第一种情况,新数组遍历完了,老数组剩余直接删除(12345→1234删除5

新数组没完,老数组完了(1234→1234567插入567

img

移动的情况,即之前就存在这个元素,后续只是顺序改变(123 → 4321插入4,移动2 1

img

最后删除没有涉及的元素。

总结

对于数组的diff策略,相对比较复杂,最后来梳理一下这个策略,其实还是很简单,只是看源码的时候比较难懂。

我们可以把整个过程分为三个阶段:

  1. 第一遍历新数组,新老数组相同index进行对比,通过 updateSlot方法找到可以复用的节点,直到找到不可以复用的节点就退出循环。
  2. 第一遍历完之后,删除剩余的老节点,追加剩余的新节点的过程。如果是新节点已遍历完成,就将剩余的老节点批量删除;如果是老节点遍历完成仍有新节点剩余,则将新节点直接插入。
  3. 把所有老数组元素按keyindexMap里,然后遍历新数组,插入老数组的元素,这是移动的情况。

后记

刚开始阅读源码的过程是非常的痛苦的,但是当你一遍一遍的把作者想要表达的理解了,为什么要这么写 理解了,会感到作者的设计是如此的精妙绝伦,每一个变量,每一行代码感觉都是精心设计过的,然后感受到自己与大牛的差距,激发自己的动力。

更多的对于React原理相关,源码相关的内容,请关注我的githubDeep In React 或者 个人博客:桃园

我是桃翁,一个爱思考的前端er,想了解关于更多的前端相关的,请关注我的公号「前端桃园」

下一页