【第3262期】React的Fiber架构原理


link: https://mp.weixin.qq.com/s/70cRoSek1n2b90KDB81XeQ author: published: 2024-05-18T08:10:00

tags: []

Highlights


【第3262期】React的Fiber架构原理

林督俊 前端早读课 _2024-05-18 08:10

前言

主要内容是解释 React 的 Fiber 架构原理及其在 React 16 版本中的实现和优势。文章通过比较 React 15 和 React 16 在处理大量列表渲染时的性能差异,引出了 Fiber 架构的必要性。今日前端早读课文章由 @林督俊分享,公号:哈啰技术授权。

正文从这开始~~

简介

先看两个例子,例子除了 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 运算,占住主线程不放,这时你的页面就会卡,用户的输入也得不到响应。

【第3132期】结合 React Fiber 结构与 chrome 插件,谈谈无侵入自动化表单的技术尝试

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

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

React15 架构

React15 架构可以分为两层:

  • Reconciler(协调器)—— 负责找出变化的组件

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

Reconciler(协调器)

我们知道,在 React 中可以通过 this.setStatethis.forceUpdateReactDOM.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)。

图片

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

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

图片

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 具备暂停、恢复以及优先级调度能力

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

关于本文
作者:@林督俊
原文:https://mp.weixin.qq.com/s/q70TMZ5jJcOCJpHOWndo8A