React - Fiber

939 阅读19分钟

本文转载自这可能是最通俗的 React Fiber(时间分片) 打开方式这篇文章。

为什么要引入 Fiber 架构

React 为什么要引入 Fiber 架构? 看看下面的火焰图,这是React V15 下面的一个列表渲染资源消耗情况。整个渲染花费了130ms。🔴 在这里面 React 会递归比对VirtualDOM树,找出需要变动的节点,然后同步更新它们, 一气呵成。这个过程 React 称为 Reconcilation(中文可以译为协调)。

image.png

在 Reconcilation 期间,React 会霸占着浏览器资源,一则会导致用户触发的事件得不到响应, 二则会导致掉帧,用户可以感知到这些卡顿。

同步模式下的 React:

1.gif

优化后的 Concurrent 模式下的 React:

2.gif

React 的 Reconcilation 是CPU密集型的操作, 它就相当于我们上面说的’长进程‘。所以初衷和进程调度一样,我们要让高优先级的进程或者短进程优先运行,不能让长进程长期霸占资源。

所以React 是怎么优化的?🔴 为了给用户制造一种应用很快的'假象',我们不能让一个程序长期霸占着资源. 你可以将浏览器的渲染、布局、绘制、资源加载(例如HTML解析)、事件响应、脚本执行视作操作系统的'进程',我们需要通过某些调度策略合理地分配CPU资源,从而提高浏览器的用户响应速率, 同时兼顾任务执行效率。

🔴 所以 React 通过 Fiber 架构,让自己的Reconcilation 过程变成可被中断。适时地让出CPU执行权,除了可以让浏览器及时地响应用户的交互,还有其他好处:

  • 与其一次性操作大量 DOM 节点相比, 分批延时对DOM进行操作,可以得到更好的用户体验。

这就是为什么React 需要 Fiber。

何为 Fiber

对于 React 来说,Fiber 可以从两个角度理解:

一种流程控制语

Fiber 也称协程、或者纤程。

**其实协程和线程并不一样,协程本身是没有并发或者并行能力的(需要配合线程),它只是一种控制流程的让出机制。**要理解协程,你得和普通函数一起来看, 以Generator为例:

普通函数执行的过程中无法被中断和恢复

const tasks = []
function run() {
  let task
  while (task = tasks.shift()) {
    execute(task)
  }
}

Generator 可以:

const tasks = []
function * run() {
  let task

  while (task = tasks.shift()) {
    // 🔴 判断是否有高优先级事件需要处理, 有的话让出控制权
    if (hasHighPriorityEvent()) {
      yield
    }

    // 处理完高优先级事件后,恢复函数调用栈,继续执行...
    execute(task)
  }
}

React Fiber 的思想和协程的概念是契合的: 🔴 React 渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。

那么现在你应该有以下疑问:

1️⃣ 浏览器没有抢占的条件, 所以React只能用让出机制?

2️⃣ 怎么确定有高优先任务要处理,即什么时候让出?

3️⃣ React 那为什么不使用 Generator?

答1️⃣: 没错, React主动让出。

一是浏览器中没有类似进程的概念,任务之间的界限很模糊,没有上下文,所以不具备中断/恢复的条件。二是没有抢占的机制,我们无法中断一个正在执行的程序。

所以我们只能采用类似协程这样控制权让出机制。这个和上文提到的进程调度策略都不同,它有更一个专业的名词:合作式调度(Cooperative Scheduling), 相对应的有抢占式调度(Preemptive Scheduling)。

这是一种’契约‘调度,要求我们的程序和浏览器紧密结合,互相信任。 比如可以由浏览器给我们分配执行时间片(通过requestIdleCallback实现, 下文会介绍),我们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。

image.png

答2️⃣: requestIdleCallback API

上面代码示例中的 hasHighPriorityEvent() 在目前浏览器中是无法实现的,我们没办法判断当前是否有更高优先级的任务等待被执行。

只能换一种思路,通过超时检查的机制来让出控制权。解决办法是: 确定一个合理的运行时长,然后在合适的检查点检测是否超时(比如每执行一个小任务),如果超时就停止执行,将控制权交换给浏览器。

举个例子,为了让视图流畅地运行,可以按照人类能感知到最低限度每秒60帧的频率划分时间片,这样每个时间片就是 16ms。

其实浏览器提供了相关的接口 —— requestIdleCallback API:

window.requestIdleCallback(
  callback: (dealine: IdleDeadline) => void,
  option?: {timeout: number}
)

IdleDeadline的接口如下:

interface IdleDealine {
  didTimeout: boolean // 表示任务执行是否超过约定时间
  timeRemaining(): DOMHighResTimeStamp // 任务可供执行的剩余时间
}

单从名字上理解的话,requestIdleCallback 的意思是让浏览器在'有空'的时候就执行我们的回调,这个回调会传入一个期限,表示浏览器有多少时间供我们执行, 为了不耽误事,我们最好在这个时间范围内执行完毕。

那浏览器什么时候有空?

我们先来看一下浏览器在一帧(Frame,可以认为事件循环的一次循环)内可能会做什么事情:

image.png

浏览器在一帧内可能会做执行下列任务,而且它们的执行顺序基本是固定的:

  • 处理用户输入事件
  • Javascript执行
  • requestAnimation 调用
  • 布局 Layout
  • 绘制 Paint

上面说理想的一帧时间是 16ms (1000ms / 60),如果浏览器处理完上述的任务(布局和绘制之后),还有盈余时间,浏览器就会调用 requestIdleCallback 的回调。例如:

image.png

但是在浏览器繁忙的时候,可能不会有盈余时间,这时候requestIdleCallback回调可能就不会被执行。 为了避免饿死,可以通过requestIdleCallback的第二个参数指定一个超时时间。

另外不建议在requestIdleCallback中进行DOM操作,因为这可能导致样式重新计算或重新布局(比如操作DOM后马上调用 getBoundingClientRect),这些时间很难预估的,很有可能导致回调执行超时,从而掉帧。

目前 requestIdleCallback 目前只有Chrome支持。所以目前 React 自己实现了一个。它利用MessageChannel 模拟将回调延迟到'绘制操作'之后执行。

image.png

任务优先级

上面说了,为了避免任务被饿死,可以设置一个超时时间. 这个超时时间不是死的,低优先级的可以慢慢等待, 高优先级的任务应该率先被执行. 目前 React 预定义了 5 个优先级, 这个我在[《谈谈React事件机制和未来(react-events)》]中也介绍过:

  • Immediate(-1) - 这个优先级的任务会同步执行, 或者说要马上执行且不能中断
  • UserBlocking(250ms) - 这些任务一般是用户交互的结果, 需要即时得到反馈
  • Normal (5s) - 应对哪些不需要立即感受到的任务,例如网络请求
  • Low (10s) - 这些任务可以放后,但是最终应该得到执行. 例如分析通知
  • Idle (没有超时时间) - 一些没有必要做的任务 (e.g. 比如隐藏的内容), 可能会被饿死

答3️⃣: 太麻烦

  • Generator 不能在栈中间让出。比如你想在嵌套的函数调用中间让出, 首先你需要将这些函数都包装成Generator,另外这种栈中间的让出处理起来也比较麻烦,难以理解。除了语法开销,现有的生成器实现开销比较大,所以不如不用。

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

一个执行单元

Fiber的另外一种解读是“纤维”: 这是一种数据结构或者说执行单元。我们暂且不管这个数据结构长什么样,🔴将它视作一个执行单元,每次执行完一个“执行单元”, React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去。

上文说了,React 没有使用 Generator 这些语言/语法层面的让出机制,而是实现了自己的调度让出机制。这个机制就是基于’Fiber‘这个执行单元的,它的过程如下:

假设用户调用 setState 更新组件, 这个待更新的任务会先放入队列中, 然后通过 requestIdleCallback 请求浏览器调度:

updateQueue.push(updateTask);
requestIdleCallback(performWork, {timeout});

现在浏览器有空闲或者超时了就会调用 performWork 来执行任务:

// 1️⃣ performWork 会拿到一个Deadline,表示剩余时间
function performWork(deadline) {

  // 2️⃣ 循环取出updateQueue中的任务
  while (updateQueue.length > 0 && deadline.timeRemaining() > ENOUGH_TIME) {
    workLoop(deadline);
  }

  // 3️⃣ 如果在本次执行中,未能将所有任务执行完毕,那就再请求浏览器调度
  if (updateQueue.length > 0) {
    requestIdleCallback(performWork);
  }
}

workLoop 的工作大概猜到了,它会从更新队列(updateQueue)中弹出更新任务来执行,每执行完一个“执行单元”,就检查一下剩余时间是否充足,如果充足就进行执行下一个执行单元,反之则停止执行,保存现场,等下一次有执行权时恢复。

// 保存当前的处理现场
let nextUnitOfWork: Fiber | undefined // 保存下一个需要处理的工作单元
let topWork: Fiber | undefined        // 保存第一个工作单元

function workLoop(deadline: IdleDeadline) {
  // updateQueue中获取下一个或者恢复上一次中断的执行单元
  if (nextUnitOfWork == null) {
    nextUnitOfWork = topWork = getNextUnitOfWork();
  }

  // 🔴 每执行完一个执行单元,检查一次剩余时间
  // 如果被中断,下一次执行还是从 nextUnitOfWork 开始处理
  while (nextUnitOfWork && deadline.timeRemaining() > ENOUGH_TIME) {
    // 下文我们再看performUnitOfWork
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork, topWork);
  }

  // 提交工作,下文会介绍
  if (pendingCommit) {
    commitAllWork(pendingCommit);
  }
}

image.png

React的 Fiber 改造

1. 数据结构的调整

image.png

左侧是Virtual DOM,右侧可以看作diff的递归调用栈。

上文中提到 React 16 之前,Reconcilation 是同步的、递归执行的。也就是说这是基于函数’调用栈‘的Reconcilation算法,因此通常也称它为Stack Reconcilation

栈挺好的,代码量少,递归容易理解, 至少比现在的 React Fiber 架构好理解, 递归非常适合树这种嵌套数据结构的处理。

只不过这种依赖于调用栈的方式不能随意中断、也很难被恢复, 不利于异步处理。这种调用栈,不是程序所能控制的,如果你要恢复递归现场,可能需要从头开始, 恢复到之前的调用栈。

因此首先我们需要对React现有的数据结构进行调整,模拟函数调用栈, 将之前需要递归进行处理的事情分解成增量的执行单元,将递归转换成迭代

React 目前的做法是使用链表, 每个 VirtualDOM 节点内部现在使用 Fiber 表示, 它的结构大概如下:

export type Fiber = {
  // Fiber 类型信息
  type: any,
  // ...

  // ⚛️ 链表结构
  // 指向父节点,或者render该节点的组件
  return: Fiber | null,
  // 指向第一个子节点
  child: Fiber | null,
  // 指向下一个兄弟节点
  sibling: Fiber | null,
}

用图片来展示这种关系会更直观一些:

image.png

使用链表结构只是一个结果,而不是目的,React 开发者一开始的目的是冲着模拟调用栈去的。

image.png

调用栈最经常被用于存放子程序的返回地址。在调用任何子程序时,主程序都必须暂存子程序运行完毕后应该返回到的地址。因此,如果被调用的子程序还要调用其他的子程序,其自身的返回地址就必须存入调用栈,在其自身运行完毕后再行取回。除了返回地址,还会保存本地变量函数参数环境传递

React Fiber 也被称为虚拟栈帧(Virtual Stack Frame), 你可以拿它和函数调用栈类比一下, 两者结构非常像:

函数调用栈Fiber
基本单位函数Virtual DOM 节点
输入函数参数Props
本地状态本地变量State
输出函数返回值React Element
下级嵌套函数调用子节点(child)
上级引用返回地址父节点(return)

Fiber 和调用栈帧一样, 保存了节点处理的上下文信息,因为是手动实现的,所以更为可控,我们可以保存在内存中,随时中断和恢复。

有了这个数据结构调整,现在可以以迭代的方式来处理这些节点了。来看看 performUnitOfWork 的实现, 它其实就是一个深度优先的遍历:

/**
 * @params fiber 当前需要处理的节点
 * @params topWork 本次更新的根节点
 */
function performUnitOfWork(fiber: Fiber, topWork: Fiber) {
  // 对该节点进行处理
  beginWork(fiber);

  // 如果存在子节点,那么下一个待处理的就是子节点
  if (fiber.child) {
    return fiber.child;
  }

  // 没有子节点了,上溯查找兄弟节点
  let temp = fiber;
  while (temp) {
    completeWork(temp);

    // 到顶层节点了, 退出
    if (temp === topWork) {
      break
    }

    // 找到,下一个要处理的就是兄弟节点
    if (temp.sibling) {
      return temp.sibling;
    }

    // 没有, 继续上溯
    temp = temp.return;
  }
}

你可以配合上文的 workLoop 一起看,Fiber 就是我们所说的工作单元,performUnitOfWork 负责对 Fiber 进行操作,并按照深度遍历的顺序返回下一个 Fiber。

因为使用了链表结构,即使处理流程被中断了,我们随时可以从上次未处理完的Fiber继续遍历下去。

整个迭代顺序和之前递归的一样, 下图假设在 div.app 进行了更新:

image.png

比如你在text(hello)中断了,那么下一次就会从 p 节点开始处理。

这个数据结构调整还有一个好处,就是某些节点异常时,我们可以打印出完整的“节点栈”,只需要沿着节点的return回溯即可。

2. 两个阶段的拆分

image.png

除了Fiber 工作单元的拆分,两阶段的拆分也是一个非常重要的改造,在此之前都是一边Diff一边提交的。先来看看这两者的区别:

  • ⚛️ 协调阶段: 可以认为是 Diff 阶段, 这个阶段可以被中断, 这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等,这些变更React 称之为'副作用(Effect)'。以下生命周期钩子会在协调阶段被调用:

    • constructor
    • componentWillMount 废弃
    • componentWillReceiveProps 废弃
    • static getDerivedStateFromProps
    • shouldComponentUpdate
    • componentWillUpdate 废弃
    • render
  • ⚛️ 提交阶段: 将上一个阶段计算出来的需要处理的 副作用(Effects) 一次性执行了。这个阶段必须同步执行,不能被打断。这些生命周期钩子在提交阶段被执行:

    • getSnapshotBeforeUpdate() 严格来说,这个是在进入 commit 阶段前调用
    • componentDidMount
    • componentDidUpdate
    • componentWillUnmount

也就是说,在协调阶段如果时间片用完,React就会选择让出控制权。因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。

需要注意的是:因为协调阶段可能被中断、恢复,甚至重做,React 协调阶段的生命周期钩子可能会被调用多次!!! 例如 componentWillMount 可能会被调用两次。

因此建议 协调阶段的生命周期钩子不要包含副作用。索性 React 就废弃了这部分可能包含副作用的生命周期方法,例如componentWillMountcomponentWillUpdate。v17后我们就不能再用它们了, 所以现有的应用应该尽快迁移.

现在你应该知道为什么“提交阶段”必须同步执行,不能中断的吧? 因为我们要正确地处理各种副作用,包括DOM变更、还有你在componentDidMount中发起的异步请求、useEffect 中定义的副作用... 因为有副作用,所以必须保证按照次序只调用一次,况且会有用户可以察觉到的变更, 不容出错。

3. Reconcilation

接下来就是就是我们熟知的Reconcilation(为了方便理解,本文不区分Diff和Reconcilation, 两者是同一个东西)阶段了. 思路和 Fiber 重构之前差别不大, 只不过这里不会再递归去比对、而且不会马上提交变更。

首先再进一步看一下Fiber的结构:

interface Fiber {
  /**
   * ⚛️ 节点的类型信息
   */
  // 标记 Fiber 类型, 例如函数组件、类组件、宿主组件
  tag: WorkTag,
  // 节点元素类型, 是具体的类组件、函数组件、宿主组件(字符串)
  type: any,

  /**
   * ⚛️ 结构信息
  */ 
  return: Fiber | null,
  child: Fiber | null,
  sibling: Fiber | null,
  // 子节点的唯一键, 即我们渲染列表传入的key属性
  key: null | string,

  /**
   * ⚛️ 节点的状态
   */
  // 节点实例(状态):
  //   对于宿主组件,这里保存宿主组件的实例, 例如DOM节点。
  //   对于类组件来说,这里保存类组件的实例
  //   对于函数组件说,这里为空,因为函数组件没有实例
  stateNode: any,
  // 新的、待处理的props
  pendingProps: any,
  // 上一次渲染的props
  memoizedProps: any, // The props used to create the output.
  // 上一次渲染的组件状态
  memoizedState: any,


  /**
   * ⚛️ 副作用
   */
  // 当前节点的副作用类型,例如节点更新、删除、移动
  effectTag: SideEffectTag,
  // 和节点关系一样,React 同样使用链表来将所有有副作用的Fiber连接起来
  nextEffect: Fiber | null,

  /**
   * ⚛️ 替身
   * 指向旧树中的节点
   */
  alternate: Fiber | null,
}

Fiber 包含的属性可以划分为 5 个部分:

  • 🆕 结构信息 - 这个上文我们已经见过了,Fiber 使用链表的形式来表示节点在树中的定位。

  • 节点类型信息 - 这个也容易理解,tag表示节点的分类、type 保存具体的类型值,如div、MyComp。

  • 节点的状态 - 节点的组件实例、props、state等,它们将影响组件的输出。

  • 🆕 副作用 - 这个也是新东西. 在 Reconciliation 过程中发现的“副作用”(变更需求)就保存在节点的effectTag 中(想象为打上一个标记)。那么怎么将本次渲染的所有节点副作用都收集起来呢? 这里也使用了链表结构,在遍历过程中React会将所有有‘副作用’的节点都通过nextEffect连接起来。

  • 🆕 替身 - React 在 Reconciliation 过程中会构建一颗新的树(官方称为workInProgress tree,WIP树),可以认为是一颗表示当前工作进度的树。还有一颗表示已渲染界面的旧树,React就是一边和旧树比对,一边构建WIP树的。 alternate 指向旧树的同等节点。

现在可以放大看看beginWork 是如何对 Fiber 进行比对的:

function beginWork(fiber: Fiber): Fiber | undefined {
  if (fiber.tag === WorkTag.HostComponent) {
    // 宿主节点diff
    diffHostComponent(fiber)
  } else if (fiber.tag === WorkTag.ClassComponent) {
    // 类组件节点diff
    diffClassComponent(fiber)
  } else if (fiber.tag === WorkTag.FunctionComponent) {
    // 函数组件节点diff
    diffFunctionalComponent(fiber)
  } else {
    // ... 其他类型节点,省略
  }
}

宿主节点比对:

function diffHostComponent(fiber: Fiber) {
  // 新增节点
  if (fiber.stateNode == null) {
    fiber.stateNode = createHostComponent(fiber)
  } else {
    updateHostComponent(fiber)
  }

  const newChildren = fiber.pendingProps.children;

  // 比对子节点
  diffChildren(fiber, newChildren);
}

类组件节点比对也差不多:

function diffClassComponent(fiber: Fiber) {
  // 创建组件实例
  if (fiber.stateNode == null) {
    fiber.stateNode = createInstance(fiber);
  }

  if (fiber.hasMounted) {
    // 调用更新前生命周期钩子
    applybeforeUpdateHooks(fiber)
  } else {
    // 调用挂载前生命周期钩子
    applybeforeMountHooks(fiber)
  }

  // 渲染新节点
  const newChildren = fiber.stateNode.render();
  // 比对子节点
  diffChildren(fiber, newChildren);

  fiber.memoizedState = fiber.stateNode.state
}

子节点比对:

function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
  let oldFiber = fiber.alternate ? fiber.alternate.child : null;
  // 全新节点,直接挂载
  if (oldFiber == null) {
    mountChildFibers(fiber, newChildren)
    return
  }

  let index = 0;
  let newFiber = null;
  // 新子节点
  const elements = extraElements(newChildren)

  // 比对子元素
  while (index < elements.length || oldFiber != null) {
    const prevFiber = newFiber;
    const element = elements[index]
    const sameType = isSameType(element, oldFiber)
    if (sameType) {
      newFiber = cloneFiber(oldFiber, element)
      // 更新关系
      newFiber.alternate = oldFiber
      // 打上Tag
      newFiber.effectTag = UPDATE
      newFiber.return = fiber
    }

    // 新节点
    if (element && !sameType) {
      newFiber = createFiber(element)
      newFiber.effectTag = PLACEMENT
      newFiber.return = fiber
    }

    // 删除旧节点
    if (oldFiber && !sameType) {
      oldFiber.effectTag = DELETION;
      oldFiber.nextEffect = fiber.nextEffect
      fiber.nextEffect = oldFiber
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    if (index == 0) {
      fiber.child = newFiber;
    } else if (prevFiber && element) {
      prevFiber.sibling = newFiber;
    }

    index++
  }
}

上面的代码很粗糙地还原了 Reconciliation 的过程, 但是对于我们理解React的基本原理已经足够了。

image.png

上图是 Reconciliation 完成后的状态,左边是旧树,右边是WIP树。对于需要变更的节点,都打上了“标签”。 在提交阶段,React 就会将这些打上标签的节点应用变更。

4. 双缓冲

WIP 树构建这种技术类似于图形化领域的“双缓存(Double Buffering)”技术, 图形绘制引擎一般会使用双缓冲技术,先将图片绘制到一个缓冲区,再一次性传递给屏幕进行显示,这样可以防止屏幕抖动,优化渲染性能。

WIP 树构建这种技术类似于图形化领域的'双缓存(Double Buffering)'技术, 图形绘制引擎一般会使用双缓冲技术,先将图片绘制到一个缓冲区,再一次性传递给屏幕进行显示,这样可以防止屏幕抖动,优化渲染性能。

双缓存技术还有另外一个重要的场景就是异常的处理,比如当一个节点抛出异常,仍然可以继续沿用旧树的节点,避免整棵树挂掉。

你可以将 WIP 树想象成从旧树中 Fork 出来的功能分支,你在这新分支中添加或移除特性,即使是操作失误也不会影响旧的分支。当你这个分支经过了测试和完善,就可以合并到旧分支,将其替换掉. 这或许就是’提交(commit)阶段‘的提交一词的来源吧?

image.png

5. 副作用的收集和提交

接下来就是将所有打了 Effect 标记的节点串联起来,这个可以在completeWork中做, 例如:

function completeWork(fiber) {
  const parent = fiber.return

  // 到达顶端
  if (parent == null || fiber === topWork) {
    pendingCommit = fiber
    return
  }

  if (fiber.effectTag != null) {
    if (parent.nextEffect) {
      parent.nextEffect.nextEffect = fiber
    } else {
      parent.nextEffect = fiber
    }
  } else if (fiber.nextEffect) {
    parent.nextEffect = fiber.nextEffect
  }
}

最后,将所有副作用提交:

function commitAllWork(fiber) {
  let next = fiber
  while(next) {
    if (fiber.effectTag) {
      // 提交,偷一下懒,这里就不展开了
      commitWork(fiber)
    }
    next = fiber.nextEffect
  }

  // 清理现场
  pendingCommit = nextUnitOfWork = topWork = null
}

开启 Concurrent Mode 后,我们可以得到以下好处(详见Concurrent Rendering in React):

  • 快速响应用户操作和输入,提升用户交互体验。
  • 让动画更加流畅,通过调度,可以让应用保持高帧率。
  • 利用好 I/O 操作空闲期或者CPU空闲期,进行一些预渲染。比如离屏(offscreen)不可见的内容,优先级最低,可以让 React 等到CPU空闲时才去渲染这部分内容。这和浏览器的preload等预加载技术差不多。
  • Suspense 降低加载状态(load state)的优先级,减少闪屏。比如数据很快返回时,可以不必显示加载状态,而是直接显示出来,避免闪屏;如果超时没有返回才显式加载状态。

参考文章

这可能是最通俗的 React Fiber(时间分片) 打开方式

谈谈React事件机制和未来(react-events)

从Preact中了解React组件和hooks基本原理

React Fiber