Bobolo
  • Home
  • Me

React Fiber

React Fiber

20 Min Read

Thu Nov 18 2021

Written By Bobolo

React 从 15 到 16 后,在架构上实现了 Fiber 的架构,也随之带来了 Hooks 和 function component,这些这架构带来的好处,这里主要不会详细探讨这部分以及代码实现,主要会聚集在目前架构的流程和逻辑上,更好的去理解 React 的思路。

首先要明白为什么要改 Fiber,主要是性能方面,如果有 VDOM 它的计算需要长时间执行,那就有可能 block 到你界面一些高优先级的交互。因此需要实现一个可中断的渲染过程。

这里我们先理解以下三个概念

  • Scheduler(调度器)—— 调度任务的优先级和执行任务
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 渲染组件

React 旧架构

React15 架构只有 ReconcilerRenderer,最初的时候 React 更让大家熟知的就是 VDom,VDom 是在当前实际的 Dom 内存中的映射,用来表示页面上的 Dom 和其对应的属性和内容。

VDOM 遍历的过程从 Root 节点一直遍历到叶子节点。遍历过程中,React 会按 Diff 来处理差异,找出需要更新的节点。这个过程是单向且自上而下的,所以在遍历过程中无法中断。

这做法的确减少操作 Dom,让性能得到改善。但也因为 VDom 是 Stack 架构(循环、递归)实现的,因此在遍历的时候是没办法中断的,至于说为什么没办法中断?

Reconciler 和 Renderer 是同步交替执行

<span>test1</span>  ————> <span>t1</span>
<span>test2</span>  ————> <span>t2</span>

Reconciler(test1) -> Renderer(t1) -> Reconciler(test2) -> Renderer(t2)

这是会直接每遍历发现一个 VDom 需要处理就进行处理,如果中断那后续的 Renderer 处理也就中断,导致界面只处理了部分。

React 新架构

通过老的架构的缺陷,可以明白要实现可中断,需要完成以下几点:

  • 需要新的数据结构来保存需要的状态和执行结果和 Dom 信息,也就是 Fiber
  • 需要一个用来调度优先级的东西,也就是 Scheduler
  • 需要改造 Reconciler 不能交替执行,需要保存 Fiber 的信息用于恢复
  • 需要改造 Renderer 完成生命周期的改动

Fiber

Fiber,它的目的就是在中断时用来保存各种的状态和数据,方便恢复时继续沿用当前的信息。

数据结构有很多,为什么是选择链表,原因可以从链表的特性找到答案。链表(Linked list)是一种线性表,但不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。

  • 由于不必须按顺序存储,链表在插入时可达到 O(1)复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要 O(n)的时间,而顺序表相应的时间复杂度分别是 O(logn)和 O(1)。

  • 链表可以克服数组链表需要预先知道数据大小的缺点,链表可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。

主要原因是 每一个节点里存到下一个节点的指针 这使得 Fiber 的遍历过程中,在中断时可以记录下当前的 Fiber,然后再需要恢复时按当前 Fiber 继续执行即可。

下面是 Fiber 所用到的部分属性,包含指针、Fiber 本身的 key 等

{
  // Instance
  this.tag = tag
  this.key = key
  this.elementType = null
  this.type = null
  this.stateNode = null

  // Fiber
  this.return = null
  this.child = null
  this.sibling = null
  this.index = 0

  this.ref = null
  this.refCleanup = null

  this.pendingProps = pendingProps
  this.memoizedProps = null
  this.updateQueue = null
  this.memoizedState = null
  this.dependencies = null

  this.mode = mode

  // Effects
  this.flags = NoFlags
  this.subtreeFlags = NoFlags
  this.deletions = null

  this.lanes = NoLanes
  this.childLanes = NoLanes

  this.alternate = null
}

Fiber 即是保存信息和状态的数据结构,也是一个可执行任务的大小。

<div>
  <span>test1</span>
  <span>test2</span>  ==>  <span>test3</span>
</div>

以上面结构为例,当遍历到 div 处需要中断时,只需要处理 div Fiber 的信息,它自身的 tag、key、flags 等,完全不会干扰它的 child Fiber,有空闲时间后再接着 this.child 去处理。

可能会觉得数组或者队列这些其他数据结构可以吗?以上面的结构为例

Array: [div,span,test1,span,test2] ==> [div,span,test1,span,test3]

Array 难以知道要跳过哪些 Fiber,如果直接遍历整个 Array 就性能影响很大,而且暂停时如果有新的 Update 要进行对比处理也异常困难

Scheduler

Scheduler 主要目的是将任务划分为不同优先级,并根据优先级调度任务的执行。执行也需要有时间限制,因为 Task 执行时间过长会导致卡顿,所以需要确定可以执行时间,也就是 Time Slicing(时间切片)。

  • 调度:Scheduler 将任务分配不同的优先级,并将其排入相应的队列中。队列按优先级排序,高优先级任务在低优先级任务之前执行
  • 执行:Scheduler 按预定顺序执行队列中的 task,直到所有 task 都执行完毕或到达截止时间

Scheduler 是一种通用设计,不仅仅应用于 React,任何需要有这样调度功能的地方都可以使用,因此 React 也把它开源.

Tip: Scheduler 并不是具备主动中断能力,是 Task 执行完后,因为 Scheduler 优先级的调整而执行其他的 Task

Time Slicing

大部分浏览器是 60Hz,1000ms / 60Hz = 16.6ms 刷新一次浏览器,我们知道浏览器是在 Js EngineGUI 是互斥,所以如果在刷新时还在执行 JS Code 就会造成卡顿现象。

需要留足够的时间给 GUI 去渲染,React 决定给 JS 的时间是 5ms,至于为什么是 5,能不能是其他?

这个当然可以是其他,例如 15 或者 1,但 15 比较高容易压缩 GUI 时间,1 又太小执行不了太多 JS。5ms 是 React 团队 在不断尝试和优化的基础上得出这个时间间隔在大多数场景下表现得最佳,因此选择了它。

目前是固定的 5ms,也许可以把这个时间交给用户设置?可能有些项目其他的时间更佳,或者 React 是否可以进行收集对比,有点像 AI 学习自我调整

如何实现这样一个 Api?先看现有的 Web Api 有没有问题

  • setTimeout: 在定时器到期后执行

    • setTimeout 容易引起 4ms 时间差的问题,setInterval 也同样,详细原因
  • requestIdleCallback:浏览器空闲时期被调用,最初采用这个 Api

    • 兼容性问题
    • FPS 为 20, 1000ms / 20 = 50 ms 大于 16.7ms 的问题
  • requestAnimationFrame: 浏览器在下次重绘之前调用指定的回调函数

    • 当切换 Tab 后会停止执行,当 Tab 再激活时会按原先地方继续执行
    • 没有明确的渲染时间标准
    • 执行时机不稳定,不是每次事件循环都会触发重渲染,浏览器可能将多次渲染合成一次
  • Generator:生成器函数在执行时能暂停,后面又能从暂停处继续执行

    • 将函数包装在 Generator 堆栈中,增加很多语法开销,也增加运行时开销
    • 生成器是有状态的。无法在其中途恢复。如果你要恢复递归现场,可能需要从头开始, 恢复到之前的调用栈
  • Web Worker:

    • 尝试提出共享的不可变持久数据结构、自定义 VM 调整等。JS 不太适合这种情况,因为可变的共享运行时(如原型)。 生态系统还没有为此做好准备,因为您必须在 Web Worker 间重复代码加载和模块初始化。可以看 Dan 解释

通过代码可知,React 在浏览部份是优先选择 MessageChannel,不兼容则使用 setTimeout

优先选择 MessageChannel 的原因是:

  1. MessageChannel 是宏任务,可以将主线程交给浏览器的 GUI 去渲染,而不像微任务需要在页面更新前全部执行完

  2. 提供了一个异步的通信机制,可以让任务在不同的宏任务之间进行传递,并且不会受到宿主环境对定时器的最小执行时间限制

  3. MessageChannel 的事件回调函数可以被设置为微任务,在下一个宏任务执行前执行

if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // There's a few reasons for why we prefer setImmediate.
  //
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // (Even though this is a DOM fork of the Scheduler, you could get here
  // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
  // https://github.com/facebook/react/issues/20756
  //
  // But also, it runs earlier which is the semantic we want.
  // If other browsers ever implement it, it's better to use it.
  // Although both of these would be inferior to native scheduling.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline)
  }
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = performWorkUntilDeadline
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null)
  }
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    // $FlowFixMe[not-a-function] nullable value
    localSetTimeout(performWorkUntilDeadline, 0)
  }
}

setImmediate: 把长时间运行的操作放在回调函数里,完成后面其他语句后就立刻执行回调函数,目前兼容性不行

Priority

优先级部分主要是以用户交互为最高优先级,还有一些过期任务,数据更新等自然优先级较低,这部分主要是 React 进行确定,但目前的话已经有 useTransition Hooks 可以标记低优先级。

  1. Immediate:最高优先级,用于处理紧急任务,立即执行任务,不考虑其他任务的状态。

  2. User-blocking:用于处理需要立即响应的任务,如动画和用户交互。

  3. Normal:用于处理常规任务,如数据更新和渲染。

  4. Low:最低优先级,用于处理不紧急的任务,如预加载和日志记录、网络请求等。

  5. Idle(最低优先级):处理闲置任务,例如在空闲时间内执行的定时任务等。

Lane

优先级虽然确定,但是需要调度执行的机制,首先可以想到按 Array 的优先级队列来划分 Queue。

const Immediate = [event1, event2, event3]
const Normal = [event4, event5, event6]
const Idle = [event7, event8, event9]
// Normal 的 event5 因为超时需要提升到 Immediate

因为 Task 很容易有优先级的变化,不停的操作 Array 性能上不够好。React 提出了 Lane 模型。

Lane,它类似于汽车的赛道,采用 Bitwise Operations(位运算)来确定赛道,可以用 31 位的二进制值去表示,值的每一位代表一条赛道对应一个 lane 优先级,赛道位置越靠前,优先级越高。

export const TotalLanes = 31

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000

export const SyncHydrationLane: Lane = /*               */ 0b0000000000000000000000000000001
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010

export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000100
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000

export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000010000
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000

export const SyncUpdateLanes: Lane = /*                */ 0b0000000000000000000000000101010

const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000001000000
const TransitionLanes: Lanes = /*                       */ 0b0000000011111111111111110000000
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000010000000
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000100000000
// 还有一些其他的 Lane
  • Sync Lane:最高优先级,同步水合、同步任务等
    • SyncHydrationLane
    • SyncLane
  • Input Continuous Lane:高优先级,主要用于用户交互或持续计算等
    • InputContinuousHydrationLane
    • InputContinuousLane
  • Default Lane:普通优先级,用于处理不需立即响应的任务
    • DefaultHydrationLane
    • DefaultLane
  • Transition Lane:低优先级,例如 useTransition 等
    • TransitionHydrationLane
    • TransitionLanes

以往版本对比,新的 Lane 已经抛弃 SyncBatchedLane,也增加不少 TransitionLaneRetryLane,应该是和 React 更专注在 Server 有关。优先级也改变判断方式。

Reconciler

Reconciler 主要用于查找和确定需要更新的地方,实现 Fiber 架构需要在这里进行重构,可中断就是在 Reconciler 过程中进行中断和恢复。

Flow

以上图为例,假设在执行完 test1 的时候需要中断,Stack 架构的同步交替渲染是无法恢复的。那需要哪些条件才可以?

  • 恢复时需要从兄弟 <span> 去执行就需要一个指针 sibling 指向兄弟的 Fiber
  • test1 完成当前处理后需要知道下一个要处理的 Fiber,子 Fiber 及兄弟节点完成后就需要到其父级节点,react 称为 return

Reconciler 将 Fiber 的完成流程分为 beginWorkcompleteWork 两部分,类似事件捕获冒泡机制。简单说明一下两个的作用

  • beginWork:mount 时创建新的 Fiber 或者 update 时确定能否复用之前的 Fiber
  • completeWork:主要是处理 Props 以及 mount 时生成 Dom 节点

整个过程也称为 Render Phase,当整个 Render Phase 完成之后就会交给 Renderer,也就是 Commit Phase。

workInProgress

整个 Fiber 的处理都是在内存中进行,而页面上的 Dom 和 Dom 对应的 Fiber 也就是 Current Fiber,界面有 update 时会在内存中再创建一个 Fiber 树和 Current Fiber 进行对比,这个 Fiber 树就叫 workInProgress Fiber

fiberRootNode 是应用的根节点,rootFiber 是 <App/> 的根节点。当 workInProgress Fiber 在 Commit Phase 完成,界面渲染完成后,fiberRootNode 会将指针指向 workInProgress Fiber

Flow

EffectList

Render Phase 完成后,会进行到 Commit Phase 进行界面的渲染,可能会有疑问,虽然把 Fiber 处理完,但在 Commit Phase 渲染时是不是又要遍历一次 Fiber 树,然后根据 efffectTag 来确定渲染的 Fiber?

以上面的 HTML 结构为例,Test1 => Test2 只需要处理这步,重新遍历影响性能,尤其结构层级多的时候。

因此 React 将执行完 completeWork 且存在 effectTag(标志要执行的 Dom 操作) 的 Fiber 节点会被保存在 updateQueue.lastEffect 这个单项链表中,也称为 EffectList。

const firstEffect = lastEffect.next;,然后在 Commit Phase 只要遍历这个链表就可以把需要渲染处理的 Fiber 执行完。

Render Phase Flow

Flow

Renderer

Commit Phase 主要就是根据需要处理的 Fiber 进行渲染,GUI 的渲染是不可中断,整个过程主要就是处理生命周期和调度 Hooks。

按界面渲染的流程可以分为以下阶段:

  • BeforeMutation(Dom 操作前):commitBeforeMutationEffects

    • 调用 getDerivedStateFromProps

    • 执行 getSnapshotBeforeUpdate

    • 通过 Fiber 的属性,更新组件的 props 和 state 生成新的 VDOM,在之后的 Dom 操作时使用

    • 调度 useEffect

      调度而不是执行 useEffect,因为 useEffect 的 deps[] 为空会在 commitMutationEffects 执行,不为空则在 commitLayoutEffects

  • Mutation (Dom 操作中):commitMutationEffects

    • 操作 Dom,调用 DOM API 将新增节点插到正确位置上、将要删除节点其从 DOM 树上移除、将要更新的节点的属性和样式等信息进行更新
    • 特殊的 DOM 操作,比如 input 元素的自动聚焦,以及 select 元素的选中状态等
    • 调用 ref 回调函数,在更新后获取对应的组件或 DOM 元素的引用
    • 如果是删除节点,则调用 componentWillUnmount,useLayoutEffect 的销毁函数、解绑 Ref

      commitMutationEffects 并不是直接对 DOM 进行操作,而是将操作封装成一系列的操作命令,然后交给浏览器的渲染引擎进行批量处理,以提高渲染性能。

  • GUI 渲染(渲染中)

  • Layout(DOM 渲染完成后):commitLayoutEffects

    • 同步调用 useLayoutEffect

      为什么同步? 渲染后已经可以获取 Dom 的样式等信息,如果修改 DOM,可以让程序更快地响应用户操作,并减少视觉上的不一致

    • 执行生命周期方法:渲染完成后,依次执行所有需要执行的生命周期方法,包括 componentDidMount 和 componentDidUpdate。

    • 触发 ref 回调函数:渲染完成后,触发所有需要触发的 ref 回调函数,将 ref 引用指向对应的 DOM 元素

    • 调度 useEffect 的销毁函数与回调函数

每个阶段的执行都需要遍历 EffectList,而当整个流程执行完后,会将 fiberRootNode 指向这个 workInProgress Fiber,这个 workInProgress Fiber 就变成 current Fiber。

Commit Phase Flow

Flow

React Flow

Flow

Conclusion

React Fiber 架构主要是通过 Fiber 链表数据结构实现查询下一个 Fiber 和保存当前任务执行结果的功能,再加上实现 Scheduler 来去调度任务的优先级,所以在 Reconciler 阶段就可以进行任务的中断。文章没有对源码进行非常细致的讲解,也是因为 React 非常庞大,单拿 Scheduler 来说,React 用过多种方式来实现,而且 Priority 的判断等也有过改变。并不是说源码不重要,例如要想实现一个 Diff 应该如何做,有这样的疑问依然是非常推荐去看看源码。

在这里也有一些好奇或者说疑问的点

  • 优先级智能化,例如 Web Worker 中自己收集各项的数据,然后去调节一些优先级等,可以根据不同用户行为来区分优先级
  • 是否可以选择将 Class 代码清理,减少包的体积,或者放到 worker 中加载
  • 在 Reconciler 做到了可中断,但本质上需要的时间并没减少,未来的解决方案会是什么,序列化数据放到 worker 或者是新的数据结构

Reference

Powered by Bobolo

Copyright © Bobolo Blog 2021