React 架构演进:从 Stack 到 Fiber,解决卡顿难题

194 阅读9分钟

React 作为构建快速响应 Web 应用的首选框架,其架构的演进始终围绕着提升用户体验和性能展开。本文将深入探讨 React 架构的变迁,从早期的 Stack 架构到如今的 Fiber 架构,剖析其背后的设计思想和解决的问题。

面试题:React 架构知多少?

在面试中,经常会被问到 React 的架构问题,例如:

  • 是否了解过 React 的架构?
  • 新的 Fiber 架构相较于之前的 Stack 架构有什么优势?

一个标准且浅显的回答是:

Stack 架构在进行虚拟 DOM 树比较的时候,采用的是递归,计算会消耗大量的时间,新的 Fiber 架构采用的是链表,可以实现时间切片,防止 JS 的计算占用过多的时间从而导致浏览器出现丢帧的现象。

但这仅仅是冰山一角,要深入理解 React 架构的演进,我们需要了解旧架构的问题以及新架构的解决思路。

旧架构(Stack):递归更新的困境

React v15 及之前的架构被称为 Stack 架构。其核心思想是将 UI 描述为虚拟 DOM (VDOM),通过比较 VDOM 树的差异来更新真实 DOM,从而提高性能。

1. CPU 瓶颈:递归计算的性能瓶颈

在 Stack 架构中,当组件需要更新时,会触发 mountComponent (对于新组件) 或 updateComponent (对于已存在的组件) 方法。这两个方法都会递归地更新子组件。这种递归更新的方式存在一个严重的问题:一旦更新流程开始,就无法中断。

我们在浏览网页的时候,这张网页实际上是由浏览器绘制出来的,就像一个画家画画一样

draw

平时我们所浏览的网页,里面往往会有一些动起来的东西,比如轮播图、百叶窗之类的,本质其实就是浏览器不停的在进行绘制。

目前,大多数设备的刷新频率为 60 FPS,意味着 1秒钟需要绘制 60 次,1000ms / 60 = 16.66ms,也就是说浏览器每隔 16.66ms 就需要绘制一帧。

浏览器在绘制一帧画面的时候,实际上还有很多的事情要做:

上图中的任务被称之为“渲染流水线”,每次执行流水线的时候,大致是需要如上的一些步骤,但是并不是说每一次所有的任务都需要全部执行:

  • 当通过 JS 或者 CSS 修改 DOM 元素的几何属性(比如长度、宽度)时,会触发完整的渲染流水线,这种情况称之为重排(回流)
  • 当修改的属性不涉及几何属性(比如字体、颜色)时,会省略掉流水线中的 Layout、Layer 过程,这种情况称之为重绘
  • 当修改“不涉及重排、重绘的属性(比如 transform 属性)”时,会省略流水线中 Layout、Layer、Print 过程,仅执行合成线程的绘制工作,这种情况称之为合成

按照性能高低进行排序的话:合成 > 重绘 > 重排

想象一下,如果你的应用拥有庞大的组件树,一次更新可能会触发大量的计算,导致 JavaScript 代码执行时间过长。由于 JavaScript 代码的执行和浏览器的渲染流水线在同一个线程上,长时间的 JavaScript 执行会阻塞渲染,导致页面掉帧,出现卡顿现象。

假设有如下的 DOM 层次结构:

image-20230223152638127

那么转换成虚拟 DOM 对象结构大致如下:

{
  type : "div",
  props : {
    id : "test",
    children : [
      {
        type : "h1",
        props : {
          children : "This is a title"
        }
      }
      {
        type : "p",
        props : {
          children : "This is a paragraph"
        }
      },{
        type : "ul",
        props : {
          children : [{
            type : "li",
            props : {
              children : "apple"
            }
          },{
            type : "li",
            props : {
              children : "banana"
            }
          },{
            type : "li",
            props : {
              children : "pear"
            }
          }]
        }
      }
    ]
  }
}

在 React v16 版本之前,进行两颗虚拟 DOM 树的对比的时候,需要涉及到遍历上面的结构,这个时候只能使用递归,而且这种递归是不能够打断的,一条路走到黑,从而造成了 JS 执行时间过长。

image-20221227150133112

这样的架构模式,官方就称之为 Stack 架构模式,因为采用的是递归,会不停的开启新的函数栈。

用搭积木来理解:

想象你正在用积木搭建一个复杂的城堡。Stack 架构就像你必须一口气把城堡搭完,不能停下来休息,也不能让别的小朋友玩一下。如果城堡太大,你可能会累得喘不过气,甚至搭到一半就放弃了。

2. I/O 瓶颈:缺乏优先级的更新调度

除了 CPU 瓶颈,Stack 架构还存在 I/O 瓶颈。在前端开发中,最主要的 I/O 瓶颈是网络延迟。用户对卡顿的感知程度因操作而异。例如,输入框的轻微延迟会引起用户明显的卡顿感,而列表加载的几秒延迟则相对可以接受。

Stack 架构中,所有的更新任务都以相同的优先级进行处理,无法区分用户交互的紧急程度。这意味着即使是高优先级的更新(例如输入框的输入),也可能被低优先级的更新阻塞,导致用户体验下降。

总结:

Stack 架构的问题在于:

  • 递归更新导致 CPU 瓶颈,无法实现时间切片。
  • 缺乏优先级调度导致 I/O 瓶颈,无法区分更新的紧急程度。

新架构(Fiber):可中断的更新与优先级调度

为了解决 Stack 架构的问题,React v16 引入了全新的 Fiber 架构。Fiber 架构的核心思想是:

  • 将更新任务分解成更小的单元,实现时间切片。
  • 引入调度器 (Scheduler),根据优先级调度任务。

1. Fiber:链表结构的 VDOM

Fiber 架构引入了 Fiber 的概念,它是一种通过链表来描述 UI 的方式,本质上也可以看作是一种虚拟 DOM 的实现。每个 Fiber 对象代表一个 DOM 节点,并通过 childsiblingreturn 属性与其他 Fiber 对象连接,形成一个链表结构。

Fiber 本质上也是一个对象,但是和之前 React 元素不同的地方在于对象之间使用链表的结构串联起来,child 指向子元素,sibling 指向兄弟元素,return 指向父元素。

如下图:

image-20230224112508425

使用链表这种结构,有一个最大的好处就是在进行整颗树的对比(reconcile)计算时,这个过程是可以被打断。

在发现一帧时间已经不够,不能够再继续执行 JS,需要渲染下一帧的时候,这个时候就会打断 JS 的执行,优先渲染下一帧。渲染完成后再接着回来完成上一次没有执行完的 JS 计算。

image-20221227150225918

官方还提供了一个 Stack 架构和 Fiber 架构的对比示例:claudiopro.github.io/react-fiber…

下面是 React 源码中创建 Fiber 对象的相关代码:

const createFiber = function (tag, pendingProps, key, mode) {
  // 创建 fiber 节点的实例对象
  return new FiberNode(tag, pendingProps, key, mode);
};
​
function FiberNode(tag, pendingProps, key, mode) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null; // 映射真实 DOM
​
  // Fiber
  // 上下、前后 fiber 通过链表的形式进行关联
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;
​
  this.ref = null;
  this.refCleanup = null;
  // 和 hook 相关
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;
​
  this.mode = mode;
​
  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;
​
  this.lanes = NoLanes;
  this.childLanes = NoLanes;
​
  this.alternate = null;
  // ...
}

用搭积木来理解:

Fiber 架构就像你把搭积木的任务分解成小块,每次只搭一点点,然后停下来休息一下,看看别的小朋友是不是也想玩。这样你不会太累,而且别的小朋友也能参与进来。

Fiber 架构的优势:

  • 可中断的更新: Fiber 架构允许在更新过程中暂停和恢复,从而实现时间切片。当浏览器需要处理更高优先级的任务(例如用户交互)时,React 可以暂停当前的更新,让出控制权给浏览器。
  • 增量更新: Fiber 架构可以增量地构建和更新 VDOM 树,避免一次性计算整个树的差异。

2. Scheduler:任务调度的指挥官

React v16 引入了 Scheduler (调度器),用于调度任务的优先级。Scheduler 可以根据任务的类型和紧急程度,将任务划分为不同的优先级,并优先处理高优先级的任务。

Scheduler 的作用:

  • 优先级调度: 确保高优先级的更新(例如用户输入)能够及时响应。
  • 时间切片: 将更新任务分解成小块,避免阻塞主线程。

3. 工作循环 (Work Loop):时间切片的实现

Fiber 架构通过工作循环 (Work Loop) 来实现时间切片。工作循环会不断执行 performUnitOfWork,直到以下两种情况之一发生:

  • workInProgress === null:表示没有更多任务需要执行。
  • shouldYield() === true:表示当前时间切片已经用完,需要让出控制权。

shouldYield() 函数会判断当前时间切片是否有足够的剩余时间。如果没有足够的剩余时间,工作循环会暂停执行,将主线程还给渲染流水线,进行下一帧的渲染操作。渲染工作完成后,再等待下一个宏任务继续执行。

代码示例:

function workLoopConcurrent{
  // 如果还有任务,并且时间切片还有剩余的时间
  while(workInProgress !== null && !shouldYield()){
    performUnitOfWork(workInProgress);
  }
}
​
function shouldYield(){
  // 当前时间是否大于过期时间
  // 其中 deadline = getCurrentTime() + yieldInterval
  // yieldInterval 为调度器预设的时间间隔,默认为 5ms
  return getCurrentTime() >= deadline;
}

总结:

Fiber 架构通过以下方式解决了 Stack 架构的问题:

  • Fiber 链表结构: 实现可中断的更新,允许时间切片。
  • Scheduler 调度器: 调度任务的优先级,确保高优先级任务及时响应。
  • 工作循环: 实现时间切片,避免阻塞主线程。

总结:从 Stack 到 Fiber,React 的进化之路

React 的架构演进是一个不断优化用户体验和性能的过程。从早期的 Stack 架构到如今的 Fiber 架构,React 始终致力于解决卡顿问题,提升应用的响应性。

Fiber 架构的引入是 React 发展史上的一个重要里程碑。它不仅解决了 Stack 架构的 CPU 和 I/O 瓶颈,还为 React 的未来发展奠定了坚实的基础。通过时间切片和优先级调度,Fiber 架构使 React 应用能够更好地应对复杂的场景,为用户提供更加流畅和响应迅速的体验。

希望这篇文章能够帮助你深入理解 React 的架构演进,并在面试中游刃有余地回答相关问题。