likes
comments
collection

精读源码《React18》:Concurrent Mode

作者站长头像
站长
· 阅读数 8

前言

React团队花了2年时间重构成Fiber架构,目的就是为了Concurrent Mode做铺垫。花这么大的精力要打造的Concurrent Mode到底是什么呢?

What

Concurrent Mode(并发更新)是React一种新的更新模式,包含一组新的功能特性,其中最重要的特性就是: 异步可中断更新。这个特性带来的好处就是让应用一直保持响应,也就是让用户不会觉得页面卡顿,带来更好的交互体验。

并发和并行

肯定有人会把并发和并行的概念搞混了(小声bb:我之前就是)。

  • 并发指的是:多个任务,在同一时间段内同时发生,多个任务会有互相抢占资源的情况。
  • 并行指的是:多个任务,在同一时间点上同时发生,多个任务之间不会互相抢占资源。

举两个🌰:

  1. 一个人在跑步,这时他的鞋带松了,他停下来系鞋带,然后继续跑步。这是并发执行。
  2. 一个人在跑步,但同时他还戴着耳机听歌。这是并行执行。

在我们的计算机上, 只有多个cpu的情况下才可能发生并行。而我们看到的很多同时发生的事情,其实都是并发执行。

在这里,我们千万不要理解Concurrent Mode是在同时执行多个任务哦,它是在一段时间内,可以执行多个任务(因为可以打断低优先级任务,插队高优先级任务)。

流程图

精读源码《React18》:Concurrent Mode

Why

我们为什么需要Concurrent Mode

在使用Concurrent Mode之前的React在进行交互更新时都是同步更新,一旦渲染开始就不能被终止。也就是在更新完成前,页面会卡在那,用户是无法进行交互的。正常情况更新可以很快完成,用户无感知,但是当更新耗时较就,页面卡顿会给用户极不好的体验。

Concurrent Mode通过使渲染可中断来解决这个限制。

How

Concurrent Mode这种中断渲染的行为,虽然能打破React天花板,解锁未来可能,但也给React团队带来了不小的挑战:

  • 怎样做到中断渲染?
  • 怎样定义任务的重要程度和执行顺序?
  • 何时中断任务,怎样划分时间片?
  • ...

个人总结主要通过三个方面完成:

  • 颗粒化更新节点来解决递归不可中断问题;
  • 任务增加优先级来解决任务执行顺序;
  • 创建任务调度机制来解决时间分片和任务中断,任务恢复;

对应到React的实现就是:Fiber架构lane模型scheduler任务调度.

Fiber架构

在重构Fiber架构之前,React是没办法解决这些问题的。因为在此之前,React的渲染更新主要是通过对比更新前后的虚拟DOM,找出不同进行更新,而对比的过程因为虚拟DOM树结构的限制,只能采用递归更新,我们知道递归一旦开始,中途就无法中断。

那Fiber架构为什么能解决这个问题呢?

  1. 每个Fiber节点对应一个React Element,保存有该组件的所有基本状态信息
  2. 每个Fiber节点保存有该组件的更新信息

因为Fiber节点承载了基本状态和更新信息,这样React就可以将Fiber节点视为最小的工作单元,可以实现Fiber节点这种粒度的更新,因为粒度的细化也就使得异步可中断更新成为了可能。

Fiber节点的基本状态保存了它的父节点,子节点,兄弟节点信息,这样可以将之前的递归遍历改变为循环遍历,使渲染中断成为可能。

想了解更多关于Fiber架构可戳:Fiber

lane模型

lane模型主要解决的是任务优先级问题。

我们想中断渲染的本质是想让有更高优先级的任务可以中断低优先级任务来插队执行。那怎么定义任务优先级呢,lane模型通过31位的位运算符来定义:

// lane使用31位二进制来表示优先级车道共31条, 位数越小(1的位置越靠右)表示优先级越高
export const TotalLanes = 31;

// 没有优先级
export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

// 同步优先级,表示同步的任务一次只能执行一个,例如:用户的交互事件产生的更新任务
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000001;

// 连续触发优先级,例如:滚动事件,拖动事件等
export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lanes = /*            */ 0b0000000000000000000000000000100;

// 默认优先级,例如使用setTimeout,请求数据返回等造成的更新
export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000001000;
export const DefaultLane: Lanes = /*                    */ 0b0000000000000000000000000010000;

// 过渡优先级,例如: Suspense、useTransition、useDeferredValue等拥有的优先级
const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000000100000;
const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111111000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000001000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000000100000000;
...

可以看到React定义任务的优先级:

同步任务 > 连续触发事件任务 > setTimeout,请求更新任务 > 过渡任务(React18新特性)

事件优先级

具体同步任务和连续触发事件任务:

export function getEventPriority(domEventName: DOMEventName): * {
  switch (domEventName) {
    case 'cancel':
    case 'click':
    case 'copy':
    case 'dragend':
    case 'dragstart':
    case 'drop':
    ...
    case 'focusin':
    case 'focusout':
    case 'input':
    case 'change':
    case 'textInput':
    case 'blur':
    case 'focus':
    case 'select':
      // 同步优先级
      return DiscreteEventPriority;
    case 'drag':
    case 'mousemove':
    case 'mouseout':
    case 'mouseover':
    case 'scroll':
    ...
    case 'touchmove':
    case 'wheel':
    case 'mouseenter':
    case 'mouseleave':
      // 连续触发优先级
      return ContinuousEventPriority;
   ...
    default:
      return DefaultEventPriority;
  }
}

调度优先级

调度优先级分为四种:

  • Immediate:立即执行,最高优先级。

  • render-blocking:会阻塞渲染的优先级,优先级类似 requestAnimationFrame。如果这种优先级任务不能被执行,就可能导致 UI 渲染被 block。

  • default:默认优先级,普通的优先级。优先级可以理解为 setTimeout(0) 的优先级。

  • idle:比如通知等任务,用户看不到或者不在意的。


switch (lanesToEventPriority(nextLanes)) {
      case DiscreteEventPriority:
        schedulerPriorityLevel = ImmediateSchedulerPriority;
        break;
      case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
      case DefaultEventPriority:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
      case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
      default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    }

React根据lane模型来给不同的任务分配优先级,具体做法是: 看过我之前文章的应该知道,React更新主要是创建一个update对象,而updatelane会记录任务更新的优先级。

任务饥饿

任务饥饿是讲一个低优先级的任务一直被高优先级的任务插队,导致这个任务已经过了执行期限依然没有得到执行,在这种情况下,React会将该任务置为同步渲染任务,在下次更新时立即执行。

任务插队

当执行任务调度之前,会调用ensureRootIsScheduled()。它会判断当前是否存在饥饿任务更高优先级的任务。如果有更高优先级的任务,会中断当前任务执行,转而开始执行高优先级的任务。

Q:那低优先级任务如果被高优先级的任务插队怎样呢?

A:会直接被取消掉。因为高优先级的任务执行结果可能会影响到低优先级任务。

Q:那低优先级任务如果没有被影响会丢失吗?

A:不会,因为当高优先级任务执行完,页面重新渲染会再次走rendercommit流程,此时低优先级任务会再次加入任务队列。

任务调度

任务队列

scheduler中有两种任务队列,taskQueuetimerQueue,他们分别存放未过期任务和已过期任务。

前面讲到任务调度优先级分为四种,因此他们对应的timeout也不同:

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;

调度优先级反应到任务身上就是过期时间,过期时间的计算就是:

expirationTime = currentTime + timeout

很明显,立即执行任务的timeout是-1,所以它在添加的那一刻就已经过期了,React就视为它应该立即执行。

// 如果delay大于0,startTime = currentTime + delay
startTime = currentTime 

expirationTime = startTime + timeout

  if (startTime > currentTime) {
    // This is a delayed task.
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    
  } else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
  }
taskQueue

依据任务的过期时间expirationTime排序,过期时间越早表示任务优先级越高。

timerQueue

依据任务的开始时间startTime排序,开始时间越早优先级越高。

在开始调度的时候,因为timerQueue都是过期任务,会先清空timerQueue,将它们加入到taskQueue中。

let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      // Timer was cancelled.
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // Timer fired. Transfer to the task queue.
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
      if (enableProfiling) {
        markTaskStart(timer, currentTime);
        timer.isQueued = true;
      }
    } else {
      // Remaining timers are pending.
      return;
    }
    timer = peek(timerQueue);
}

时间切片

React根据浏览器的requestIdleCallback写了一个库scheduler

它规定一个时间切片为5ms

export const frameYieldMs = 5;

正常情况下,在一个时间切片里,任务循环调度,每调度完成一个任务都会检查时间片,当到达时间切片长度中断渲染交出控制权。

交出控制权的前提是:

当前任务队列中没有过期任务;

精读源码《React18》:Concurrent Mode

借用知乎大佬@淡苍的一张图,Concurrent Mode只能中断render阶段任务,不能中断commit阶段任务。

因为commit阶段会直接进行DOM更新,DOM更新是无法中断的,必须一次性执行完成。

交出控制权的方法是通过创建一个宏任务,了解JS事件循环机制的同学就会知道,宏任务是一个异步任务,它不会阻塞浏览器的运行。

React创建宏任务有三种策略:

优先顺序:setImmediateMessageChannelsetTimeout

交出控制权后,如果浏览器当前存在事件响应,更高优先级任务或者其他代码需要执行。

禁用时间切片
  • 存在阻塞渲染的任务;
  • 存在饥饿任务;

当存在上面两种情况时,直接走同步渲染,而不是Concurrent Mode

任务恢复

当任务中断交出控制权后,浏览器空闲时间就会执行我们上面中断任务时创建的宏任务,此时React就会重新创建任务循环,继续执行任务队列里的其他任务。

总结

本文主要从Fiber架构,lane模型,scheduler任务调度三个角度来尝试阐述Concurrent Mode的主要特性:异步可中断更新

总结起来是

通过Fiber架构完成从递归遍历更新到循环遍历更新的跨越,让render阶段可中断;

通过lane模型给不同任务事件添加优先级来定义任务的重要程度;

通过scheduler完成任务分片执行和中断恢复;

题外话

在此之前,前端框架主要解决的还是工程效率,开发效率问题,而无关乎用户体验。但是Concurrent Mode做了一次勇敢的尝试。

没有贴太多源码,主要是源码太多,贴一点源码更容易让读者云山雾罩。