Fiber vs. Stack: 深入解析 React 的渲染机制
React 是现代前端开发中广泛使用的库之一,其性能和用户体验的优化离不开 React 的渲染机制的不断演进。在 React 16 之前,React 使用了传统的 Stack 架构来进行渲染,而从 React 16 开始,React 引入了 Fiber 架构。
过去的问题
浏览器掉帧
浏览器掉帧的主要原因在于浏览器的渲染过程和 JavaScript 执行通常在同一个线程上进行,导致无法并行执行。当 JavaScript 执行时间过长时,会阻塞渲染流水线,从而导致掉帧,影响用户体验。这种问题在 Stack 架构下尤为明显,因为 Stack 架构的执行js的过程是同步的,无法中断,使得渲染主线程被阻塞。
发展历程
React 16 之前:Stack 架构
在 React 16 之前,React 使用了传统的 Stack 架构。此架构通过递归调用栈来处理虚拟 DOM 的构建和比较。这种方法的缺点在于无法中断,导致长时间的操作可能会阻塞主线程,从而影响用户体验。
React 16 之后:Fiber 架构
React 16 引入了 Fiber 架构,旨在解决 Stack 架构中的各种问题。Fiber 架构的核心在于其新的数据结构和渲染策略,使得 React 能够支持中断渲染、增量更新和优先级调度。
Stack
数据结构
Stack 架构使用普通的树数据结构。树的遍历方式使用调用栈实现深度优先遍历,其特点是递归遍历整个树,无法中断。
图示
ComponentA
├─ ComponentB
│ └─ ComponentC
└─ ComponentD
└─ ComponentE
Stack 构建 DOM 的方法
Stack 架构使用调用栈来构建虚拟 DOM 树,并且递归地进行比较。这种方法存在几个问题:
- 无法中断:一旦开始比较,就必须等到整个 DOM 树渲染完成,这意味着在处理大型树或复杂操作时,可能会导致长时间的阻塞和掉帧。
- 调用栈溢出:深度递归可能会导致调用栈溢出,特别是在处理复杂的树结构时。
构建UI的流程
- 创建虚拟 DOM:从根组件开始,创建虚拟 DOM 树的根节点,并推入调用栈。
- 递归创建:递归创建每个虚拟 DOM 节点,并将这些函数调用推入调用栈。
- 完成创建:当所有虚拟 DOM 节点都创建完成后,函数从调用栈中弹出,构建完整的虚拟 DOM 树。
- 比较虚拟 DOM:类似于构建虚拟 DOM 的过程,比较新旧节点并生成更新操作。
- 应用更新:将计算出的最小 DOM 更新集应用到真实 DOM 上。
Fiber
数据结构
Fiber 架构使用链表树数据结构。每个节点包含以下属性:
-
基本结构
- tag
- 结点类型
- key
- 结点的唯一标识符
- tag
-
数据和状态
- props
- 结点属性
- ...
- props
-
链表指针
- child
- 子Fiber结点
- sibling
- 下一个Fiber兄弟结点
- return
- 父亲Fiber结点
- child
-
副作用以及DOM操作
-
工作管理
-
上下文
图示
Root
|
+-- Fiber A
| |
| +-- Fiber B
| | |
| | +-- Fiber D
| |
| +-- Fiber C
|
+-- Fiber E
|
+-- Fiber F
Fiber 双缓冲机制
核心对象
- Current Fiber Tree(当前树):渲染在 UI 的树(在内存中的抽象数据结构)。
- Work-In-Progress Fiber Tree(工作进行中的树):正在构建的虚拟 DOM。
注意:两颗树都是在内存中的数据结构,并不是实际的DOM。
构建UI流程
- 初始化 UI:通过组件的 render 构造工作进行树,使用 Reconciler 计算最小 DOM 操作集,并在 commit 阶段将当前树指向工作进行树,应用 DOM 操作。
- 更新 UI:组件状态变化时,通过 Reconciler 构建工作进行树,计算 DOM 操作集合,并在 commit 阶段使用 Renderer 将操作应用到真实 DOM 上。
优点
- 增量渲染:将任务分解为小任务,支持时间切片。
- 优先级机制:不同任务可以有不同的优先级。
- 可中断渲染:避免长时间阻塞主线程。
- 回退机制:在合理时间内未完成任务时,可以回退到上一个稳定状态。
React框架特性发展史
调用栈遍历 vs. 链表遍历
调用栈(Call Stack)
- 数据结构:调用栈是一种后进先出(LIFO)的数据结构,用于管理函数调用的上下文。
- 递归遍历:在遍历 DOM 树时,使用递归调用将每个节点的处理上下文推入调用栈中。递归调用必须等到所有子函数完成后才能返回,导致无法中断遍历。
链表(Linked List)
- 数据结构:链表是一种线性数据结构,每个节点包含指向附近节点的指针。链表允许动态地插入和删除节点,并且可以在遍历过程中中断或跳过部分节点。
- 遍历和中断:链表允许动态调整和中断遍历,这使得Fiber每一个结点就是一个工作单元,而无需一次将Fiber树遍历到底。
Fiber 的链表遍历带来的优势
增量更新
Fiber 支持将更新任务拆分为多个小任务,这些任务可以在不同时间点处理,避免一次性处理整个树所带来的性能问题。
中断和恢复
由于每个Fiber结点都包含着附近节点的属性和当前上下文状态,使得每一个Fiber结点都是一个工作单元。那么,Fiber 可以在遍历结点的过程中保存当前状态与当前结点,并在稍后恢复。这使得 Fiber 能够处理长时间的更新任务,而不会阻塞主线程。
Stack的调用栈遍历带来的缺点
Stack更新UI时,需要遍历 DOM 树。而DOM树使用调用栈遍历,递归调用将每个节点的处理上下文推入调用栈中。递归调用必须等到所有子函数完成后才能返回,导致无法中断遍历。这样造成了页面掉帧的问题。
优先级调度
Stack
- 无优先级概念:任务按照先后顺序执行,无法进行优先级调度。
Fiber
- 优先级调度:Fiber 引入优先级概念,不同任务可以被赋予不同的优先级。高优先级任务可以中断低优先级任务,依赖调度器(Scheduler)来管理任务。
渲染 UI 的组件
Stack
- Reconciler:协调器,用于构建虚拟 DOM 和比较新旧虚拟 DOM。
- Renderer:渲染器,将计算出的 DOM 操作应用到真实 DOM 上。
Fiber
- Scheduler:调度器,负责任务的优先级调度和时间切片。
- Reconciler:协调器,构建工作进行树,通过 diff 算法计算 DOM 操作集合。
- Renderer:渲染器,执行计算出的 DOM 操作集合,并将当前树指向工作进行树。
应用举例
- UI
<div>{count}</div>
,用户操作使得count + 1
。 - 更新流程:
- Scheduler 将更新交给 Reconciler。
- Reconciler 更新虚拟 DOM,交给 Renderer。
- Renderer 修改真实 DOM。
总结
Fiber 使用链表树结构,引入优先级机制,实现了增量更新、中断、优先级调度等特点。使得浏览器渲染过程中,js运行与页面渲染并发执行,减少页面掉帧现象。