Published on

关于Fiber架构

React 架构的优化

React15 架构的缺点

React15 架构可以分为两层:

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

Reconciler中,mount的组件会调用mountComponentupdate的组件会调用updateComponent。这两个方法都会递归更新子组件。

由于递归执行,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了 16ms,用户交互就会卡顿。听起来似乎很好解决,为什么不使用可中断的异步更新代替同步的更新来替代同步更新呢?因为 React15 的架构并不支持异步更新,即使你试图打断执行,React15也不会听你的。。。。。

基于这个原因,React决定重写整个架构。

React16 架构

React16 后的架构可以分为三层:

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

可以看到,相较于 React15,React16 中新增了Scheduler(调度器),也就是实现优先级调度的核心

Scheduler(调度器)

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

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

  • 浏览器兼容性
  • 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换 tab 后,之前 tab 注册的requestIdleCallback触发的频率会变得很低

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

React16 架构优化的核心就在于 Scheduler 实现的时间切片和优先级调度,以及Fiber 的链表结构

时间切片原理

时间切片的本质是模拟实现requestIdleCallback

除去“浏览器重排/重绘”,下图是浏览器一帧中可以用于执行JS的时机。

-- 一个task(宏任务)
-- 队列中全部job(微任务)
-- requestAnimationFrame
-- 浏览器重排/重绘
-- requestIdleCallback

requestIdleCallback是在“浏览器重排/重绘”后如果当前帧还有空余时间时被调用的。

浏览器并没有提供其他API能够在同样的时机(浏览器重排/重绘后)调用以模拟其实现。

唯一能精准控制调用时机的APIrequestAnimationFrame,他能让我们在“浏览器重排/重绘”之前执行JS

这也是为什么我们通常用这个API实现JS动画 —— 这是浏览器渲染前的最后时机,所以动画能快速被渲染。所以,退而求其次,Scheduler时间切片功能是通过task(宏任务)实现的。

最常见的task当属setTimeout了。但是有个tasksetTimeout执行时机更靠前,那就是MessageChannel

所以Scheduler将需要被执行的回调函数作为MessageChannel的回调执行。如果当前宿主环境不支持MessageChannel,则使用setTimeout

Reactrender阶段,开启Concurrent Mode时,每次遍历前,都会通过Scheduler提供的shouldYield方法判断是否需要中断遍历,使浏览器有时间渲染:

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress)
  }
}

是否中断的依据,最重要的一点便是每个任务的剩余时间是否用完。

Schdeduler中,为任务分配的初始剩余时间为5ms。随着应用运行,会通过fps动态调整分配给任务的可执行时间。

这也解释了为什么设计理念一节启用Concurrent Mode后每个任务的执行时间大体都是多于 5ms 的一小段时间 —— 每个时间切片被设定为 5ms,任务本身再执行一小段时间,所以整体时间是多于 5ms 的时间

优先级调度

首先我们来了解优先级的来源。需要明确的一点是,Scheduler是独立于React的包,所以他的优先级也是独立于React优先级的。

Scheduler对外暴露了一个方法unstable_runWithPriority

这个方法接受一个优先级与一个回调函数,在回调函数内部调用获取优先级的方法都会取得第一个参数对应的优先级

function unstable_runWithPriority(priorityLevel, eventHandler) {
  switch (priorityLevel) {
    case ImmediatePriority:
    case UserBlockingPriority:
    case NormalPriority:
    case LowPriority:
    case IdlePriority:
      break
    default:
      priorityLevel = NormalPriority
  }

  var previousPriorityLevel = currentPriorityLevel
  currentPriorityLevel = priorityLevel

  try {
    return eventHandler()
  } finally {
    currentPriorityLevel = previousPriorityLevel
  }
}

可以看到,Scheduler内部存在 5 种优先级。

React内部凡是涉及到优先级调度的地方,都会使用unstable_runWithPriority

比如,我们知道commit阶段是同步执行的。可以看到,commit阶段的起点commitRoot方法的优先级为ImmediateSchedulerPriority

ImmediateSchedulerPriorityImmediatePriority的别名,为最高优先级,会立即执行。

function commitRoot(root) {
  const renderPriorityLevel = getCurrentPriorityLevel();
  runWithPriority(
    ImmediateSchedulerPriority,
    commitRootImpl.bind(null, root, renderPriorityLevel),
  );
  return null;
}

优先级的意义

Scheduler对外暴露最重要的方法便是unstable_scheduleCallback。该方法用于以某个优先级注册回调函数。

比如在React中,之前讲过在commit阶段的beforeMutation阶段会调度useEffect的回调:

if (!rootDoesHavePassiveEffects) {
  rootDoesHavePassiveEffects = true
  scheduleCallback(NormalSchedulerPriority, () => {
    flushPassiveEffects()
    return null
  })
}

这里的回调便是通过scheduleCallback调度的,优先级为NormalSchedulerPriority,即NormalPriority

不同优先级意味着什么?不同优先级意味着不同时长的任务过期时间:

var timeout
switch (priorityLevel) {
  case ImmediatePriority:
    timeout = IMMEDIATE_PRIORITY_TIMEOUT
    break
  case UserBlockingPriority:
    timeout = USER_BLOCKING_PRIORITY_TIMEOUT
    break
  case IdlePriority:
    timeout = IDLE_PRIORITY_TIMEOUT
    break
  case LowPriority:
    timeout = LOW_PRIORITY_TIMEOUT
    break
  case NormalPriority:
  default:
    timeout = NORMAL_PRIORITY_TIMEOUT
    break
}

var expirationTime = startTime + timeout

其中:

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250
var NORMAL_PRIORITY_TIMEOUT = 5000
var LOW_PRIORITY_TIMEOUT = 10000
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt

可以看到,如果一个任务的优先级ImmediatePriority,对应IMMEDIATE_PRIORITY_TIMEOUT-1,那么

var expirationTime = startTime - 1;

则该任务的过期时间比当前时间还短,表示他已经过期了,需要立即被执行。

不同优先级任务的排序

我们已经知道优先级意味着任务的过期时间。设想一个大型React项目,在某一刻,存在很多不同优先级任务,对应不同的过期时间。

同时,又因为任务可以被延迟,所以我们可以将这些任务按是否被延迟分为:

  • 已就绪任务
  • 未就绪任务
if (typeof options === 'object' && options !== null) {
  var delay = options.delay
  if (typeof delay === 'number' && delay > 0) {
    // 任务被延迟
    startTime = currentTime + delay
  } else {
    startTime = currentTime
  }
} else {
  startTime = currentTime
}

所以,Scheduler存在两个队列:

  • timerQueue:保存未就绪任务
  • taskQueue:保存已就绪任务

每当有新的未就绪的任务被注册,我们将其插入timerQueue并根据开始时间重新排列timerQueue中任务的顺序。

timerQueue中有任务就绪,即startTime <= currentTime,我们将其取出并加入taskQueue

取出taskQueue中最早过期的任务并执行他。

为了能在 O(1)复杂度找到两个队列中时间最早的那个任务,Scheduler使用小顶堆实现了优先级队列

Reconciler(协调器)

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

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

/** @noinline */
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress)
  }
}

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

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

export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;

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

Renderer(渲染器)

Renderer根据Reconciler为虚拟 DOM 打的标记,同步执行对应的 DOM 操作。 假设我们点击按钮后,实现了一个数据的自增,他在 React16 架构中整个更新流程就会是如下情况

更新流程

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

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

由于红框中的工作都在内存中进行,不会更新页面上的 DOM,所以即使反复中断,用户也不会看见更新不完全的 DOM(即上一节演示的情况)。

Fiber 的含义

React15及以前, StackReconciler 方案由于递归不可中断问题,如果 Diff 时间过长(JS 计算时间),会造成页面 UI 的无响应(比如输入框)的表现,vdom 无法应用到 dom 中。

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

为了适配这种新的调度器,推出了 FiberReconciler,将原来的树形结构(vdom)转换成 Fiber 链表的形式(child/sibling/return),整个 Fiber 的遍历是基于循环而非递归,可以随时中断。更加核心的是,基于 Fiber 的链表结构,对于后续(React 17 lane 架构)的异步渲染和 (可能存在的)worker 计算都有非常好的应用基础

Fiber包含三层含义:

  1. 作为架构来说,之前React15Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack ReconcilerReact16Reconciler基于Fiber节点实现,被称为Fiber Reconciler

    多个Fiber节点靠如下三个属性连接成树

    // 指向父级Fiber节点
    this.return = null;
    // 指向子Fiber节点
    this.child = null;
    // 指向右边第一个兄弟Fiber节点
    this.sibling = null;
    

    也就是说如果我们的组件是如下结构

    function App() {
      return (
        <div>
          i am
          <span>KaSong</span>
        </div>
      )
    }
    

    他的Fiber树就会是如下结构

    image.png

为什么父级指针叫做return而不是parent或者father呢?因为作为一个工作单元,return指节点执行完completeWork后会返回的下一个节点。子Fiber节点及其兄弟节点完成工作后会返回其父级节点,所以用return指代父级节点。这就是可以打断执行的关键

  1. 作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的 DOM 节点等信息。

  2. 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)。

    如下两个字段会保存调度优先级相关的信息

    // 调度优先级相关
    this.lanes = NoLanes
    this.childLanes = NoLanes
    

Fiber 的双缓存结构

Fiber节点构成的Fiber树就对应DOM树。如果想要更新DOM,就需要用到被称为“双缓存”的技术。双缓存是指在内存中构建并直接替换。在 ReactFiber树的构建与替换——对应着DOM树的创建与更新。

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

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

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

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

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

关于 Fiber 树如何创建与替换,如果文字版不好理解也可以看一下这个视频 卡老师的硬核 React 面试题

render 以及 commit 阶段完整流程

  1. 首先会调用 ReactDOM.render,进入 render 阶段,
  2. 采用深度优先遍历创建 fiber 树(current Fiber)(根节点一路向下找,没有子了找同级的兄弟,然后找上一级的兄弟,每次找到对应的节点就会调用创建阶段的生命周期函数),
  3. 进入 commit 阶段,渲染完成后从根部开始执行 componentDidMount 函数,依次向上

(假设此时用户产生了一次交互,每次调用 this.setState 都会创建完整的 fiber 树)

  1. 调用 this.setState,相应节点数据发生改变
  2. 进入 render 阶段
  3. 采取深度优先遍历重新创建一个 fiber 树(workInProgress Fiber),不更新的节点不调用对应的生命周期函数,如果 reconcile 算法发现这个节点数据变了,他会标记这次变化,调用这个节点对应的生命周期函数 get DerivedStatefromProps,render。
  4. render 阶段完成后会进入 commit 阶段,执行这个节点变化的视图操作
  5. 完成后,新创建的 fiber 树会替换之前的 fiber 树,等待下一次调用 this.setState 再生成一颗新的 fiber 树

mount 时

function App() {
  const [num, add] = useState(0);
  return <p onClick={() => add(num + 1)}>{num}</p>;
}

ReactDOM.render(<App />, document.getElementById("root"));
  1. 首次执行ReactDOM.render会创建fiberRootNode(源码中叫fiberRoot)和rootFiber。其中fiberRootNode是整个应用的根节点,rootFiber<App/>所在组件树的根节点。

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

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

    rootFiber

    fiberRootNode.current = rootFiber;
    

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

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

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

    workInProgressFiber

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

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

    workInProgressFiberFinish

update 时

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

    wipTreeUpdate

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

    这个决定是否复用的过程就是 Diff 算法

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

    currentTreeUpdate

如何实现任务中断和恢复

首先先了解一下为什么不使用 generateer

  1. Generator 不能在栈中间让出。

    比如你想在嵌套的函数调用中间让出, 首先你需要将这些函数都包装成 Generator,另外这种栈中间的让出处理起来也比较麻烦,难以理解。

    除了语法开销,现有的生成器实现开销比较大,所以不如不用。

  2. Generator 是有状态的, 很难在中间恢复这些状态。

React Fiber 实现任务中断和恢复的核心思想是基于协作式的工作调度(Cooperative Scheduling)。

React Fiber 实现任务中断和恢复的关键步骤包括:

  1. 任务分割: React Fiber 将渲染任务划分为多个小的工作单元,每个工作单元对应于组件树中的一个节点或一部分节点。这样就可以在执行每个工作单元时,检查当前任务的优先级,并在必要时中断任务。
  2. 可中断的检查点: 在每个工作单元之间插入了可中断的检查点(Checkpoint),这样在执行工作单元时,就可以根据优先级和时间片来中断任务。这些检查点允许 React 在任意时间中断当前任务,并在稍后恢复执行。
  3. 优先级调度: React Fiber 使用优先级调度算法(Lane)来确定每个工作单元的执行优先级。通过调整优先级,React 可以确保在页面加 载和用户交互时,优先处理最重要的任务,提高页面的响应速度和用户体验。
  4. 时间片调度: React Fiber 还引入了时间片(Time Slice)的概念,用于限制每个工作单元的执行时间。通过将工作拆分成小的时间片,React 可以在每个时间片结束时中断任务,并在下一个时间片恢复执行,从而确保页面渲染的连续性和流畅性。

哪个阶段可以打断执行

image.png

之前是一边 Diff 一边提交的,现在分为两个阶段,reconciliation/render 协调阶段 和 commit 提交阶段 。

(1)协调阶段,可以打断的

  • constructor
  • componentWillMount 废弃
  • componentWillReceiveProps 废弃
  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • componentWillUpdate 废弃
  • render

因为 Reconciliation 阶段能被打断,会出现函数多次调用的情况,所以这些生命周期函数应该避免使用,16 版之后标记为不安全的

(2)提交阶段,不能暂停,一直到界面更新完成

  • getSnapshotBeforeUpdate 严格来说,这个是在进入 commit 阶段前调用

  • componentDidMount(发请求)

  • componentDidUpdate

  • componentWillUnmount

这也就是为什么 React 请求完全不推荐放在 componentWillMount 的原因,对于 componentWillMount 这个生命周期函数的调用次数会变得不确定,React 可能会多次频繁调用 componentWillMount,一个请求重复发送多次,这显然不是我们想要的结果

Lane -React17 后推出的优先级机制

React 17 中推出了 Concurrent 模式,他是一组 React 的新功能,可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整。

现在,React 中有三套优先级机制:

  1. React 事件优先级
  2. Lane 优先级
  3. Scheduler 优先级

17 之前,React可以控制更新Fiber架构中运行/中断/继续运行。当一次更新在运行过程中被中断,过段时间再继续运行,这就是“异步可中断的更新”。

当一次更新在运行过程中被中断,转而重新开始一次新的更新,我们可以说:后一次更新打断了前一次更新

这就是优先级的概念:后一次更新优先级更高,他打断了正在进行的前一次更新

但是,多个优先级之间如何互相打断?优先级能否升降?本次更新应该赋予什么优先级? 先前,如果「高优先级 IO 任务」阻塞了「低优先级 CPU 任务」,那么可能会出现即使你后台数据哐哐更新,但页面数据已经不发生改变的情况。

这就需要一个模型控制不同优先级之间的关系与行为,于是lane模型诞生了。

lane模型使用 31 位的二进制表示 31 条赛道,位数越小的赛道优先级越高,某些相邻的赛道拥有相同优先级

既然lane对应了二进制的位,那么优先级相关计算其实就是位运算。

比如:

计算ab两个lane是否存在交集,只需要判断ab按位与的结果是否为0

export function includesSomeLane(a: Lanes | Lane, b: Lanes | Lane) {
  return (a & b) !== NoLanes
}

计算b这个lanes是否是a对应的lanes的子集,只需要判断ab按位与的结果是否为b

export function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane) {
  return (set & subset) === subset
}

将两个lanelanes的位合并只需要执行按位或操作:

export function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
  return a | b
}

set对应lanes中移除subset对应lane(或lanes),只需要对subsetlane(或lanes)执行按位非,结果再对set执行按位与。

export function removeLanes(set: Lanes, subset: Lanes | Lane): Lanes {
  return set & ~subset
}