探索React源码:初探 React fiber

为何引入fiber

React哲学.png

上图是是React官网在介绍React哲学时说的一段话,这其中包含了React的设计理念——快速响应。

但我们都知道,JavaScript 是单线程运行的,在一轮事件环中,浏览器需要经过以下的几个阶段:

从Task队列中选出最紧急的Task -> 清空microtask -> 执行 UI render 操作(有可能被跳过)

当浏览器当前执行的Task十分庞大,执行时间超过50毫秒时,这个Task被称为Long Taskevent loop processing model 中有提过,在单帧内需要给 UI render 留出一定时间,否则过长的 JS 执行会导致单帧内剩余时间不足以在当前 event loop 中执行 UI render 操作时,将跳过 执行 UI render 操作阶段,便会造成卡顿,这时候用户就能感受到应用响应不够及时,这就不符合React的哲学了。

那么该如何解决长任务的问题呢,React在Concurrent 模式介绍 (实验性) – React (reactjs.org)
中说到:产生卡顿的原因很简单:一旦渲染开始,就不能被终止。 要是我们能够把不能把中断的渲染变成可中断的渲染,问题就能解决。为此,React在React 16中引入了Concurrent mode。而Concurrent mode的基础工作单元便是fiber。

React 15的架构

React 15的架构可分为两层:

  • Reconciler
  • Render

其中Reconciler负责找出变化的组件,Render负责将Reconciler找出的变化的组件渲染到页面上,两者交替工作。

React 15的Reconciler被称为Stack Reconciler,是基于递归( n 叉树的前序遍历)的方式进行工作的。下面我们通过一个简单的例子来大致了解下React 15的工作流程。

class Demo extends React.Component {
  constructor(...props) {
    super(...props);
    this.state = {
      num: 1
    };
  }
  onClick() {
    this.setState({
      num: this.state.num + 1
    });
  }
  render() {
    return (
      <div>
        <button onClick={() => this.onClick()}>add button</button>
        <p>{0 + this.state.num}</p>
        <p>{1 + this.state.num}</p>
        <p>{2 + this.state.num}</p>
      </div>
    );
  }
}

在demo中,当点击add button时,count的值会加1,页面更新的大致流程如下所示:

  1. Reconciler发现第一个p下的文本节点1需要更新为2 ,通知Render该文本节点需要更新为2;
  2. Render将此文本节点更新为2
  3. Reconciler发现第二个p下的文本节点2需要更新为3 ,通知Render该文本节点需要更新为3;
  4. Render将此文本节点更新为3;
  5. Reconciler发现第二个p下的文本节点3需要更新为4 ,通知Render该文本节点需要更新为4;
  6. Render将此文本节点更新为4;

React 15无法支撑快速响应理念的原因

  • Stack Reconciler 容易造成卡顿且难以中断
    一旦 Stack Reconciler 开始执行,就会从根节点开始向向下递归进行 n 叉树的前序遍历,直至遍历完成。整个协调过程是同步且连续的。若树结构层级过深,必然造成卡顿。同时这种递归是高度依赖系统调用栈的。系统调用栈难以被中断,并且无法简单地进行现场恢复,如果需要恢复到中断的当前位置,就只能从头再执行一遍。

  • 协调与渲染交替执行
    在介绍React的工作流程中我们可以看到,Render和Reconciler两者交替工作的,若在工作中途发生中断,便可能会造成视图更新不完整,让用户感觉应用出现了异常。

React16以后的架构

React16之后,React的架构变成了三层

  • Scheduler(调度)
  • Reconciler(协调)
  • Renderer(渲染)

在原来的两层的架构基础之上,增加了一层Scheduler层,负责更新调度任务的优先级

Scheduler

React需要一种机制来告诉我们,什么时候需要中断当前任务。因此,React引入了Scheduler负责调度各个更新任务,并给各个更新任务赋予优先级。

Reconciler

React16将React15的Stack Reconciler改造为Fiber Reconciler,我们在后面再详细介绍。

Renderer

Render依然是负责将Reconciler找出的变化的组件同步的渲染到页面上,但与之前不同的是,Renderer与Reconciler是分阶段执行的。

什么是fiber

React16引入了fiber的概念,fiber 也称纤程。

引入fiber后的reconciler称为Fiber Reconciler,fiber包含了多种含义:

作为一个工作单元

fiber 是协调过程中工作单元。React16后,协调的过程从递归变成了可以中断的循环过程。

// 执行协调的循环
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  //shouldYield为Scheduler提供的函数, 通过 shouldYield 返回的结果判断当前是否还有可执行下一个工作单元的时间
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

每当React执行完一个工作单元便会去检查当前是否还有可执行下一个工作单元的时间,若有则继续执行下一个工作单元,如果没有更多得时间则将控制权归还给浏览器,并在浏览器空闲时再恢复渲染。

作为一种数据结构

fiber也可以认为是是一种数据结构,React将VDOM tree由n叉树的结构改造成了由fiber节点组成的多条相交的链表结构。我们来看看fiber节点的属性定义

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // 作为静态数据结构的属性
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // 用于连接其他Fiber节点形成Fiber树
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  // 作为动态的工作单元的属性
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  this.effectTag = NoEffect;
  this.nextEffect = null;

  this.firstEffect = null;
  this.lastEffect = null;

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

  // 指向该fiber在另一次更新时对应的fiber,下面的双缓冲部分会详细介绍
  this.alternate = null;
}

我们重点来看下面三个属性:

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

这三个属性使fiber节点处于三个单向链表当中:

  • child链表(子节点链表)
  • sibling链表(兄弟节点链表)
  • return链表(父节点链表)

各个fiber节点的所处的链表相交组合,便构成了fiber tree。

我们来看一个简单的例子:

function App() {
  return (
    <div>
      father
      <div>child</>  
    </div>
  )
}

对应的fiber tree为:


fiber tree.png

引入fiber为何做到快速响应

可优雅的进行中断

我们再来看看引入fiber后,fiber tree的遍历过程:(不需要完全看懂,只需要看懂遍历的流程就好)

// 执行协调的循环
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  //shouldYield为Scheduler提供的函数, 通过 shouldYield 返回的结果判断当前是否还有可执行下一个工作单元的时间
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork: Fiber): void {
  //...

  let next;
  //...
  //对当前节点进行协调,如果存在子节点,则返回子节点的引用
  next = beginWork(current, unitOfWork, subtreeRenderLanes);

  //...

  //如果无子节点,则代表当前的child链表已经遍历完
  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    //此函数内部会帮我们找到下一个可执行的节点
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }

  //...
}

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    //...

    //查看当前节点是否存在兄弟节点
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      // If there is more work to do in this returnFiber, do that next.
      //若存在,便把siblingFiber节点作为下一个工作单元,继续执行performUnitOfWork,执行当前节点并尝试遍历当前节点所在的child链表
      workInProgress = siblingFiber;
      return;
    }
    // Otherwise, return to the parent
    //如果不存在兄弟节点,则回溯到父节点,尝试查找父节点的兄弟节点
    completedWork = returnFiber;
    // Update the next thing we're working on in case something throws.
    workInProgress = completedWork;
  } while (completedWork !== null);

  //...
}
未命名文件 (2).png

可以看到,React采用child链表(子节点链表)、sibling链表(兄弟节点链表)、 return链表(父节点链表)多条单向链表遍历的方式来代替n叉树的前序遍历。在协调的过程中,我们不再需要依赖系统调用栈。因为单向链表遍历是严格按照链表方向,同时每个节点都拥有唯一的下一节点,所以在中断时,我们不需要维护整理调用栈,以便恢复中断。我们只需要保护对中断时所对应的fiber节点的引用,在恢复中断时就可以继续遍历下一个节点(不管下一个节点是child还是sibling还是return)。

时间分片

时间切片的其实是模拟实现requestIdleCallback,具体如何实现以后有空我们再来讨论(挖坑)。上面我们介绍的协调循环中,workLoopConcurrent中是否要执行下一次的performUnitOfWork的其中一个条件是shouldYield的返回值是否为true。shouldYield是Scheduler提供的函数, 通过 shouldYield 返回的结果我们能够判断浏览器当前是否还有可执行下一个工作单元的时间,从而实现时间分片

可以看到,引入fiber后,我们把协调的过程划分成为各个fiber节点执行过程,并且协调的过程可以优雅的进行中断,当浏览器没有更多的时间进行协调时,我们可以中断掉当前的更新任务,并在浏览器空闲时再次从当前工作单位继续遍历fiber tree。

同时,又因为React16后,Scheduler、Reconciler、Render三个阶段是分开执行,Scheduler与Reconciler 的操作在到达commit阶段之前都不会映射到视图当中,因此不会出现视图更新不完全的情况。

双缓冲机制

双缓存 (opens new window)机制是一种在内存中构建并直接替换的技术。React在协调的过程中就使用了这种技术。

在React中同时存在着两棵fiber tree。一棵是在屏幕上显示的dom对应的fiber tree,称为current fiber tree,而还有一棵是当触发新的更新任务时,React在内存中构建的fiber tree,称为workInProgress fiber tree。

current fiber tree和workInProgress fiber tree中的fiber节点通过alternate属性进行连接。

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React应用的根节点中也存在current属性,利用current属性在不同fiber tree的根节点之间进行切换的操作,就能够完成current fiber tree与workInProgress fiber tree之间的切换。

在协调阶段,React利用diff算法,将产生update的React elementcurrent fiber tree中的节点进行比较,并最终在内存中生成workInProgress fiber tree。此时Renderer会依据workInProgress fiber tree将update渲染到页面上。同时根节点的current属性会指向workInProgress fiber tree,此时workInProgress fiber tree就变为current fiber tree。

为什么升级到了React16+,但却没有感知到与React15之间的差别呢?

React当前是存在多种模式的,由于当前我们大多数的应用依然使用着ReactDOM.render(<App />, rootNode)作为入口函数,这会是React启用legacy模式。在legacy模式中,在遍历fiber tree时,不会进行shouldYield的判断,即为依然使用同步更新:

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

想要启用异步更新,则需要使用ReactDOM.createRoot(rootNode).render(<App />)入口函数以启用concurrent模式。
使用 Concurrent 模式(实验性)一文中,我们可以看到,React当前是存在多种模式的,各个模式的开启方法:

image.png

以及各个模式的特特性:

image.png

大家都可以阅读原文去进行了解。

探索React源码系列文章

探索React源码:初探React fiber

探索React源码:React Diff

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):