2

简介

先看两个例子,例子除了React版本外没有不同,列表有5000个元素,元素中的文字会随着用户进行输入的内容而改变,执行相同操作,也就是输入从1到9变为'123456789',可以看到上下两张图,展示上会有不同的效果,其中上图为React15.7版本的例子,下图为React16.8版本的例子,可以看出React16.8的例子比React15.7的例子流畅。

图片

React15.7版本

图片

React16.8版本

这是为什么呢?

在讲解 React Fiber 之前,我们需要了解一下浏览器的渲染流程。

  • 主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。
  • 同时浏览器有一个主线程,这个主线程既负责运行 js ,也负责页面渲染(布局,绘制,合并图层)。JS可以操作DOM,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。

在 React 15 中,每次调用 this.setState 时,它都会重新渲染整个组件树。所以在 componentDidMount 中调用 this.showList 会触发一次包含 10000 个 item 的完整渲染,进行 diff 的时候就要有大量的JS运算,占住主线程不放,这时你的页面就会卡,用户的输入也得不到响应。

而在 React 16以及之后的版本中,引入了 Fiber 架构。调用 this.setState 时,React 会使用异步更新策略,将 udpate 拆分成多个小块,在多个事件循环周期内完成。这样可以先渲染高优先级的更新(用户的输入操作),避免大量 DOM 操作阻塞主线程,Fiber 就好比给 React 加了一个操作系统,告诉它什么时候 diff,什么时候渲染,什么时候响应用户输入。这好比操作系统的时间片轮转法,也很像 generator 允许中断这种机制。

因此,我们还需要了解React15到React16架构进行了哪些变化,以及Fiber是如何设计的。

React15架构

React15架构可以分为两层:

  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Reconciler(协调器)

我们知道,在React中可以通过this.setState、this.forceUpdate、ReactDOM.render等API触发更新。

每当有更新发生时,Reconciler会做如下工作:

  • 调用函数组件、或Class组件的render方法,将返回的JSX转化为虚拟DOM
  • 将虚拟DOM和上次更新时的虚拟DOM对比
  • 通过对比找出本次更新中变化的虚拟DOM通知
  • Renderer将变化的虚拟DOM渲染到页面上
  • 没有优先级和中断机制,递归对比时会锁定页面

Renderer(渲染器)

由于React支持跨平台,所以不同平台有不同的Renderer。我们前端最熟悉的是负责在浏览器环境渲染的Renderer —— ReactDOM。

在每次更新发生时,Renderer接到Reconciler通知,将变化的组件渲染在当前宿主环境。

React15架构的缺点

在Reconciler中,mount的组件会调用mountComponent,update的组件会调用updateComponent。这两个方法都会递归更新子组件。由于递归执行,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。回到上述的例子,

图片

我们可以看到,Reconciler和Renderer是交替工作的,当第一个li在页面上已经变化后,第二个li再进入Reconciler。

由于整个过程都是同步的,所以在用户看来所有DOM是同时更新的,但更新过程中,用户继续输入2,就不会马上响应,需要等到更完成后输入框中才能出现2。

React16架构

React16架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

可以看到,相较于React15,React16中新增了Scheduler(调度器),让我们来了解下他。

Scheduler(调度器)

既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。

其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,React放弃使用。

  • 浏览器兼容性问题:requestIdleCallback API还没有得到所有主流浏览器的广泛实现,有兼容性问题。
  • 调度不精确:requestIdleCallback 的调度不够精确,不能很好地区分任务的优先级。而React需要一个更加细粒度的调度算法。
  • 调试困难:使用 requestIdleCallback 进行异步更新会使调试变得更困难,因为更新不再是可预测的。这对开发者来说是个难点。
  • 需要自行实现优先级处理:requestIdleCallback本身不支持任务优先级,需要React自己实现优先级相关的逻辑。增加了复杂度。

基于以上原因,React实现了功能更完备的requestIdleCallbackpolyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。

Reconciler(协调器)

我们知道,在React15中Reconciler是递归处理虚拟DOM的。让我们看看React16的Reconciler。

我们可以看见,更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield判断当前是否有剩余时间。

function workLoopConcurrent() {

// Perform work until Scheduler asks us to yield

while (workInProgress !== null && !shouldYield()) {

workInProgress = performUnitOfWork(workInProgress);

}

}

那么React16是如何解决中断更新时DOM渲染不完全的问题呢?

在React16中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记,类似这样:

// 标记变量

export const Placement = /*             / 0b0000000000010;

export const Update = /                / 0b0000000000100;

export const PlacementAndUpdate = /    / 0b0000000000110;

export const Deletion = /              */ 0b0000000001000;

整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。

Renderer(渲染器)

Renderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。

组合在一起,这就形成了React16架构的优势,让我们回到上述的例子。

图片

其中红框中的步骤随时可能由于以下原因被中断:

  • 有其他更高优任务需要先更新
  • 当前帧没有剩余时间

所以当用户输入数字1之后,然后输入数字2,因为用户输入是高优先级动作,当Reconciler的流程还没执行未时, Reconcile的执行会被打断,输入框内容优先变化,更新为'12',然后重新进行Reconciler流程,由于红框中的工作都在内存中进行,不会更新页面上的DOM,所以即使反复中断,用户也不会看见更新不完全的DOM。

通过本节我们知道了React16采用新的Reconciler,Reconciler内部采用了Fiber的架构。

Fiber架构

Fiber的起源

从前面我们知道了,在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。

为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。

Fiber的含义

Fiber包含三层含义:

  • 作为架构来说,之前React15的Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack Reconciler。
  • React16的Reconciler基于Fiber节点实现,被称为Fiber Reconciler。作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息。
  • 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)。

我们可以从这里看到Fiber节点的属性定义。虽然属性很多,但我们可以按三层含义将他们分类来看。

function FiberNode(

tag: WorkTag,

pendingProps: mixed,

key: null | string,

mode: TypeOfMode,

) {

// 作为静态数据结构的属性

this.tag = tag; // 节点的类型,表示函数组件、类组件、原生DOM等

this.key = key; // react元素的key属性

this.elementType = null; // 如果是原生DOM节点,该字段为DOM节点名称;如果是组件,该字段为组件类

this.type = null; // 对于函数组件,该字段为函数本身;对于类组件,为类的实例

this.stateNode = null; // 对应DOM节点或组件实例对象

// 用于连接其他Fiber节点形成Fiber树

this.return = null; // 指向父Fiber节点

this.child = null; // 指向子Fiber节点

this.sibling = null; // 指向兄弟Fiber节点

this.index = 0; // 用于记录当前Fiber节点在兄弟节点中的位置索引


this.ref = null; // 对应组件的ref属性


// 作为动态的工作单元的属性

this.pendingProps = pendingProps; // 尚未生效的Props,用于架构工作流程

this.memoizedProps = null; // 上一次渲染保存的props,用于 props 比对

this.updateQueue = null; // Effects队列,链表结构用于管理变化传播

this.memoizedState = null; // 上一次渲染保存的state,用于state比较

this.dependencies = null; // 组件受state和props变化影响的依赖项


this.mode = mode; // 代表并发模式,如异步模式、同步模式等


this.effectTag = NoEffect; // 用于记录副作用类型

this.nextEffect = null; // 副作用队列,用于异步渲染时记录effects,例如增、删、改


this.firstEffect = null; // 副作用链表的头指针

this.lastEffect = null; // 副作用链表的尾指针


// 调度优先级相关

this.lanes = NoLanes; // 位运算字段,包含多个Lane值,表示任务优先级信息

this.childLanes = NoLanes; // 子树上存在的Lane信息汇总


// 指向该fiber在另一次更新时对应的fiber,双缓存机制

this.alternate = null;

}

每个Fiber节点有个对应的React element,多个Fiber节点是如何连接形成树呢?靠如下三个属性:

// 指向父级Fiber节点

this.return = null;

// 指向子Fiber节点

this.child = null;

// 指向右边第一个兄弟Fiber节点

this.sibling = null;

举个例子,如下的组件结构:

function App() {

return (

<div>

number

<span>0</span>

</div>

)

}

对应的Fiber树结构:

图片

1.作为静态的数据结构

// 作为静态数据结构的属性

// 节点的类型,表示函数组件、类组件、原生DOM等

this.tag = tag;

// react元素的key属性

this.key = key;

// 如果是原生DOM节点,该字段为DOM节点名称;如果是组件,该字段为组件类

this.elementType = null;

// 对于函数组件,该字段为函数本身;对于类组件,为类的实例

this.type = null;

// 对应DOM节点或组件实例对象

this.stateNode = null;

其中在 React Fiber架构中,Fiber节点对象包含 tag、type 和 elementType 这三个属性,它们各自所代表的意义不同:

  • tag表示该 Fiber 节点的类型,比如 FunctionComponent、ClassComponent、HostComponent 等。tag决定了该节点具体对应什么组件类型。
  • type表示该组件渲染后的具体类型,对于函数组件,type 指向函数本身;对于类组件,type 指向类的实例。
  • elementType表示 React 元素的类型,对于原生 DOM 组件,它指向字符串类型的 tag 名称,如 'div'、'span' 等;对于函数或类组件,它指向组件本身。

之所以需要这三个属性是因为:

  • tag 表示 Fiber 节点类型,决定节点行为
  • type 表示组件实例类型,用于调用实例方法
  • elementType 表示 React 元素类型,用于创建实例

它们代表不同的概念,在处理 Fiber 节点时扮演不同的角色。比如需要调用生命周期时,通过 type 获取实例;需要决定处理方式时,通过 tag 判断类型。

所以 React 在 Fiber 中设置了这三个属性,用于在不同场景中获取节点的相关类型信息,以实现 Fiber 在处理和渲染各种类型组件时的通用性。

2.作为动态的工作单元
作为动态的工作单元,Fiber中如下参数保存了本次更新相关的信息,我们会在后续的更新流程中使用到,在此只作简单介绍。

// 作为动态的工作单元的属性

// 尚未生效的Props,用于架构工作流程

this.pendingProps = pendingProps;

// 上一次渲染保存的props,用于 props 比对

this.memoizedProps = null;

// Effects队列,链表结构用于管理变化传播

this.updateQueue = null;

// 上一次渲染保存的state,用于state比较

this.memoizedState = null;

// 组件受state和props变化影响的依赖项

this.dependencies = null;


// 代表并发模式,如异步模式、同步模式等

this.mode = mode;


// 用于记录副作用类型

this.effectTag = NoEffect;

// 副作用队列,用于异步渲染时记录effects,例如增、删、改

this.nextEffect = null;


// 副作用链表的头指针

this.firstEffect = null;

// 副作用链表的尾指针

this.lastEffect = null;

如下两个字段保存调度优先级相关的信息,主要使用在Scheduler中。

// 位运算字段,包含多个Lane值,表示任务优先级信息

this.lanes = NoLanes;

// 子树上存在的Lane信息汇总

this.childLanes = NoLanes; 

Fiber的工作原理

我们了解了Fiber的起源与架构,其中Fiber节点可以构成Fiber树。那么Fiber树和页面呈现的DOM树有什么关系,React又是如何更新DOM的呢?这需要用到被称为“双缓存”的技术。

1.双缓存Fiber树
在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。

current Fiber树中的Fiber节点被称为current Fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress Fiber,他们通过alternate属性连接。

currentFiber.alternate === workInProgressFiber;

workInProgressFiber.alternate === currentFiber;

React应用的根节点通过使current指针在不同Fiber树的rootFiber间切换来完成current Fiber树指向的切换。

即当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。

每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。

接下来我们以具体例子讲解mount时、update时的构建/替换流程。

function App() {

const [num, addNum] = useState(0);

return (

<div>

number

<span onClick={() => add(num + 1)}>{num}</span>

</div>

)

}

ReactDOM.render(<App/>, document.getElementById('root'));

2.mount时
首次执行ReactDOM.render会创建fiberRoot和rootFiber。其中fiberRoot是整个应用的根节点,rootFiber是所在组件树的根节点。

之所以要区分fiberRoot与rootFiber,是因为在应用中我们可以多次调用ReactDOM.render渲染不同的组件树,他们会拥有不同的rootFiber。但是整个应用的根节点只有一个,那就是fiberRoot。

fiberRoot的current会指向当前页面上已渲染内容对应Fiber树,即current Fiber树。

图片

fiberRootNode.current = rootFiber;

由于是首屏渲染,页面中还没有挂载任何DOM,所以fiberRoot.current指向的rootFiber没有任何子Fiber节点(即current Fiber树为空)。

接下来进入render阶段,根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树。(下图中右侧为内存中构建的树,左侧为页面显示的树)。

在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber(即rootFiber.alternate)。

image.png

图中右侧已构建完的workInProgress Fiber树在commit阶段渲染到页面。

此时DOM更新为右侧树对应的样子。fiberRoot的current指针指向workInProgress Fiber树使其变为current Fiber 树。

image.png

3.update时
接下来我们点击span节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树。

图片

和mount时一样,workInProgress fiber的创建可以复用current Fiber树对应的节点数据。

workInProgress Fiber 树在render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树。

图片

总结

React16通过对引入这Scheduler(调度器)与Fiber两个架构

  • Scheduler负责协调任务时间线,优化渲染顺序
  • Fiber重新实现核心算法,使渲染过程异步化
  • 两者协同工作,使React16具备暂停、恢复以及优先级调度能力

实现了异步渲染、优先级调度等能力,让页面加载与用户交互更加流畅,是一次重要性的性能提升。

(本文作者:林督俊)

图片


哈啰技术
89 声望53 粉丝

哈啰官方技术号,不定期分享哈啰的相关技术产出。