React 原理系列 - 并发模式,双缓冲,优先级

1,428 阅读7分钟

为什么需要并发模式

因为 javascript 和 UI 渲染是在同一个线程交替进行的,所以当 javascript 执行时间过长会导致页面卡顿。要解决这个问题需要合理的调度任务,每一帧只执行部分的 javascript 任务,让浏览器有时间执行其他的事情。所以就需要对 javascript 长任务进行任务分片,每帧只执行部分任务。这种在一个时间段可以由多个任务交替执行的过程叫做并发模式。

task-split

React 中怎么实现并发模式

时间切片

在 React16 之前的渲染过程是同步,如果一次渲染的时间过长,就会导致 UI 阻塞。所以 React16 引入了并发模式,对长任务进行时间切片。

如何进行时间分片?

在 web 应用中最终呈现到页面的是 DOM 树 ,DOM 树是由一个个 DOM 节点组成,我们可以把一个 DOM 节点的操作作为为一个分片任务(在 React 中是构建虚拟 DOM 树)。

但是构建 DOM 树的过程怎么中断,又怎么恢复执行?

如果 DOM 文档的结构不是一颗树而是一根链条,我们很直观的就能感受到可以在任意节点中断,并知道下一个要恢复执行的节点是什么。所以我们需要把这种数形结构改造成类链式结构。

因为 最后我们要构建的 DOM 树,我们并不能破坏它的树形结构,所以怎么能在不改变数形结构的前提下让它具有链式结构的特点呢?

我们可以为它增加指针,建立每个节点和下一个节点之间关系,把整个树串联起来。React 中 DOM 树的遍历是深度优先的顺序 (深度优先是递归的过程,在递进的阶段创建 DOM、标记更新,在归出的阶段插入 DOM、冒泡标记),依据这样的遍历顺序,我们发现下一个要遍历的节点有三种可能

  • 子节点:第一个儿子节点(大儿子)
  • 父节点:儿子节点遍历结束回退到父节点
  • 兄弟节点:当前节点和所有儿子节点都遍历完成,需要遍历下一个同级兄弟节点

所以我们需要给每个节点增加的 child,return,subling 指针指向,建立节点之间的联系,来满足在任意节点中断,可以知道下一个要遍历的节点。

下图是新增 child,return,subling 指针后,深度优先的遍历顺序。

fiber

双缓冲

现在我们对树数据结构的改造满足了的时间分片的需求,但是 React16 之前的 DOM 的操作是同步的,这就意味着我们在部分分片做的 DOM 操作会先在页面显示出来,不符合我们的预期。所以我们需要将同步操作 DOM 的过程转变为离屏操作,在内存中构建好 DOM 树,并在渲染阶段用构建好的 DOM 替换旧的 DOM。这种在内存中构建并直接替换的技术叫做 - 双缓冲。

off-screen

想要在内存中构建 DOM 树,并不可以影响当前的 UI,所以我们可以通过虚拟 DOM 保存需要更新的真实 DOM 的状态,在构建阶段对虚拟 DOM 进行标记(插入,更新,删除,移动),在渲染时统一提交更新。

目前 JSX 生成的虚拟 DOM 只是对 JSX 语法 的抽象描述,为了更好的扩展性(双缓冲,优先级),并且满足时间分片的链式结构,React 引入了 Fiber 架构,在虚拟 DOM 的基础上扩展了 Fiber 层。所以长任务的时间切片任务就被分割成执行一个个 Fiber 任务单元,并且在 Fiber 任务单元中离屏构建 DOM。

React 的每次的渲染过程就分为离屏构建阶段和渲染阶段,为了在渲染时更快的显示 UI,我们需要把更多的工作转移到内存中进行,在整个离屏 DOM 构建完成后才替换旧的 UI。

  • 离屏构建阶段:构建 Fiber 树
  1. beginWork:通过虚拟 DOM 和之前 Fiber 进行 DIFF 创建新的 Fiber,在 DIFF 的过程中对需要移动,更新,删除的元素打上标记,如果有新增元素需要创建 DOM,对创建 DOM 的父 Fiber 节点打上插入标记
  2. completeWork: 向上冒泡标记,方便在根 Fiber 节点知道这颗 Fiber 树需要的更新有哪些。新增子 DOM 节点插入到父 DOM 中(父 DOM 并没有插入,所以新增的 DOM 树 还是离屏的,在渲染阶段根据父 DOM 的标记进行插入渲染到页面上)。
  • 渲染阶段:根据 Fiber 树的更新标记(插入,移动,删除,更新)操作真实 DOM,最后渲染新的 UI。

下面通过一个例子来模拟 React 离屏构建的过程

<div key="A">
  <div key="B">更新前</div>
  <div key="C">删除</div>
</div>
<div key="A">
  <div key="B">更新后</div>
  <div key="D">
    <div key="F">新增</div>
  </div>
</div>

off-screen-example

优先级

在 React 中每种更新都会产生不同的优先级,不同优先级对于用户的权重是不一样的,对用户来说更希望高优先级的任务可以优先执行。

下面是 React 中的会产生的优先级,从上到下优先级依次降低

  • ImmediateSchedulerPriority:离散事件(click, keydown, input 等)
  • UserBlockingSchedulerPriority:, 连续事件(scroll, mousemove, drag 等)
  • DefaultEventPriority: setTimeout
  • IdleEventPriority: useTransition

如何实现优先级机制?

要实现优先级机制,我们需要一个任务调度中心,将 React 中产生的任务都在任务调度中心进行管理,并会进行优先级排序,每次可以取到最高优先级的任务。React 内部是通过 Scheduler 库的小顶堆数据结构维护任务的优先级(时间复杂度:peek O(1) pop 和 push O(logN) )。

在 React 中如何运行不同优先级任务?

  1. 在 React 中会产生不同优先级的任务
  2. 不同优先级任务会以小顶堆的形式维护
  3. 每次取最高优先级的任务分片执行,一个任务执行完成再取下一个最高优先级任务。

scheduler

如何高优先级打断低优先级任务?

如果在低优先级任务执行的过程中产生了更高优先级的任务,如果需要等待低优先级任务执行完成才能执行高优先级任务,这无疑对用户来说是一种不好的体验,所以用户更期望先让高优先级的任务打断低优先级的任务优先执行。所以我们需要两颗 Fiber 树,一颗描述当前 UI 的 Fiber 树,一颗正在构建的 Fiber 树, 如果在构建的过程中被高优先级任务打断的话,会销毁这颗正在构建的 Fiber 树,基于当前的 Fiber 数重新构建更高优先级任务的 Fiber 树。

priority

总结

  • 时间分片:将可能阻塞 UI 渲染的长任务,切成一片片,每帧执行部分分片任务,不阻塞 UI。
  • 双缓冲:将分片构建的任务转移到后台进行,在整个新的 UI 构建完成之后,替换旧的 UI
  • 优先级:用小顶堆数据结构维护不同优先级任务,每次可以取堆顶最高优先级任务,并且可以让高优先级任务打断低优先级任务

React 通过时间分片,双缓冲,优先级这些技术手段,让整个 React 应用运行的更加的稳定和流畅,用户体验更好,未来也有更大的发展前景。