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 (对于已存在的组件) 方法。这两个方法都会递归地更新子组件。这种递归更新的方式存在一个严重的问题:一旦更新流程开始,就无法中断。
我们在浏览网页的时候,这张网页实际上是由浏览器绘制出来的,就像一个画家画画一样
平时我们所浏览的网页,里面往往会有一些动起来的东西,比如轮播图、百叶窗之类的,本质其实就是浏览器不停的在进行绘制。
目前,大多数设备的刷新频率为 60 FPS,意味着 1秒钟需要绘制 60 次,1000ms / 60 = 16.66ms,也就是说浏览器每隔 16.66ms 就需要绘制一帧。
浏览器在绘制一帧画面的时候,实际上还有很多的事情要做:
上图中的任务被称之为“渲染流水线”,每次执行流水线的时候,大致是需要如上的一些步骤,但是并不是说每一次所有的任务都需要全部执行:
- 当通过 JS 或者 CSS 修改 DOM 元素的几何属性(比如长度、宽度)时,会触发完整的渲染流水线,这种情况称之为重排(回流)
- 当修改的属性不涉及几何属性(比如字体、颜色)时,会省略掉流水线中的 Layout、Layer 过程,这种情况称之为重绘
- 当修改“不涉及重排、重绘的属性(比如 transform 属性)”时,会省略流水线中 Layout、Layer、Print 过程,仅执行合成线程的绘制工作,这种情况称之为合成
按照性能高低进行排序的话:合成 > 重绘 > 重排
想象一下,如果你的应用拥有庞大的组件树,一次更新可能会触发大量的计算,导致 JavaScript 代码执行时间过长。由于 JavaScript 代码的执行和浏览器的渲染流水线在同一个线程上,长时间的 JavaScript 执行会阻塞渲染,导致页面掉帧,出现卡顿现象。
假设有如下的 DOM 层次结构:
那么转换成虚拟 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 执行时间过长。
这样的架构模式,官方就称之为 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 节点,并通过 child、sibling 和 return 属性与其他 Fiber 对象连接,形成一个链表结构。
Fiber 本质上也是一个对象,但是和之前 React 元素不同的地方在于对象之间使用链表的结构串联起来,child 指向子元素,sibling 指向兄弟元素,return 指向父元素。
如下图:
使用链表这种结构,有一个最大的好处就是在进行整颗树的对比(reconcile)计算时,这个过程是可以被打断。
在发现一帧时间已经不够,不能够再继续执行 JS,需要渲染下一帧的时候,这个时候就会打断 JS 的执行,优先渲染下一帧。渲染完成后再接着回来完成上一次没有执行完的 JS 计算。
官方还提供了一个 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 的架构演进,并在面试中游刃有余地回答相关问题。