React Fiber 原理剖析

avatar
@https://www.tuya.com/

本文由团队成员寒汀撰写,已授权涂鸦大前端独家使用,包括但不限于编辑、标注原创等权益。

React 自从 2013 年 5 月开源以来,一路披襟斩棘到前端最热门框架之一,框架本身具有以下特性。

  • Declarative(声明式)
  • Component-Based(组件式)
  • Learn Once, Write Anywhere(多端渲染式)

除此之外还有快速高效等特点,主要得益于 Virtual Dom 的应用,虚拟 Dom 是一种 HTML DOM 节点的抽象描述,存在 JS 中的结构对象中,当渲染时通过 Diff 算法,找到需要变更的节点进行更新,这样就节省了不必要的更新。

React 快速响应主要制约于 CPU瓶颈,比如以下栗子所示:

function App() {
const len = 3000;
  return (
    <ul>
      {Array(len).fill(0).map((_, i) => <li>{i}</li>)}
    </ul>
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl); 

当需要被渲染的节点很多时,有存在大量的 JS 计算,因为 GUI渲染线程JS执行线程 是互斥的,所以在 JS 计算的时候就会停止浏览器界面渲染行为,导致页面感觉卡顿。

主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次,也就说渲染一帧的时间必须控制在16ms内才能保证不掉帧。

这段时间内需要完成以下操作:

  • 脚本执行(JavaScript)
  • 样式计算(CSS Object Model)
  • 布局(Layout)
  • 重绘(Paint)
  • 合成(Composite)

JS->Style->Layout->Paint->Composite 过程,既然 JS 执行比较耗时,能不能中断或暂停JS的执行,把执行权交回给渲染线程呢? 首先看一下 React 是怎么去做这事的

// react/packages/scheduler/src/forks/SchedulerHostConfig.default.js
// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let yieldInterval = 5;
let deadline = 0;

从源码中可以看到,React 每次会利用这部分时间(5ms)更新组件,当超过这个时间 React 就会将执行权就还给浏览器由浏览器自主分配执行权,React 本身则等待下一帧时间来继续被中断的工作,这就引入了一个 时间切片 的概念。将耗时的长任务拆分到每一帧中,一次执行小块任务。总结来说就是将 同步的更新变成可中断的异步更新

React v15 Stack Reconciler

ReactDOM.render(<App />, rootEl);

React DOM 将 <App /> 递给 Reconciler,此时 Reconciler 将会检查 App 是 函数

  • 【函数】 -> App(props)
  • 【类】 -> new App(props) 来实例化 App, 并调用生命周期方法

componentWillMount(),之后调用 render() 方法来获取渲染的元素

tips:面试过程中通常会问函数组件和类组件,两者是否都被实例化?答案就在上面

此过程是基于树的深度遍历的递归过程(遇到自定义组件就会一直的递归下去,直到最原始的 HTML 标签),Stack Reconciler 的递归一旦进入调用栈就无法中断或暂停,如果当组件嵌套很深或数量极多,在 16ms 内无法完成就势必造成浏览器丢帧导致卡顿。 刚在上面也提过解决方案就是将 同步的更新变成可中断的异步更新,但 15 版本架构不支持异步更新,所以React团队决定撸起袖子重写,折腾了两年多终于在 2017/3 发布了可用版本。

React Fiber

在首次渲染中构建出虚拟 dom 树,后续更新时(setState)通过 diff 虚拟 dom 树得到 dom change,最后将 dom change 应用到真实 dom 树中,Stack Reconciler 自顶向下递归(mount/update)无法中断导致主线程上的布局/动画/交互响应无法及时得到处理,引起卡顿。

这些问题 Fiber Reconciler 能够解决。

Fiber 原意纤维,工作最小单元,每次通过 ReactDOM.rende 首次构建时都会生成一个 FiberNode,接下来具体看下 FiberNode 结构。

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;  // FiberNode类型,目前总有25种类型,常用的就是FunctionComponent 和 ClassComponent
  this.key = key; //和组件Element中的key一致
  this.elementType = null;
  this.type = null;  //Function|String|Symbol|Number|Object
  this.stateNode = null; //FiberRoot|DomElement|ReactComponentInstance等绑定的其他对象

  // Fiber
  this.return = null; // FiberNode|null 父级FiberNode
  this.child = null; // FiberNode|null 第一个子FiberNode
  this.sibling = null;// FiberNode|null 相邻的下一个兄弟节点
  this.index = 0;  //当前父fiber中的位置

  this.ref = null; //和组件Element中的ref一致

  this.pendingProps = pendingProps; // Object 新的props
  this.memoizedProps = null; // Object 处理后的新props
  this.updateQueue = null; // UpdateQueue 即将要变更的状态
  this.memoizedState = null;  //Object 处理后的新state
  this.dependencies = null;

  this.mode = mode; // number
  // 普通模式,同步渲染,React15-16的生产环境使用
  // 并发模式,异步渲染,React17的生产环境使用
  // 严格模式,用来检测是否存在废弃API,React16-17开发环境使用
  // 性能测试模式,用来检测哪里存在性能问题,React16-17开发环境使用

  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null; // render阶段的diff过程检测到fiber的子节点如果有需要被删除的节点

  this.lanes = NoLanes; //如果fiber.lanes不为空,则说明该fiber节点有更新
  this.childLanes = NoLanes; //判断当前子树是否有更新的重要依据,若有更新,则继续向下构建,否则直接复用已有的fiber树

  this.alternate = null; //FiberNode|null 候补节点,缓存之前的Fiber节点,与双缓存机制相关,后续讲解

}

所有 fiber 对象都是 FiberNode 实例,通过 tag 来标识类型。通过 createFiber 初始化 FiberNode 节点,代码如下

const createFiber = function(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
): Fiber {
  // $FlowFixMe: the shapes are exact here but Flow doesn't like constructors
  return new FiberNode(tag, pendingProps, key, mode);
};

Fiber解决这个问题的解法是把渲染/更新过程拆分成一系列小任务,每次执行一小块,再看是否有剩余时间继续下一个任务,有则继续,无则挂起,将执行线程归还。

Fiber Tree

通过虚拟 dom 树,react 会再创建一个 Fiber Tree,不同的 Element 类型对应不同类型的 Fiber Node,在后续的更新过程中每次重新渲染都会重新创建 Element,但是 Fiber 不会重新创建,只会更新自身属性。

顾名思义,通过多个 Fiber Node 组成了一个 Fiber Tree,也是为了满足 Fiber 增量更新的特性才拓展出了 Fiber Tree 结构。

Fiber Tree.png

首先每个节点是统一的,会有两个属性 FirstChildNextSibiling,第一个指向节点第一个儿子节点,第二个指向下一个兄弟节点,Fiber 这种单链表结构就可以把整个树串联起来。同时 Fiber Tree 在 Instance 层又新增了额外三个实例:

  • effect:每个workInProgress tree节点上都有一个effect list 存放diff结果,更新完毕后updateQueue进行收集
  • workInProgress: reconcile过程中的快照,工作过程节点,用户不可见
  • fiber:用来描述增量更新所需的上下文信息

这里我们着重来理解一下 workInProgress 到底起了什么作用?首先通过代码来看下它是如何被创建的

// This is used to create an alternate fiber to do work on.
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode,
    );
  // 以下两句很关键
  workInProgress.alternate = current;
  current.alternate = workInProgress;
  // do something else ...
  } else {
  // do something else ...
  }
 // do something else ...
  return workInProgress;
}

首先 workInProgress 一个 Fiber 节点,当前节点的 alternate 为空时,通过 createFiber 创建,每次状态更新都会产生新的 workInProgress Fiber 树,通过 current 与 workInProgress 的替换完成 dom 更新,简单来说当 workInProgress Tree 内存中构建完成后直接替换 Fiber Tree 的做法,就是刚刚提到的 双缓冲机制

workInProgress.png

当内存中的 workInProgress 树直接构建完成后,直接替换了页面需要渲染的 Fiber 树,这是 mount 的过程。

当页面其中一个 node 节点发生变更时,会开启一次新的 render 阶段并构建一颗心的 workInProgress 树,这里有个优化点就是 因为每个 node 节点都有一个 alternate 属性互相指向,在构建时会尝试复用当前 current Fiber 树已有的节点内属性,是否复用取决于 diff 算法判断。

update.png

在更新过程中,React 在 filbert tree 中实际发生改变的 fiber 上创建 effect,所有 effect 构成 effect list 链表,在 commit 阶段执行,实现了只对实际发生改变的 fiber 做 dom 更新,避免了遍历整个 fiber tree 造成性能浪费。每当一个 Fiber 节点的 flags 字段不为 NoFlags 时,就会把此 Fiber 节点添加到 effect list 中,根据每一个 effect 的 effectTag 类型执行对应的 dom 树更改。

递归Fiber节点

Fiber 架构下的每个节点都会经历 两个过程,即 beginWork/completeWork。

1、beginWork

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
// do something else
}
  • current: 当前组件上一次更新的 Fiber 节点,workInProgress.alternate
  • workInProgress: 当前组件内存的 Fiber 节点
  • renderlanes: 相关优先级

由于双缓存机制的存在,我们可以通过 current === null 来判断组件是处于 mount 还是 uplate,当 mount 时会根据 fiber.tag 创建不同类型的子 Fiber 节点,当 update 时 didReceiveUpdate === false 就可以直接复用前一次更新的子 Fiber 节点,具体判断如下:

if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (
      oldProps !== newProps ||
      hasLegacyContextChanged() ||
      (__DEV__ ? workInProgress.type !== current.type : false)
    ) {
      didReceiveUpdate = true;
    } else if (!includesSomeLane(renderLanes, updateLanes)) {
      didReceiveUpdate = false;
      switch (workInProgress.tag) {
        // do something else
      }
      return bailoutOnAlreadyFinishedWork(
        current,
        workInProgress,
        renderLanes,
      );
    } else {
      didReceiveUpdate = false;
    }
  } else {
    didReceiveUpdate = false;
  }

2、completeWork

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case IndeterminateComponent:
    case LazyComponent:
    case SimpleMemoComponent:
    case FunctionComponent:
    case ForwardRef:
    case Fragment:
    case Mode:
    case Profiler:
    case ContextConsumer:
    case MemoComponent:
      return null;
    case ClassComponent: {
     // do something else
      return null;
    }
    case HostRoot: {
      // do something else
      updateHostContainer(workInProgress);
      return null;
    }
    case HostComponent: {
      // do something else
      return null;
    }
  // do something else
}

传入参数和beginWork一致,不做过多讲解,completeWork 会根据 tag 不同调用不同的处理逻辑。对于处理的当前节点是 mount 还是 update 阶段同样可以使用 current === null 来做判断。由于 completeWork 属于“归”阶段的函数,每次调用 appendAllChildren 都会将已生成的子孙节点插入当前生成的 dom 节点,这样就一个完整的 dom 树了。

3、effectList

每个执行完 completeWork 并且存在 effectTag 的 Fiber 节点都会保存在 effectList 单向链表中,同时 effectList 第一个和最末个 Fiber 节点会分别保存在 fiber.firstEffect /fiber.lastEffect 属性中。

effectList.png

effectList 使得 commit 阶段只需要遍历 effectList 就可以了,提高了运行性能, 至此 render阶段告一段落。

写在最后

我觉得 React Fiber是一种解决问题的理念架构,从 React16 架构来说分为三层:Scheduler/Reconciler/Renderer

它利用浏览器的空闲时间完成循环模拟 过程,所有操作都在内存中进行,只有所有组件完成 Rconciler 工作,才会走 Renderer 一次渲染展示,提升效率。