漫谈 react 系列(三): 三层 loop 弄懂 Concurrent 模式

1,653 阅读28分钟

本文使用「署名 4.0 国际 (CC BY 4.0)」 许可协议,欢迎转载、或重新修改使用,但需要注明来源。

作者: 百应前端团队 @0o华仔o0 首发于 juejin.cn/post/702299…

前言

目前,react18 最新的版本已经到了 18.0.0-alpha-f6abf4b40-20211020,相信再过不久,我们就可以正式使用 react18 了。相比 1718 版本最大的变化就是原来作为实验性的 Concurrent 特性可以正式使用。基于此,我们今天就借着本文和大家一起来聊一聊 Concurrent 模式,了解一下 Concurrent 模式的用法以及其内部原理,提前熟悉 Concurrent,以便将来能更好的迎接 react18 的到来。

整篇文章的目录结构为:

初次体验 Concurrnt Mode

一次 react 更新,最核心的过程就是 fiber tree协调。通过协调,我们可以找到 fiber tree 中发生变化的 fiber node,最小程度的对页面的 dom tree 结构进行调整。

如果对 fiber tree 的协调过程还不是很了解,可以先去阅读一下之前的文章 漫谈 react 系列(一):初探 react 的工作过程漫谈 react 系列(二):用二叉树的中序遍历搞懂 fiber tree 的协调过程

在进行协调时,react 提供了两种模式:Legacy mode - 同步阻塞模式Concurrent mode - 并行模式

这两种模式,区别在于 fiber tree协调过程是否可中断Legacy mode协调过程不可中断Concurrent mode协调过程可中断

Concurrent & Legacy

两种不同的模式,我们先通过两个简单的 demo 来体验一下。

legency 模式:legency-demo

Oct-15-2021 08-55-29.gif

Concurrent 模式: concurrent-demo

Oct-15-2021 11-11-32.gif

示例中,react、react-dom 使用的是最新版本: 18.0.0-alpha-f6abf4b40-20211020。

上面的示例,只是用来说明两种模式的启用方式,并不能形象直观的展示出两种模式的区别。在这里,我们需要结合浏览器提供的 performance 功能,再来运行一下两种模式。通过 performance 分析器,我们可以非常方便的看出两种模式的不同。

  • Legacy 模式

    legency-demoperformance 分析图如下:

    legency.png

    Legacy 模式下,一次完整的 react 更新,经历的主要过程如下:

    legacy.png

    在整个过程中,如果 fiber tree 的结构很复杂,那么协调 fiber tree 可能会占用大量的时间,导致主线程会一直被 js 引擎占用,渲染引擎无法在规定时间(浏览器刷新频率 - 16.7ms)内完成工作,使得页面出现卡顿(掉帧),影响用户体验。

  • Concurrent 模式

    concurrent-demoperformance 分析图如下:

    concurrent.png

    Concurrent 模式下,一次完整的 react 更新,经历的过程如下:

    concurrent.png

    对比 Legacy 模式,Concurrent 模式下的协调过程,js 引擎不会一直占用主线程,到了规定时间就会自动让出主线程

    具体来说,整个协调过程是分段进行的,每个时间段为 5ms。如果在规定的 5ms 内,协调过程没有结束,js 引擎会自动让出主线程。此时由于协调还没有结束,react 会请求浏览器分配的一个 5ms 的时间片继续进行协调过程。整个过程会一直重复,直到协调结束为止。

    Concurrent 模式下,主线程会定时被 js 引擎释放,相比 legacy 模式,在一定程度上能提升用户体验。

另外,细心的同学可能会发现,在上面的 legency-democoncurrent-demo 中,不管是 Legacy 模式,还是 Concurrent 模式,我们都是使用 createRoot 这个 API 来启动 react 应用,并没有使用我们非常熟悉的 render 方法。

这两个 demo 唯一的区别是 legency-democlick 事件回调方法中直接调用了 setState,而 concurrent-demo 是在通过 useTransition 来调用 setState。直接调用 setStatereact 采用 Legacy 模式来协调 fiber tree;通过 useTransition 调用 setStatereact 采用 Concurrent 模式来协调 fiber tree

之所以会这样,是因为即将到来的 React18 将不再支持 render(不推荐使用),我们需要通过 createRoot 来启动一个 react 应用。应用启动以后,react 会根据触发更新上下文来决定采用何种模式来协调 fiber tree。如果是在 eventsetTimeoutnetwork requestcallback 中触发更新,react 会采用 Legacy 模式,而如果更新与 SuspenseuseTransitionOffScreen 相关,那么 react 会采用 Concurrent 模式。

Concurrent mode 的意义

Concurrent 模式最大的意义,就是借着可中断的协调,来提升应用的用户体验。使用 Concurrent 模式以后,我们的 react 应用可以做到以下几点:

  • 协调不会长时间阻塞浏览器渲染

  • 高优先级更新可以中断低优先级更新优先渲染

  • 通过 SuspenseSuspenseListuseTransitionuseDeferredValue 这些新的 API,给用户提供更好的交互体验(这一部分会在后续文章中梳理);

示例:协调不阻塞浏览器渲染

阻塞

Oct-25-2021 20-27-40.gif

非阻塞

Oct-25-2021 20-25-02.gif

示例:高优先级更新中断低优先级更新

中断

Oct-25-2021 20-56-36.gif

Concurrent 模式下 fiber tree 的协调是如何调度的

在上面 初次体验 Concurrent Mode 一节中,我们了解到 Concurrent 模式下 fiber tree 的协调过程是可中断的,并且高优先级的更新会中断低优先级的更新,那么问题来了:

  • 协调过程是如何被中断的?
  • 中断的协调过程是如何被恢复的?
  • 协调需要的时间片是如何决定的?
  • 时间片用完,该如何请求新的时间片?
  • 如何判断更新的优先级?
  • 高优先级的更新如何中断低优先级的更新?
  • 被中断的低优先级更新如何处理?

带着这些问题,我们来开始本章内容的学习。相信看完本章以后,大家对上面提到的这些问题基本上就有了答案了。

浏览器 eventLoop

在开始本节内容之前,我们需要先聊一下浏览器的 eventLoop

关于 eventLoop,网上已经有太多的资料了,本文就不再做详细说明了。在这里,贴一个自认为讲的比较好的链接:eventLoop, 感兴趣的同学可以自行去学习。

在该视频中,有最关键的一个图例:

image.png

这是关于 eventLoop 最形象的一个模型。在该模型中,中间部分是 eventLoop,左侧部分使用 js 引擎处理消息队列中的任务,右侧是渲染引擎绘制页面。js 引擎渲染引擎工作时互斥,同一时间只能由某一个引擎占据主线程

在讲解后面的 react 调度时,还需要用到这个模型,因此我们基于这个模型做了重新绘制,如下:

scheduler-eventLoop.png

我们知道通过 setTimeout,可以向消息队列中添加一个宏任务。当该宏任务被处理时,对应的 callback 会被 js 引擎处理。

基于次,我们可以通过 eventLoop 来实现时间分片以及重新请求时间片

一段 js 程序,如果在规定时间内没有结束,那我们可以主动结束它,然后通过 setTimeout 请求一个新的时间片,在下一个时间片内继续处理上一次没有结束的任务。

整个过程的伪代码如下:

let taskQueue = [...];   // js 程序要处理的任务列表
let shouldTimeEnd = 5ms;  // 一个时间片定义为 5ms

function processTaskQueue() {
    let beginTime = performance.now();  // 记录开始时间
    while(true) { // 循环处理 taskQueue 中的任务
        let currentTime = performance.now(); // 记录下一个任务开始时的时间
        if (currentTime - beginTime >= shouldTimeEnd) break; // 时间片到期,结束任务处理
        processTask();  // 没有到期,继续处理任务
    }
    if (taskQueue.length) {  // 时间到期,通过 setTimeout 请求下一个时间片继续处理任务
        setTimeout(processTaskQueue, 0); // 在下一个时间片内,继续处理任务
    }
}

processTaskQueue();

这就是一个非常简单的时间分片以及重新请求时间片调度模型

在上面的模型中,我们使用了 setTimemout。使用 setTimemout 会有一个问题,那就是尽管我们设置了延时时间为 0ms,但实际上仍然会有大概 4ms 左右的延迟,导致下一个时间片不能尽快拿到。基于次,我们可以使用 MessageChannel 来替换 setTimeout。使用 MessageChannel 时,只会有大概 1ms 左右的延迟,能帮助我们尽快的拿到下一个时间片

使用 MessageChannel 的伪代码如下:

let taskQueue = [...];   // 任务列表
let shouldTimeEnd = 5ms;   // 一个时间片定义为 5ms
let channel = new MessageChannel();  // 创建一个 MessageChannel 实例

function processTaskQueue() {
    let beginTime = performance.now();  // 记录开始时间
    while(true) { // 循环处理 taskQueue 中的任务
        let currentTime = performance.now();  // 记录下一个任务开始时的时间
        if (currentTime - beginTime >= shouldTimeEnd) break;  // 时间片已经到期,结束任务处理
        processTask();  // 时间片没有到期,继续处理任务
    }
    if (taskQueue.length) { // 时间片到期,通过调用 postMessage,请求下一个时间片
        channel.port2.postMessage(null); 
    }
}

channel.port1.onmessage = processTaskQueue;  // 在下一个时间片内继续处理任务
processTaskQueue();

react时间分片以及重新请求时间片就是基于 MessageChannel 实现的,基本过程和我们上面的伪代码一样,感兴趣的同学可以自行去查看源码了解。

(✌🏻,时间分片和重新请求时间片的问题已解决!)

浏览器本身的任务调度是没有优先级概念的,遵循的是 FIFO 策略,即先进入消息队列里面的任务先处理。而在实际上生活中,用户的交互操作是有优先级的,比如一次点击操作,用户会期望能快速看到结果,优先级较高;切换页面,可以延迟一些,优先级不高。为了可以根据优先级进行任务调度,react 自身在浏览器 eventLoop 的基础上,实现了一套带优先级的任务调度 - workLoop

react 任务调度 - wookLoop

前面我们在讲时间分片以及重新请求时间片用到的伪代码,实际上就是一个简化版的 react wookLoop

let taskQueue = [];   // 任务列表
let shouldTimeEnd = 5ms;   // 一个时间片定义为 5ms
let channel = new MessageChannel();  // 创建一个 MessageChannel 实例

function wookLoop() {
    let beginTime = performance.now();  // 记录开始时间
    while(true) { // 循环处理 taskQueue 中的任务
        let currentTime = performance.now();  // 记录下一个任务开始时的时间
        if (currentTime - beginTime >= shouldTimeEnd) break;  // 时间片已经到期,结束任务处理
        processTask();  // 时间片没有到期,继续处理任务
    }
    if (taskQueue.length) { // 时间片到期,通过调用 postMessage,请求下一个时间片
        channel.port2.postMessage(null); 
    }
}

channel.port1.onmessage = wookLoop;  // 在下一个时间片内继续处理任务
workLoop();

和浏览器的 eventLoop 一样, react 也会维护一个任务队列 - taskQueue,然后通过 workLoop 遍历 taskQueue,依次处理 taskQueue 中的任务。

taskQueue 中收集任务是有先后处理顺序的,workLoop 每次处理 taskQueue 中的任务时,都会挑选优先级最高任务进行处理。

整个过程,和 eventLoop 类似,图例如下:

image.png

那这整个过程是如何实现的呢?

要回答这个问题,我们需要先搞清楚三个问题:

  • react 任务是如何创建的?
  • react 任务的优先级是怎么确定的?
  • 如何获取到 taskQueue 中优先级最高的任务?

创建一个 react 任务

当我们在浏览器中做点击操作时,对应的事件 handler 并不会立即触发。浏览器会为点击事件生成一个调度任务,并添加到消息队列中,任务的 callback 是对应点击事件的 handler。当 eventLoop 开始处理该调度任务时,点击事件的 handler 才会触发。

同样的,react 的更新操作也是一样的。

每触发一次 react 更新,意味着一次 fiber tree协调,但协调并不会在更新触发时立刻同步进行。相反,react 会为这一次更新,生成一个 task,并添加到 taskQueue 中,fiber tree协调方法会作为新建 taskcallback。当 wookLoop 开始处理该 task 时,才会触发 taskcallback,开始 fiber tree协调

react 任务的优先级

react 更新触发时会存在不同的上下文

我们可能会在 clickinputmousemove 等事件的 handler 中通过 setState 触发更新,也可能会在网络请求callback 中通过 setState 触发更新,还可能在使用 useTransition 时触发更新,甚至在 Suspense 阻塞恢复时触发更新等等。

不同的更新上下文,代表着不同的优先级, 决定了对应 task优先级

react 在内部定义了 5 种类型的优先级:

  • ImmediatePriority, 直接优先级,对应用户的 clickinputfocus 等操作;
  • UserBlockingPriority用户阻塞优先级,对应用户的 mousemovescroll 等操作;
  • NormalPriority普通优先级,对应网络请求useTransition 等操作;
  • LowPriority低优先级(未找到应用场景);
  • IdlePriority空闲优先级,如 OffScreen;

5优先级的顺序为: ImmediatePriority > UserBlockingPriority > NormalPriority > LowPriority > IdlePriority

在确定了任务的优先级以后,react 会根据优先级为任务计算一个过期时间 - expirationTime,即 expirationTime = currentTime(即 performance.now()) + timeout,然后根据 expirationTime 时间来决定任务处理的先后顺序。

优先级不同,timeout 也不相同:

  • ImmediatePrioritytimeout-1,表示任务要尽快处理;
  • UserBlockingPriority, timeout250 ms
  • NormalPriority, timeout5000 ms
  • LowPriority, timeout10000 ms
  • IdlePriority, timeout1073741823 ms

在这里,大家可能会有一个疑问,就是既然 task优先级已经确定了,为什么不直接使用优先级来决定 task 的处理顺序呢?高优先级task 先处理,低优先级task 后处理,相同优先级task 按创建任务的先后顺序来处理。

只用优先级来决定 task 处理的先后顺序,会存在一个问题。在前面我们已经了解到 workLoop 处理 taskQueuetask 时,如果分配的时间片已到期,那么就需要让出主线程,在下一个时间片内继续处理 taskQueuetask。而时间片的分配,是由浏览器 eventLoop 决定的,不受开发人员控制,下一个时间片的分配可能会间隔较久,导致 task 的处理出现延迟,会影响用户体验。 而有了 expirationTime 之后,wookLoop时间片到期时,还会判断下一个要处理的 task 是否过期。如果 task 已过期,就不能让出主线程,需要立即处理。

每个 task 都有 expirationTime,那么也就顺理成章的可以使用 expirationTime 来判断任务处理的先后顺序了(目前也只理解到这一层,😂)。

(✌🏻,如果判断更新优先级的问题解决!)

获取最先处理的 task

react 采用了最小堆来存储 task,即 taskQueue 是一个最小堆

最小堆,顾名思义,就是堆顶元素最小的,反映在任务调度里面,那就是放在堆顶task 是需要最先处理的。

使用最小堆时,要三个操作:pushpoppeek

push,入堆操作,即将 task 添加到 taskQueue 中。添加一个新创建的 task 时,会将 task 添加到最小堆堆底,然后对最小堆自底向上的调整。调整时,会比较堆节点(task)expirationTime,将 expirationTime 较小的 task调整。

peek,获取堆顶元素,即获取需要最先处理的 task,执行 taskcallback,开始 fiber tree 的协调。

pop堆顶元素出堆,即 task 处理完毕,从 taskQueue 中移除。移除堆顶元素以后,会将堆底元素放到堆顶位置,然后对最小堆自顶向下的调整。调整时,也是比较堆节点(task)expirationTime,将 expirationTime 较大的 task调整。

react 最小堆是基于数组实现的,整个实现过程网上已经有大量的资料,这里就不做详细说明了,感兴趣的同学可以自行查阅资料来了解

最后,我们将 workLoopeventLoop 结合起来,帮大家更形象得理解 react 任务调度:

scheduler-event&work.png

legacy 和 concurrent 下的协调调度

workLoop 开始处理 taskQueue 中的 task 时,会触发 taskcallback,开始进行 fiber tree 的协调。

和浏览器 eventLoopreact 任务调度 workLoop 一样, fiber tree协调过程,也是一个 Loop

workLoopSync & workLoopConcurrent

协调模式不同,Loop 的工作过程也不相同。Legacy 模式下,是 workLoopSyncConcurrent 模式下,是 workLoopConcurrent

workLoopSync / workLoopConcurrent 的整个工作过程,可以用下面图例来说明:

scheduler-xietiao.png

协调过程中,需要对 fiber tree深度优先遍历,因此我们可以使用一个 stack 来模拟 fiber node 的处理过程(源码中并没有使用 stack)。在前面的 漫谈 react 系列(二):用二叉树的中序遍历搞懂 fiber tree 的协调过程 文章中,我们通过二叉树的中序遍历梳理了 fiber tree 的协调过程,结合该文章,可以更容易理解上面的 stack 操作。

workLoopSync 对应 Legacy 模式。如果是在 eventsetTimeoutnetwork requestcallback 中触发更新,那么协调时会启动 workLoopSyncworkLoopSync 开始工作以后,要等到 stack 中收集的所有 fiber node 都处理完毕以后,才会结束工作,也就是 fiber tree 的协调过程不可中断

workLoopConcurrent 对应 Concurrent 模式。如果更新与 SuspenseuseTransitionOffScreen 相关,那么协调时会启动 workLoopConcurrentworkLoopConcurrent 开始工作以后,每次协调 fiber node 时,都会判断当前时间片是否到期。如果时间片到期,会停止当前 workLoopConcurrentworkLoop,让出主线程,然后请求下一个时间片继续协调。

结合前面的 eventLoopworkLoop,整个过程如下:

scheduler-event&work&concurrent.png

通过上面 eventLoopworkLoopworkLoopSync / workLoopConcurrent 这三层循环的协作,就完成了 react 的可中断渲染 - Concurrent,😄。

(✌🏻,协调如何中断问题解决!)

恢复中断的协调

Concurrent 模式下,协调过程被中断时,会使用全局指针 workInProgressRootworkInProgress 记录正在处理的 fiber treeroot nodefiber node

当下一个时间片到来之后,继续处理被中断的任务,根据 workInProgressRootworkInProgress 找到上次协调结束的位置,继续未完成的协调

(✌🏻,如何恢复中断的协调问题解决!)

高优先级的更新中断低优先级的更新

Concurrent 模式下,如果在低优先级更新协调过程中,有高优先级更新进来,那么高优先级更新会中断低优先级更新协调过程。

整个过程如下:

concurrent - 1.png

每次拿到新的时间片以后,workLoopConcurrent 都会判断本次协调对应的优先级和上一次时间片到期中断的协调优先级是否一样。如果一样,说明没有更高优先级的更新产生,可以继续上次未完成的协调;如果不一样,说明有更高优先级的更新进来,此时要清空之前已开始的协调过程,从根节点开始重新协调。等高优先级更新处理完成以后,再次从根节点开始处理低优先级更新

(✌🏻,高优先级更新如何中断低优先级更新、被中断的低优先级更新如何处理问题解决!)

上面的一段话,理解起来估计非常费劲,这样因为我们有一个重要概念 - lane 还没有解释。关于 lane,我们将会在下一章节详细说明,到时候再过来头来看,就容易理解了。

到这里,本节内容结束了。相信借助 eventLoopworkLoopworkLoopSync / workLoopConcurrent 这三层循环的协作,大家对 Concurrent 模式下 react 的更新过程已经有一个比较深刻的理解了吧。

接下来,我们就来了解一下 Concurrent 模式下不同优先级更新的处理策略。

Concurrent 模式下不同优先级更新的处理策略

在开始本节内容之前,我们还是先来看一个 demo, 如下:

Oct-23-2021 18-04-33.gif

示例中,我们在 inputchange 事件的 handler 中,调用了两次 setState,一次是直接调用,修改短列表的值;一次使用了 useTransition,修改长列表的值。很明显的可以看到 input 输出框的值变化时,长列表的渲染会出现卡顿

我们打开浏览器的 performance 面板来分析一下 input 输入框值发生变化时 react 的工作过程,如下;

20211023.png

上面示例中长列表渲染之所以出现卡顿,是因为长列表要渲染的节点有 10000 个,导致 layout 过程耗时过久。

在面板中,我们可以很清晰的看到,两次 setState,触发了两次 react 更新,两次 fiber tree 的协调。value1 在第一次协调时更新,value2 在第二次协调时更新。

那么问题来了,在第一次协调开始之前,我们已经提交了两次 setState,这两次 setState上下文不同,那么 react 是怎么在每次协调时选择正确的 state 来更新呢?

带着这个问题,我们来开始本节内容的学习。相信看完本节,大家就能找到答案了。

react 组件 state 的更新机制

首先,我们先来了解一下组件 state 的更新机制。

组件,是一个 react 应用的最小工作单元

通常,我们在定义一个非展示类型的组件时,不管是 class 组件还是 function 组件,我们都会为组件定义一个或多个 state,然后通过调用 setState 修改 state,触发 react 更新,然后重新渲染页面。

调用 setState,并不会立即更新组件 statestate 的更新,其实是发生在 fiber tree 的协调过程中,这个过程如下:

concurrent-2.png

示例 demo 中,连续的两次 setState,会生成两个 update 对象 - update1update2,其中 update1 对应短列表 value1 的更新, update2 对应长列表 value2 的更新。 这两个 update 对象会按照创建的先后顺序依次添加到 updateQueue 中。

由于创建 update 对象的上下文不相同,导致 update 对象处理的时机不相同。第一次协调时,处理 update1,更新 value1;第二次协调时,处理 update2,更新 value2

之所以这样,是因为不同的上下文,为 update 对象绑定了的不同的 lanelane 决定了 update 对象的处理时机。

lane

lane,中文意思为小路车道

我们可以把一个 update 理解成一辆汽车lane 就是为 update 这辆汽车分配的车道。不同的上下文,为 update 定义了不同的 lane, 模型如下:

lane.png

上下文不同,lane 的优先级不相同。react 内部定义了 31lane,按优先级从低到高依次为:

lane2.png

react 采用了 31 位二进制数来表示 lane,如下:

const SyncLane = 1;      // 0000 0000 0000 0000 0000 0000 0000 0001
const DefaultLane = 16;  // 0000 0000 0000 0000 0000 0000 0001 0000

lane 对应的位数越,优先级最高。如 SyncLane1,优先级最高OffscreenLane31, 优先级最低

除了 updatelane 以外,另外还有两个关于 lane 的概念需要理解: 组件 fiber node 的 lanesfiber tree 根节点 root 的 pendingLanes

一个组件,可能会因为各种情况触发更新,每次更新都会生成 update 对象并分配 lane,生成的 update 对象会收集到 updateQueue 中,而分配的 lane 则会收集到组件 fiber nodelanes,收集方式如下:

fiberNode.lanes = 0;
fiberNode.lanes = fiberNode.lanes | SyncLane;  // lanes 为 1
fiberNode.lanes = fiberNode.lanes | DefaultLane;  // lanes 为 17

通过 fiber nodelanes,我们就可以知道该 fiber node 是否有 update 需要处理以及要处理的 update优先级

另外,为 update 对象分配的 lane 除了要收集到组件 lanes 外,还需要收集到 fiber tree 根节点 rootpendingLanes 中,收集方式和组件 lanes 一样:

root.pendingLanes = 0;
root.pendingLanes = root.pendingLanes | SyncLane;     // pendingLanes 为 1
root.pendingLanes = root.pendingLanes | DefaultLane;  // 

通过 rootpendingLanes,我们就可以在协调开始前,知道 fiber tree 中是否有 update 需要处理以及要处理的 update优先级。如果 fiber tree 中没有 update 要处理,即 pendingLanes0,那么 workLoopSync / workLoopConcurrent 就不需要启动了。

workLoopSync / workLoopConcurrent 每次工作时,react 会从 pendingLanes 中选出优先级最高的一类 lanes 作为本次协调指定的 lanes - renderLanes,然后处理分配到该 lanes 下的所有 update。 有了 renderLanes,我们就可以知道一个组件 fiber node 需不需要协调,一个 update 需不需要处理。如 renderLanesSyncLane, 组件 fiber nodelanesDefaultLaneupdatelaneDefaultLane,那么在本次协调中该组件就不需要处理,可以直接跳过

如果是 Transition 或者 Retry 类型的 lane,那么协调指定的 lanes 会有多条;如果不是,那么协调指定的 lane 只有一条。通常情况下,每次协调只会指定某一类型的 lanes,特殊情况如使用 useMutableSource 时会出现指定多种类型的 lanes,这种情况称为 lanes entangle。该情况我们会在之后的文章中详细说明

了解 lane 之后,我们将 lanereact 更新的过程结合起来,整个过程如下:

concurrent-3.png

在这里,我们就可以解释前面比较难理解的 Concurrent 模式下高优先级更新中断低优先级更新的过程了:

concurrent-5.png

这样理解起来就比较容易了吧。

模式不同,lane 的分配策略以及协调update 的处理策略也不相同。

Legacy 模式下的 lane

legacy 模式下,更新没有优先级的概念。不管触发更新的上下文是什么,所有的 update 对象创建时分配的 lane 都是 SyncLane。这意味着所有发生更新的 fiber nodelanes 都是 SyncLane,根节点 rootpendingLanes 永远为 SyncLane

workLoopSync 开始工作后,只能为本次协调指定 SyncLane,处理分配 SyncLane 的所有更新。组件节点在协调时,由于 updateQueue 中收集的 update 都分配了相同的 lane - SyncLane,所以会全部遍历来用于计算 new state

Legacy 模式下所有的更新都分配的是相同的 lane - SyncLane,更新是按照 FIFO 的顺序来处理的,不存在更新中断的情形。

Concurrent 模式下的 lane

Concurrent 模式下,更新有优先级的概念。react 会根据触发更新上下文,为每一个 update 分配对应的 lane

workLoopConcurrent 开始工作以后,会从 root.pengingLanes 为本次协调指定 renderLanes,然后处理匹配 renderLanes 的组件 fiber nodeupdate 对象。

组件 updateQueue 中收集的 update 对象,会根据更新上下文,被分配不同的 lanelane 不同,就会存在匹配 renderLanes不匹配 renderLanes 的情况。那么在遍历 updateQueue 时该怎么处理这种情况呢?

首先,updateQueue 中不匹配 renderLanesupdate 会被跳过。

例如,updateQueueupdateA1 -> B2 -> C1 -> D2renderLanes1, 那么第 13update 会被处理,第 24update 会被跳过,先输出 AC

其次,有些 update 会依赖上一个 update 对象的返回值,为了保证状态的连续性,就需要记录上一次协调时第一次出现 lane 不配 renderLanesupdate 的位置以及当时的 state

在上面的例子中,第一次协调时,在第二个 update 这里第一次出现 lane 不匹配 renderLanes。此时,stateA,不匹配的 update 及后续 updateB2 -> C1 -> D2。第二次协调时,renderLanes2stateA, 那么最后输出为 ABD

到这里,大家就可能有疑惑了。我们期望的结果是 ABCD,现在却是 ABD,是哪里出现问题了呢?

之所以会这样,是因为中间有一个步骤被遗漏了。第一次协调时,A1C1 被处理,他们的 lane 会被置为 0,那么第二次协调时处理的是 B2 -> C0 -> D20 是可以匹配任何 renderLanes 的,所以在第二次协调时,C 会再被处理一遍,最后的结果也就变成了如我们所愿的 ABCD

Concurrent 模式引发的副作用

Concurrent 模式下,不可避免的会出现组件重复协调update 重复处理的情况,这就会给我们的 react 应用带来一些副作用,需要我们在日常开发中注意。

组件重复协调引发的副作用

高优先级更新中断低优先级更新已开始的协调时,会发生组件重复协调的情况。

在示例 组件重复协调 中,我们定义了一个子组件 - Component3,组件中使用了 componentWillReceiveProps 用于修改 Component3stateComponent3 的父组件为 Component1Component1 更新时分配的 laneTransitionLane,为低优先级更新。当 Component1 发生更新时,子组件 Component3 也要更新,lane 也是 TransitionLane。由于 Component3 内定义的节点较多,协调过程耗时会较长,会分段进行。

另外,我们还定义组件 - Component2,更新时分配的 laneSyncLane,为高优先级更新

当我们只更新 Component1 时,Component3 也一起更新,协调时只触发一次 componentWillReceivePropsstate 修改是正常的,效果如下:

Oct-24-2021 23-09-10.gif

Component3 协调时,我们触发了 Component2 高优先级更新,Component3 的协调被中断,会在高优先级更新结束以后再次协调。在整个过程中,componentWillReceiveProps 会触发两次,导致 state 修改出现异常,效果如下:

Oct-24-2021 23-07-59.gif

除了 componentWillReceiveProps 以外, componentWillMountcomponentWillUpdate 在使用时,也会出现诸如上面示例出现的异常情况。

componentWillReceivePropscomponentWillMountcomponentWillUpdate 都是在协调时触发的,方法内部都可以修改 state,当组件重复协调时,不正当的操作会引来额外的副作用,因此 react 将这个生命周期方法定义为 unsafe_xxxxx, 在 Concurrent 模式下需谨慎使用。

update 重复处理的副作用

当一个组件内发生多种优先级更新时,就会出现 update 重复处理的情况。

在示例 状态重复处理 中,我们在 clickhandler 中连续三次修改了 stateupdate 的优先级情况为 SyncLane - SyncLane - SyncLaneSyncLane - TransitionLane - SyncLane。可以很明显的发现,优先级不同时,更新结果出现了异常,效果如下:

Oct-24-2021 23-32-28.gif

之所以出现异常,是因为优先级不同时,第三次 update 处理了两次。这个只是表面原因,实际原因是我们更新 state 的方式不合理:

const reducer = (state, action) => {
    switch(action) {
        case "INCREMENT":
            state.count = state.count + 1;
            return { ...state };
        ...
}

我们来分析一下上面一段代码为什么会在优先级不同时出现异常。

首先是优先级相同的情况。初始的 state,我们将它定义为 state1count0update 处理过程如下:

  • 第一个 updatestate1count1,返回 state2;
  • 第二个 updatestate2count1,返回 state3
  • 第三个 updatestate3count1,返回 state4

最后返回的 state4 将作为组件更新以后的 statecount 等于 3,这个结果是正常。

接下来是优先级不同的情况。update 处理过程如下:

  • 第一个 update高优先级,要处理, 将 state1count1,返回 state2
  • 第二个 update低优先级,不处理跳过,此时要将 state2 先保存下来;
  • 第三个 update高优先级,要处理,将 state2count1,返回 state 3;此时 state2 被修改, count2
  • 次协调结束,返回的 state.count 等于 2
  • 次协调,处理低优先级更新
  • 第二个 update 处理,将上一次协调保存下来的 state2count1, 返回 state4
  • 第三个 update 再次处理,将 state4count1, 返回 state5

最后返回的 state5 将作为组件更新以后的 statecount 等于 4,结果出现异常。

正确的更新 state 方式应该如下,这样就不会出现副作用

const reducer = (state, action) => {
    switch(action) {
        case "INCREMENT":
            return { ...state, count: state.count + 1 };
        ...
}

因此 Concurrent 模式下,state 的更新过程要谨慎,要避免写可引发副作用的代码,否则可能会因为 update 重复处理出现异常。

写在最后

到这里,本文就结束了。

最后,我们再对全文内容做一个回顾。本文的主要内容如下:

  • react18Concurrent 模式的启用方式Concurrent 模式和 Legacy 模式的区别、Concurrent 模式的意义;
  • 通过 eventLoopworkLoopworkLoopSync / workLoopConcurrent 三层循环来梳理 react 更新的整个过程;
  • 通过 lane 来梳理 react 不同优先级的更新的处理情况;

另外本文是基于 18.0.0-alpha-f6abf4b40-20211020 梳理的,可能会和最终的标准版有些出入。到时候如果和标准版有较大出入,将会根据标准版再会本文进行调整(变化应该不会太大,😄)。

传送门

参考文档