2023 年,你是时候要掌握 react 的并发渲染了(1) - fiber 架构

所有的研究结果都是基于 react@18.2.0。
注意,这是一个系列文章之一。本文主要讲「fiber 架构跟并发渲染之间的关系」。

并发渲染简史

JSConf Iceland 2018 大会上,Dan Abramov 首次提出了「time slicing」,惊艳了普罗大众。这是前端界首次把相对复杂的操作系统概念带到了自身的领域里面来。

在大会上,Dan 展示了两个 Demo。第一个是「CPU demo」,第二是「IO demo」。第一个 demo 用来解释了「time slicing」这个概念。Dan 是如是说道:

We've built a generic way to ensure that high-priority updates don't get blocked by a low-priority update, called time slicing.

在大会上,针对 heavy component 的 re-render 的场景,Dan 用三种实现方案去做了效果比对:

  • sync 方案
  • debounced 方案
  • Asynchronous 方案

可以看出,此时,async rendering 几乎是跟 time slicing 同一时间提出来的。该概念主打的卖点是「非阻塞型的渲染」和「创造高可响应性的用户体验」。

到了 react@17 的时候,react 提出了「concurrent mode」,目的是帮助开发者以渐进的方式去拥抱和使用「async rendering」。在 concurrent mode 下面,react 所有情况下的渲染行为都是异步渲染。

到了 react@18 的时候,react 团队宣布废除 concurrent mode 概念,强调只有 concurrent feature。从这个时候开始,「concurrent rendering」概念开始出现并流行。

在 react@18 中,存在两种渲染模式,一种是「同步渲染」(sync rendering),另外一种是「并发渲染」(concurrent rendering)。一般情况下,只有使用了 concurrent feature 才会激活 react 的并发渲染特新。

从上面的介绍,我们可以看出了,我们先后经历了下面几个概念:

  1. time slicing/async rendering;
  2. concurrent mode
  3. concurrent rendering

这个过程持续了四五年,其实是 react 团队对最初的 time slicing 的概念调整重塑。那时至今日的 2023, react@18 中的 「concurrent rendering」到底是指什么呢?

concurrent rendering 到底是指什么?

对于这个概念,react 官方一直没有给出也不愿意给出一个准确的概念。他们美其名曰:"react 使用者不必了解 react 的底层原理,你们只需要表达你们需要什么样的用户体验即可,我们来帮你们实现"。不但对于「concurrent rendering」这个概念是如此,对于诸多稍微底层一点的概念,在官方文档 - Glossary of React Terms上也只字不提。这种风格很 react - 一贯的固执己见和自以为是(这种措辞本意是中性)。

对于这个问题,react 官方在 react@18 的发版 blog 上稍微提到了一点:

Concurrency is not a feature, per se. It's a new behind-the-scenes mechanism that enables React to prepare multiple versions of your UI at the same time...A key property of Concurrent React is that rendering is interruptible.React may start rendering an update, pause in the middle, then continue later. It may even abandon an in-progress render altogether. React guarantees that the UI will appear consistent even if a render is interrupted.

综合 Dan 在 Glossary + Explain Like I'm Five上对 concurrent rendering 作的比喻,我们可以总结出 concurrent rendering 基本是指:

  • react 可以「在同一个时间段」去接受并处理多个界面更新请求;
  • 一个界面更新请求就是一个 task。在 concurrent rendering 模式下:
    • task 可以先暂停-然后再恢复执行;
    • task 有可能进行到一半被废弃掉,然后再从头来过;
    • 高优先级的 task 可以中断低优先级的 task 的执行;
    • 界面的渲染不会阻塞界面的交互。

concurrent rendering 的三大基石

上面的话术其实都是从 react 的外部,以较浅的角度去阐述「concurrent rendering 是什么」。从 react 的内部实现来看。其实 concurrent rendering 具体就是指三个子系统:

  • fiber 架构
  • scheduler 调度系统
  • 优先级系统

这三大子系统既独立又相互交互,它们是 concurrency react 的三大基石。而 concurrent rendering 是 concurrency react 的一个最大的特性。故而,我们也可以说,这三大子系统是 concurrent rendering 的三大基石。

要想了解 concurrent rendering 的实现原理,那就必须掌握这三个子系统的行为特征和工作原理。下面我编写了一个系列的文章,帮助大家逐个逐个地突破它们。

Let's go and have some fun!

fiber 架构

当提及「fiber」的时候,它应该是包含两个层面的意思:

  • 狭义:它就是指 fiber node,一种存储 react 组件 meta 数据的数据结构;
  • 广义:相对于 react 旧的 reconciliation 算法(stack reconciler),react 实现了一种新算法 - fiber reconciler,它是建立在 fiber tree 数据结构之上的一种处理界面更新流程的算法。简单来说,fiber 架构 = fiber reconciler + fiber tree

众所周知,以 stack reconciler 算法为核心的界面更新流程本质上是一个跑在 event loop call stack 里面,同步执行的递归函数调用过程。因为本质上其实是一个函数调用,而 js 函数天生是「run-to-completion」的。所以说,这个过程是一镜到底,不可中断的(耶稣来了也不行)。react 就是在这个不可中断的过程中完成了完整的的界面更新 - 在「递」的时候去做 diff,找出前后 element tree 的不同点,最后在「归」的时候根据不同点来操作 DOM 进行实质的界面更新操作。因为,在以 stack reconciler 为核心算法的 react 中,react element(也被称之为「virtual DOM」)是内部最底层的数据结构。故,我们也可以说:stack 架构 = stack reconciler + react element tree

跟 stack 架构相比,fiber 架构有以下几个不同点:

  • fiber 架构更底层;
  • fiber 架构中,界面更新流程被清晰地划分为两个先后阶段;
  • fiber 架构之下,界面更新流程是可以中断的;

fiber 架构更底层

从数据结构的层级来看,stack 架构是这样的:

js 复制代码
  ____________________________
 |      react element         |   |
 |____________________________|  /|\
  ____________________________    |
 |           DOM              |   |
 |____________________________|   |

而 fiber 架构则是这样:

js 复制代码
 ____________________________
|      react element         |
|____________________________|  |
 ____________________________  /|\
|        fiber node          |  |
|____________________________|  |
 ____________________________   |
|           DOM              |  |
|____________________________|  |

react element 跟 fiber node 之间是有联系的:react element 是通过 fiber node 的type或者elementType 属性挂载在 fiber node 身上。如下图所示:

fiber 架构中,界面更新流程被清晰地划分为两个先后阶段

  1. 先是 render 阶段,主要负责渲染组件,通过 diff 找出不同点,给 fiber node 打上相应的 flag 来做好标记;
  2. 后是 commit 阶段,遍历 fiber tree,根据 fiber 节点上的 flag 去做相应的 DOM 的更新操作(和其他副作用类型的操作)。

这跟 stack 架构中,diff 操作跟 DOM 更新操作几乎同时发生,找不到一个清晰界限点是不同的。

fiber 架构之下,界面更新流程是可以中断的

这是人尽皆知了。但是更严谨点说,可以中断的只是是界面更新流程的 render 阶段,界面更新流程的 commit 阶段是不能中断的,必须同步地执行。原因很简单,基于良好用户体验的考虑:用户需要看到一次性看到一个更新完整的界面,而不是先看到一部分,最后才看到另外一部分。

那 react 是如何实现界面更新流程阶段的可中断呢?

首当其冲的是做出了第一步,把组件的渲染和DOM更新进行了分离,使得一个更新请求所导致的所有的组件渲染工作变成了一个「纯计算型」的任务,而不掺杂着「副作用」DOM 操作。

然后,第二步就是把上面这个「纯计算型」的任务分散到各个基础结构单元上 - fiber node。用 fiber node 来保存渲染一个组件所需要的所有数据。最终,这些 fiber node 根据深度优先遍历算法来链接起来。

有了上面的基础铺垫,我们才来到了最后一步:用一个 while(){} 循环来代替函数递归调用。两者之间的区别,我们拿组件更新的场景举个例子:

js 复制代码
// stack reconciler
function updateComponent(componentInstance){
    // ......
    const nextElement = componentInstance.render()
    updateComponent(nextElement)
}

// fiber reconciler
let workInProgress = root
function workLoop(){
 while(workInProgress !== null){
     performUnitOfWork(workInProgress);
 }
}

现在,在 fiber 架构中,要想主动打断整个界面更新流程,实现也很简单,那其实就是跳转这个while(){} 循环。其实,react 可以这么实现的,这么写代码语义上更直观:

js 复制代码
  function workLoopConcurrent() {
    // Perform work until Scheduler asks us to yield
    while (workInProgress !== null) {
      performUnitOfWork(workInProgress);
      
      if(shouldYield()){
          break;
      }
    }
  }

但是 react 的源码是图个简洁:

js 复制代码
  function workLoopConcurrent() {
    // Perform work until Scheduler asks us to yield
    while (workInProgress !== null && !shouldYield()) {
      performUnitOfWork(workInProgress);
    }
  }

上面源码的注释也解释得很清楚了,是 scheduler 的时间片检查函数 shouldYield() 负责来告诉 react 中断 work loop 的。

你不是说你有「中断-恢复」的能力吗?那中断之后,怎么恢复 work loop 呢?魔法就在 workInProgress 这个全局变量上。它有两层作用:

  1. 指示当前应该要执行 work 的 fiber node 是哪一个;
  2. 在执行完 work 之后,它永远指向下一个应该执行 work 的 fiber node。

当下次 react 尝试再次进入 work loop 之前,它发现 workInProgress 是有值,于是便顺利进入 work loop 了。

从这里你可以看出,其实能够「中断」的只是一个界面更新的 render 阶段,不能中断的是「对单个 fiber node 执行 work」 。为什么?因为 performUnitOfWork(workInProgress) 是一个函数调用且里面没有被「循环-退出」机制。上面说到了,在 js 中,函数调用是「run-to-completion」的 - 一旦开始了,就无法停下来,直到调用完成为止。这是 js 引擎所决定的,react 团队再厉害也于事无补。

我写文章,志在足够深入和严谨。这是我的一贯风格。那么我们继续深入探讨一下「不可中断的最小工作单元是什么?」。这个主题对于后面理解 concurrent rendering 的行为表现是十分有帮助的。

不可中断的最小工作单元是什么?

上面我们已经把不可中断的最小单元缩小到「对单个 fiber node 执行 work」(即 performUnitOfWork()函数的调用)。从更深入理解的初衷去想,那我们还有继续缩小的空间吗?

这就关系到你对 performUnitOfWork(workInProgress) 这个函数实现的理解了。这里我就不深入讲解,感兴趣的可以看我的《770 行代码还原 react fiber 初始链表构建过程》

一个实现事实是,react 把 performUnitOfWork() 的工作流程划分为了两个先后的阶段:

  1. begin work
  2. complete work

performUnitOfWork() 工作流程了解得足够深的人都知道,对于一个特定的 fiber node,它的 begin work 和 complete work 阶段并不一定是紧挨着发生的。这块的事实就藏在 performUnitOfWork() 的源码实现里面:

js 复制代码
 function performUnitOfWork(unitOfWork) {
    // ......
    let next;
    {
      next = beginWork(current, unitOfWork, renderLanes);
    }
    // ......
    if (next === null) {
      // If this doesn't spawn new work, complete the current work.
      completeUnitOfWork(unitOfWork);
    } else {
      workInProgress = next;
    }

    ReactCurrentOwner$2.current = null;
  }

可以看出,当 next 为 null 的时候,react 才会紧接着对当前的 fiber node 去执行 complete work 操作。在这里,next 的全称是 nextWorkInProgress,是指下一个要执行 work 的 fiber 节点。那什么情况下,执行完beginWork()之后 nextWorkInProgress 为 null 呢? 只有一种情况:

  • 当执行 work 的对象是 fiber tree 上的「叶子节点」的时候

那什么样的 fiber node 算是整棵 fiber tree 的叶子节点呢?有三种情况:

  • 文本节点;
  • 单文本节点的 host component;
  • 没有 children 的 host component;

深入 beginWork() 的调用栈,我们都能在源码里面找到相应的逻辑。

对于第一种情况「文本节点 」,beginWork() 调用栈的尽头是:

js 复制代码
 function updateHostText(current, workInProgress) {
    if (current === null) {
      tryToClaimNextHydratableInstance(workInProgress);
    } // Nothing to do here. This is terminal. We'll do the completion step
    // immediately after.

    return null;
  }

对于后面这两种的情况,beginWork() 调用栈(updateHostComponent() -> reconcileChildren() -> mountChildFibers()/reconcileChildFibers() -> ChildReconciler() -> reconcileChildFibers -> deleteRemainingChildren())的尽头是:

js 复制代码
function deleteRemainingChildren(returnFiber, currentFirstChild) {
      if (!shouldTrackSideEffects) {
        // Noop.
        return null;
      } // TODO: For the shouldClone case, this could be micro-optimized a bit by
      // assuming that after the first child we've already added everything.

      let childToDelete = currentFirstChild;

      while (childToDelete !== null) {
        deleteChild(returnFiber, childToDelete);
        childToDelete = childToDelete.sibling;
      }

      return null;
    }

在上面的两个终结函数里面,可以看到,无论走哪一个条件分支语句,最终返回的都是 null

综上所述,当performUnitOfWork() 发生在 fiber tree 的叶子节点的时候,react 会在对执行完 beginWork() 后紧跟着执行completeWork()。换句话说,对于叶子节点而言,它的 beginWork()操作和 completeWork()操作是一个不可中断的工作单元。

到这里,我可以为「不可中断的最小工作单元」缩小到这样:

  • 对于叶子节点 的 fiber node 来说,它的不可中断的最小工作单元是完整的「beginWork() + completeWork()」;
  • 对于非叶子节点 的 fiber node 来说,它的不可中断的最小工作单元是仅仅是beginWork()

那是不是到这里就结束了呢?不是的,我们还可以继续缩小。就像我在自己的文章上说过,beginWork() 的核心工作就是「计算出nextWorkInProgress」,而使用的公式是:

js 复制代码
nextWorkInProgress = reconcile(workInProgress + nextChildren)

进入 beginWork() 工作流程之前,workInProgress 是有了,那 nextChildren 怎么来呢?它就是通过调用 workInProgress.type()得到的。了解 fiber node 数据结构的人知道,workInProgress.type 指的就是我们的组件函数(当前只聚焦到 function component 类型的组件中)。这一步具体是发生在 renderWithHooks() 函数调用里面:

js 复制代码
  function renderWithHooks(
    current,
    workInProgress,
    Component,
    props,
    secondArg,
    nextRenderLanes
  ){
  // ......
  let children = Component(props, secondArg);
  // ......
 }

以上是针对 function component 的讨论,对于 class component 而言,原理是一样的,class component 的 render 方法最终也是在 beginWork() 调用栈的一环的 finishClassComponent 函数中被调用的:

js 复制代码
   function finishClassComponent(
    current,
    workInProgress,
    Component,
    shouldUpdate,
    hasContext,
    renderLanes
  ) {
  // ......
  if (......) {
      // ......
    } else {
      nextChildren = instance.render();
    }
  // ......
  }

说了这么多是啥意思呢?聪明的人都知道,上面提到的 let children = Component(props, secondArg);nextChildren = instance.render(); 其实就是指我们日常开口闭口的「组件渲染」的概念。

一言以概之,「组件渲染 」是发生在 begin work 子阶段的。而上面我们已经指出了,不可中断的最小的工作单元是 beginWork() ,那现在基于上面的探索,我们是不是可以说,不可中断的最小的工作单元是「组件渲染」呢?答案是肯定的。

是的,在 react 的并发渲染模式下,不可中断的最小的工作单元是「组件渲染」!!!

我用三个感叹号强调这个认知很重要。

小结

我们知道 react 用一个 while(){} 循环加上一个全局变量 workInProgress 来实现了界面更新流程的中断和恢复。我们更是知道了在这种模式下:

  • 最大的不可中断的工作单元是 performUnitOfWork();
  • (user land 代码中我们需要关注的)最小的不可中断的工作单元是「组件渲染」;

明白最小的不可中断的工作单元是「组件渲染」这一点很重要。因为这一点能够提醒你 react 的 time slicing 并不是万能的,一旦你组件的渲染函数出现了 long task 的代码,神仙也救不了你。为什么,因为单个「组件渲染」是无法被中断的(后面在讲「scheduler」如果实现 time slicing 的时候会举例说明)。

最后我们用一张图来结束这个主题:

总结

fiber 架构讲这么多就够了。因为本文只关注「并发渲染」里面的「并发」,所以 fiber 架构中有关于具体渲染行为的那部分就跳过了。现在,我们知道以下的信息:

  • react 通过 fiber 架构把一个完整的界面更新流程分离为两个阶段:「render 阶段」和「commit 阶段」。render 阶段主要负责纯计算型的任务 - 组件渲染;commit 阶段主要负责副作用型的任务 - 主要是 DOM 操作。

  • 在 render 阶段,react 继续把纯计算型任务进行切割,切割成以 fiber node 为粒度的工作单元,即所谓的「unit of work」或者「work unit」;换句话说,render 阶段是由若干个按照顺序执行的 performUnitOfWork(fiber)来组成的。于是乎,我们现在有下面的这张图:

  • 通过用 while(){} 来代替函数递归调用,我们轻易地通过跳出 while 循环来中断界面更新流程。然后,通过在中断之前把下一次应该执行 work 的 fiber node 保存在全局变量 workInProgress 来实现界面更新任务的恢复执行;

  • 最后,我们深入思考了「不可中断的最小工作单元是什么?」这一个主题。最终我们得到下面的认知:

    • 从 react 内部来看,不可中断的最小工作单元是 performUnitOfWork();

    • 从 user land 代码来看,不可中断的最小工作单元是「组件的渲染」;

    • 当然,我们不能忘记 beginWork()。当我们在研究 react 源码在并发渲染中的行为表现的时候,这个认知点还是能帮上忙的。

相关推荐
micro2010143 分钟前
Microsoft Edge 离线安装包制作或获取方法和下载地址分享
前端·edge
.生产的驴8 分钟前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
awonw11 分钟前
[前端][easyui]easyui select 默认值
前端·javascript·easyui
九圣残炎31 分钟前
【Vue】vue-admin-template项目搭建
前端·vue.js·arcgis
柏箱1 小时前
使用JavaScript写一个网页端的四则运算器
前端·javascript·css
TU^1 小时前
C语言习题~day16
c语言·前端·算法
学习使我快乐014 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19954 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈5 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts