深入理解并发React

488 阅读23分钟

引言

React18是并发的React,提供了并发的底层机制,带来流畅的用户体验。这种特性是渐进式的,升级到React18不会破坏以前的代码,开发者可以按自己的节奏逐渐使用并发特性。

特性

并发特性影响的是React的渲染过程,这种并发渲染首先体现为可中断的渲染。

可中断

同步渲染意味着,一旦开始渲染就无法中断,直到用户可以在屏幕上看到渲染结果,这是React18以前或者React18以后但是尚未采用并发特性的情况。

在并发渲染下,一个更新可以中断挂起,稍后又继续,甚至是完全被废弃。你不必担心会出现不完全的UI,未完成的更新不会提交到页面上,只有当更新恢复,并且整棵树完成更新,完整的视图才会提交到页面。

可以看一个小示例:下面一个tabs应用,C和Rust对应的内容渲染很快,而Python对应的内容渲染很慢(需要2秒多),点击Python之后,不必等内容渲染出来,可以点击其他的tab,渲染相应的内容。

可中断.gif

代码如下:

const languageList = [
  { name: "C", key: "c" },
  { name: "Python", key: "python" },
  { name: "Rust", key: "rust" },
];
// 长度为100的数组
const sleepers = Array(100).fill(0);
let globalKey = 0;

function Languages() {
  const [activeLanguage, setActiveLanguage] = useState("c");
  const [, startTransition] = useTransition();
  const handleTabClick = (id) => {
    startTransition(() => setActiveLanguage(id));
  };
  return (
    <div>
      <div>
        {languageList.map(({ name, key }) => (
          <Language
            name={name}
            key={key}
            id={key}
            onClick={handleTabClick}
            active={activeLanguage === key}
          />
        ))}
      </div>
      <Content content={activeLanguage} />
    </div>
  );
}

function Content({ content }) {
  const sleepMs = content === "python" ? 20 : 1;
  return (
    <>
      {sleepers.map(() => (
        <Sleeper ms={sleepMs} key={globalKey++} />
      ))}
      <p>{`It's ${content}`}</p>
    </>
  );
}

function Sleeper({ ms }) {
  sleep(ms);
  return null;
} 

优先级

在并发渲染下,更新不仅是可中断的,还可以拥有不同的优先级,高优先级的更新可以先执行,甚至,高优先级的更新可以打断正在进行的低优先级更新。通常来说,用户的交互通常是更高优先级的,因为用户对此会更敏感。

在如下应用中,点击Toggle会开始一堆“1”的运动,每次会移动到相对于默认位置随机的一个偏移量处,这个变化会一直持续,但是在并发渲染下,更高优先级的用户输入始终能够得到响应,并且会打断运动,当输入停止,运动恢复。

优先级.gif

代码如下:

function Priority() {
  const [transited, setTransited] = useState(false);
  const [value, setValue] = useState("");

  const handleClick = () => {
    setTransited(!transited);
  };

  const hanldeChange = (e) => {
    setValue(e.target.value);
  };

  console.log("Priority rendered");

  return (
    <>
      <button onClick={handleClick}>Toggle</button>
      <input type="text" onChange={hanldeChange} value={value} />
      <MemoExpensiveChildren transited={transited} />
    </>
  );
}

const ExpensiveChildren = ({ transited = false }) => {
  const [transformX, setTrans] = useState("");

  useEffect(() => {
    if (transited) {
      const trans = () => {
        startTransition(() => setTrans(Math.random() * 500 - 250));
      };

      trans();
    }
  });
  console.log("ExpensiveChildren rendered");

  return (
    <div
      style={{
        transform: `translateX(${transformX}px)`,
        width: "500px",
        overflowWrap: "break-word",
      }}
    >
      {Array(400)
        .fill(0)
        .map((v, i) => (
          <ExpensiveChild key={i} />
        ))}
    </div>
  );
};

const MemoExpensiveChildren = memo(ExpensiveChildren);

function ExpensiveChild() {
  const mounted = useRef(false);
  console.log("ExpensiveChild rendered");
  if (mounted.current) {
    sleep(2);
  } else {
    mounted.current = true;
  }
  return 1;
}

中断与恢复

并发渲染下的更新中断与恢复实际上有两种情形:

  1. 高优先级更新对低优先级更新的打断。
  2. 不涉及React更新的事件对React更新的打断。

两种情形下,被打断的更新都会恢复,但不是没有区别的。结论是:普通的事件(各种交互)打断更新时,如果不涉及React更新(没有绑定回调函数,或者回调函数里没有setState),那么被打断的更新会从中断点恢复。相反,如果是高优先级更新打断低优先级更新,不管这个高优先级更新是如何触发的(事件回调函数,或者定时器,诸如此类),被打断的低优先级更新需要从root重新更新!

这是可以解释的。React更新的中断与恢复依赖workInProgress指针,如果被中断的更新期间没有新的更新,那么这个指针可以帮助React从中断处恢复。而如果高优先级更新打断了低优先级更新,在高优先级更新完成后,workInProgress指针就指向了null。如果你了解fiber树的双缓存的话,你会知道workInProgress树current树实际上已经完成了互换,低优先级更新所进行的渲染工作的部分成果已经不复存在了,更新要从头开始。

举一个简单的例子,假设当前页面有耗时的React更新:

  • 同步渲染下,鼠标滚轮滑动,页面无响应。
  • 并发渲染下,鼠标滚轮滑动,页面有响应,并且更新从中断点恢复。
  • 并发渲染下,鼠标滚轮滑动,页面有响应,在鼠标滚动事件中,调用了setState,触发了高优先级的更新,在这次setState对应的更新结束后,原有的更新从头开始。

原理

实现调度器

React的并发渲染基本逻辑就在这样的一个循环中:

function workLoopConcurrent() {
  // 执行工作直到需要中断
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

在触发setState后,代码中的函数被层层包裹,作为回调函数经过React调度器Scheduler调度,最终在Scheduler中执行。

有三个地方需要解释:

  • workInProgress是当前被处理的fiber(可见作者写的fiber树文章),在处理过程中会不断改变,当所有fiber被处理,就形成了 workInProgress树,渲染完成,随后这棵树会被提交到页面中。另外,React并发渲染中的中断/恢复就部分是因为workInProgress记录了中断点,而且不完全的树只在内存中,不会展现在页面中。
  • shouldYield由Scheduler提供,判断是否应该中断渲染工作。
  • performUnitOfWork包含具体fiber的渲染,以函数式组件为例,函数的执行发生这里。当然,并不是每一层的performUnitOfWork正好处理一个fiber,实际上,根据fiber在fiber树中的位置,每一轮performUnitOfWork会开始至少一个fiber的工作,完成0个或者多个fiber的工作。

由于并发渲染的核心在于并发,所以performUnitOfWork的实现细节无关紧要。

下面将从简单到复杂逐步展示并发渲染的实现,最终直到React源码的实现。

并发

在操作系统中,并发是指在同一个长时段有多个线程代码在执行。具体而言,每个确定的时刻只有一个线程代码在运行,多个线程交替执行,体现为一个时段内运行了多个线程代码。

在React中,由于javascript是单线程的,所以并发的单元就不是线程,而是工作单元,进一步说就是performUnitOfWork。在两个performUnitOfWork之间,浏览器可以处理包括用户交互在内的任务。

关于React的并发渲染,有两点需要关注:

  • 前提是fiber的单元化使得它能够被增量式渲染,从而为调度提供了可能性。
  • 实现涉及的是调度的方式,React何以能够使更新被中断,恢复,高优先级如何打断低优先级。

对于前提,这里不作讨论

并发的简易实现

调度器Scheduler实现了React的并发。然而,因为认识过程是由简入繁的,所以先展示一个最简单的调度器。

javascript是单线程的,React各个工作单元如果被同步处理,那并发渲染实际上是不可能的。然而对于javascript任务队列,每一轮事件循环只会处理一个任务,如果以异步的方式调度React的渲染工作单元,就可以在多个工作单元之间处理其他事件,也就实现了并发渲染。从这一点,我们可以实现一个调度器:

function scheduleCallback(callback) {
  setTimeout(callback);
  if (workInProgress !== null) {
    setTimeout(() => scheduleCallback(callback));
  }
}

scheduleCallback(performUnitOfWork);

为了代码的可读性,假设performUnitOfWork内部能访问到workInProgress

你没有看错,这个调度器就是setTimeout。也许循环调用会有点费解,但这就是许多非阻塞任务的实现方式,事实上,React Scheduler源码也是循环调用。异步循环调用使得每个工作单元都是一个异步任务,从而在任务间隙可以处理用户交互等事件。

实现的优化

上述简单的调度器确实能够实现并发渲染,但缺陷也时十分明显,主要有两点:

  • setTimeout频繁调用时,即便不指定延迟,也会默认有数毫秒的延迟,这使得整体的渲染时间因为工作单元之间的间隔而变长
  • 并发渲染带来可中断的渲染,但并不是任意时刻都存在需要插入的事件。如果可以在恰当的条件下同步处理尽可能多的工作单元,剩余的工作单元作为整体再被异步调用,这样渲染的总体时长会更短。

对于第一点,我们可以把setTimeout优化为MessageChannel,通过postMessage来调度一个onmessage回调函数。

对于第二点,可以设置一个固定时长,在这个时长内,所有的工作单元同步处理,如果超过这个时长,且还有工作单元未处理完成,那么就将剩下的工作整体再以异步的方式调度,如此循环往复,直至所有工作完成。这就是所谓的时间分片。实际上,用户很难感知数毫秒级延迟,时间分片会比异步调度每一个工作单元开销更小。

优化后的调度器是这样的(只处理一个工作 ):

const channel = new MessageChannel()
let startTime = -1
let workCallback = null

function shouldYield() {
  const timeElapsed = Date.now() - startTime;
  if (timeElapsed < 5) {
   
    return false;
  }
  return true;
}

function performWorkUntilDeadline() {
  // 由处理工作的函数自身判断工作是否完成
  workCallback = workCallback()

  if (workCallback !== null) {
    // 说明工作未完成,中断了继续调度
    schedule()
  }
}

channel.port1.onmessage = performWorkUntilDeadline

function schedule() {
  channel.port2.postMessage()
}

function scheduleCallback(callback) {
  startTime = Date.now()
  workCallback = callback

  schedule()
}

function workLoopConcurrent() {
  // 执行工作直到需要中断
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }

  let callback = null;

  if (workInProgress !== null) {
    // 工作中断了,未完成
    callback = workLoopConcurrent
  }
  return callback
}

// 现在调度的是工作整体
scheduleCallback(workLoopConcurrent);

在新的调度器里,由调度器向React提供shouldYield判断是否该中断,而React则通过回调函数的返回值告诉调度器工作是否未完成。在React与Scheduler的协作下,一个优化后的调度器能实现更佳的并发渲染能力,具体而言,就是在提供并发渲染能力的同时,优化渲染总时长。

对比图

下面的示例图展示了非并发渲染,并发渲染,以及并发渲染时不同调度器的表现差异:

屏幕截图 2024-11-22 215304.png

从React到Scheduler

React Scheduler的实现要复杂不少,而且另外提供了优先级调度的能力。通过优先级调度,React可以做到按优先级顺序更新,甚至是高优先级任务打断低优先级任务。

优先级

关于优先级,要分两个部分表述:

  1. React的优先级系统。
  2. Scheduler对优先级的处理。

对于第一点,react的优先级系统是基于lane的,包括了优先级的分类以及分配。分类是静态的,分配是每次更新时动态生成的。

lane是这样定义的:

export const TotalLanes = 31;

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncHydrationLane: Lane = /*               */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
export const SyncLaneIndex: number = 1;

export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;

export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;

export const SyncUpdateLanes: Lane =
  SyncLane | InputContinuousLane | DefaultLane;

const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000001000000;
const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111110000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000001000000000000;
const TransitionLane7: Lane = /*                        */ 0b0000000000000000010000000000000;
const TransitionLane8: Lane = /*                        */ 0b0000000000000000100000000000000;
const TransitionLane9: Lane = /*                        */ 0b0000000000000001000000000000000;
const TransitionLane10: Lane = /*                       */ 0b0000000000000010000000000000000;
const TransitionLane11: Lane = /*                       */ 0b0000000000000100000000000000000;
const TransitionLane12: Lane = /*                       */ 0b0000000000001000000000000000000;
const TransitionLane13: Lane = /*                       */ 0b0000000000010000000000000000000;
const TransitionLane14: Lane = /*                       */ 0b0000000000100000000000000000000;
const TransitionLane15: Lane = /*                       */ 0b0000000001000000000000000000000;

const RetryLanes: Lanes = /*                            */ 0b0000011110000000000000000000000;
const RetryLane1: Lane = /*                             */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000000100000000000000000000000;
const RetryLane3: Lane = /*                             */ 0b0000001000000000000000000000000;
const RetryLane4: Lane = /*                             */ 0b0000010000000000000000000000000;

export const SomeRetryLane: Lane = RetryLane1;

export const SelectiveHydrationLane: Lane = /*          */ 0b0000100000000000000000000000000;

const NonIdleLanes: Lanes = /*                          */ 0b0000111111111111111111111111111;

export const IdleHydrationLane: Lane = /*               */ 0b0001000000000000000000000000000;
export const IdleLane: Lane = /*                        */ 0b0010000000000000000000000000000;

export const OffscreenLane: Lane = /*                   */ 0b0100000000000000000000000000000;
export const DeferredLane: Lane = /*                    */ 0b1000000000000000000000000000000;

很明显,lane是一个二进制数,越小的值对应了越高的优先级。lane的一个关键分界线是DefaultLane,包括这个优先级在内的更高优先级是同步的,不可中断的,也就是无法并发渲染的。DefaultLane对应的是首次渲染,定时器里的setState更新,useEffect回调里的setState更新等。更高的优先级则是由dom事件触发的回调里的更新。DefaultLane以后的优先级则是可以并发渲染,可中断的。

至于lane的分配,这是根据setState触发的场景决定的。

对于第二点,Scheduler中的优先级则是由React调度更新时给定的。Scheduler维护一个优先级队列(基于小顶堆),根据React调度更新时传入的回调函数及优先级向优先级队列添加任务(O(logn)),执行时则依据优先级依次取出任务(O(1))。

从React lane到Scheduler优先级队列中的优先级的转化流程是这样的:lane->priorityLevel->expirationTime

priorityLevel是Scheduler的优先级,是普通的0-5的数值。Scheduler调度任务时会考虑超时问题,不同priorityLevel任务有不同的超时时间,考虑任务的开始时间,会得到一个最终的expirationTime,优先级队列就是根据expirationTime排序确定优先级的。

从setState到Scheduler

React首次渲染是不可中断的,也不需要经过调度器,需要讨论调度器的场景是更新。

React的更新一般由setState或者dispatch触发,这两个api触发更新的流程几乎是一致的,这里以setState为例。

以下是【从setState调用到Scheduler调度更新】的函数执行流程(react-reconciler v0.29.0截至11.15日最新代码):

React到Scheduler.png

值得注意的是,如果本次更新是不可中断的,也就是DefaultLane以上的优先级,那么更新不会经过调度器。

scheduleTaskForRootDuringMicrotask与scheduleCallback的源码在下面:

function scheduleTaskForRootDuringMicrotask(
  root: FiberRoot,
  currentTime: number,
): Lane {

  // ...
  
  if (includesSyncLane(nextLanes)) {
    // ...
    return SyncLane;
  } else {    
    // ...
    
    let schedulerPriorityLevel;
    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;
    }

    const newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performWorkOnRootViaSchedulerTask.bind(null, root),
    );

    // ...
  }
}

function scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: RenderTaskFn,
) {
  if (__DEV__ && ReactSharedInternals.actQueue !== null) {
    // ...
    ReactSharedInternals.actQueue.push(callback);
    return fakeActCallbackNode;
  } else {
    return Scheduler_scheduleCallback(priorityLevel, callback);
  }
}

performWorkOnRootViaSchedulerTask是执行React更新的函数,scheduleCallback就是在Scheduler提供的Scheduler_scheduleCallback的基础上做了一些针对开发环境的简单的封装。

对源码的分析将集中在Scheduler上。

Scheduler源码解析

任务从调度到执行的流程

Scheduler是一个独立的库,对于React来说是更新的东西,在Scheduler中是任务。也就是说,React的更新会作为任务在Scheduler中执行。以下是一个任务从被调度到被执行的流程图:

屏幕截图 2024-11-26 021641.png

因为每个函数的逻辑都比较简单,所以这里直接按顺序介绍每一个函数,当然,略过了与React无关的部分以及一些琐碎的细节。

unstable_scheduleCallback

React所调用的scheduleCallback就是这个函数,它的基本逻辑就是:

  1. 根据回调函数和优先级生成任务,将其加入优先级任务队列。
  2. 调度任务。

下面是它的源码(为了方面,下面还加上了其他文件对应配置的代码):

function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: {delay: number},
): Task {
  var currentTime = getCurrentTime();
  
  var startTime;
  // ...计算开始时间,如果没有延时就是currentTime

  var timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      // Times out immediately
      timeout = -1;
      break;
    case UserBlockingPriority:
      // Eventually times out
      timeout = userBlockingPriorityTimeout;
      break;
    case IdlePriority:
      // Never times out
      timeout = maxSigned31BitInt;
      break;
    case LowPriority:
      // Eventually times out
      timeout = lowPriorityTimeout;
      break;
    case NormalPriority:
    default:
      // Eventually times out
      timeout = normalPriorityTimeout;
      break;
  }

  var expirationTime = startTime + timeout;

  var newTask: Task = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  // ...

  if (startTime > currentTime) {
    // ...处理延时任务,React尚未使用
  } else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    // ...
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    }
  }

  return newTask;
}

// SchedulerFeatureFlags.js 超时相关参数
export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;

// SchedulerPriorities.js Scheduler优先级
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;

这个函数的入参中,priorityLevel在React调用scheduleCallback时由lane转化而来,callback就是React的更新回调函数,可选的option参数在React中尚未使用。

任务Task是一个对象,它的属性中最重要的是callbacksortIndex。前者是最终执行的回调函数,是React更新,后者是真正的最终优先级,实际上就是expirationTimeexpirationTime是根据开始时间加上不同优先级priorityLevel对应的超时时间得到。Task最终被加入到优先级队列中,随后56行的requestHostCallback将调度任务。

如果更新是可中断的,并且存在多次更新,又这些更新因为优先级不同而没有被批处理,那么这些更新会被转化成对应数量的任务。

requestHostCallback

这个函数是处理调度的。每个任务都会被加入优先级任务队列,但是异步调度只需要一次,在这一次调度中,多个任务可以依次被处理。

function requestHostCallback() {
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

这个函数就是单纯地保证MessageChannelonmessage回调同时只有一个,当然在这一个回调中可以执行多个任务。isMessageLoopRunning是一个全局变量,如果这个变量值为true,则说明已经有异步调度了,新任务实际上之前通过unstable_scheduleCallback被加入优先级任务队列中了,新的任务会和之前调度的任务按照优先级依次被处理。如果isMessageLoopRunning的值为false,则说明还没有调度,需要去调度一次。

schedulePerformWorkUntilDeadline

这个函数是对调度方法的封装,不同环境下的调度方法不一样,在chrome等常见场景下调度是通过MessageChannel的。

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // ...
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

从11行可以看到,schedulePerformWorkUntilDeadline其实只是postMessage调用,随后performWorkUntilDeadline会作为onmessage的回调异步执行。

performWorkUntilDeadline

这个函数处理Scheduler工作流,相较于之前的函数,是异步的,基本逻辑是:

  1. 处理工作
  2. 如果还有工作(比如工作中断了),继续调度,如此异步循环调用;如果工作都处理完了(对应于优先级任务队列清空),关闭调度,将isMessageLoopRunning置为false。
const performWorkUntilDeadline = () => {
  if (isMessageLoopRunning) {
    const currentTime = getCurrentTime();
 
    startTime = currentTime;
  
    let hasMoreWork = true;
    try {
      hasMoreWork = flushWork(currentTime);
    } finally {
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
};

flushWork

这是对执行任务workLoop的一层封装,主要是处理监控相关逻辑,生产环境下就是执行workLoop

function flushWork(initialTime: number) {
  // ...
  
  try {
    if (enableProfiling) {
      try {
        return workLoop(initialTime);
      } catch (error) {
        // ...
        throw error;
      }
    } else {
      // No catch in prod code path.
      return workLoop(initialTime);
    }
  } finally {
    // ...
  }
}

workLoop

这里的逻辑主要是:

  1. 循环取出,执行,弹出优先级任务队列中的任务。
  2. 循环结束后根据任务队里里是否还有任务,返回true或者false
function workLoop(initialTime: number) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break;
    }
    const callback = currentTask.callback;                                            
    if (typeof callback === 'function') {
      // ...
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      // ...
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
        // ...
        return true;
      } else {
        // ...
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
  // Return whether there's additional work
  if (currentTask !== null) {
    return true;
  } else {
    // ...
    return false;
  }
}

function shouldYieldToHost(): boolean {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  }
  // Yield now.
  return true;
}

peekpoppush是Scheduler优先级队列的取出,弹出,和插入方法。

任务的执行就是任务对应的callback的执行,换言之,就是React更新的执行。React与Scheduler依靠Scheduler的shouldYieldToHost以及React的更新函数返回值通信:

  1. shouldYieldToHost告诉React工作是否该中断。
  2. React更新回调函数返回该函数本身或者null通知Scheduler,更新是否被中断。

如果React更新回调函数返回null,那么这个任务对应的更新完成,任务被弹出队列,循环继续;反之,如果返回回调函数本身,则任务中断了,任务不会被弹出,循环因为shouldYieldToHost中断。

React的相关源码是这样的:

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

// ReactFiberRootScheduler.js
function performWorkOnRootViaSchedulerTask(
  root: FiberRoot,
  didTimeout: boolean,
): RenderTaskFn | null {
  // ...
  if (root.callbackNode === originalCallbackNode) {
    return performWorkOnRootViaSchedulerTask.bind(null, root);
  }
  return null;
}

React中的shouldYield就是Scheduler导出的shouldYieldToHost,这个函数一方面告诉React中断更新循环,另一方面告诉Scheduler中断任务循环。React更新循环的单元是工作单元,Scheduler任务循环的单元是任务,每个任务的回调函数是整个的React更新。

callbackNode就是Scheduler的任务。

performWorkOnRootViaSchedulerTask是被传入Scheduler的回调函数,它和workLoopConcurrent的关系是这样的:

屏幕截图 2024-11-18 020119.png Scheduler循环中断有以下几种情形:

  • 队列中任务已全部执行。
  • 任务未超时,但是本轮异步工作执行总时间超过时间片。

任务如果超时,也就是expirationTime小于currentTime,那么这个可中断任务将转化为不可中断任务,不受时间分片的约束。

shouldYieldToHost所示,任务的中断条件是getCurrentTime() - startTime > frameInterval,在这种情况下循环结束,workLoop返回true以表明还有工作,剩余的工作将在上级函数中被异步调度。

总结

Scheduler维护一个优先级任务队列,它通过异步循环的方式调度这些任务,需要注意的是,存在超时机制,一旦一个任务超时,那它将转化为不可中断任务。

应用

触发条件

并不是所有的更新都能应用并发渲染,当React18以前的代码迁移到React18后,运行逻辑是不变的。下面的源码展示了启用并发渲染,也就是开启时间分片的条件:

export function performWorkOnRoot(
  root: FiberRoot,
  lanes: Lanes,
  forceSync: boolean,
): void {
  // ...
  const shouldTimeSlice =
    !forceSync &&
    !includesBlockingLane(lanes) &&
    !includesExpiredLane(root, lanes);
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);

  // ...
}

启用时间分片的条件有三个:

  1. !forceSync,即不强制同步,Scheduler确定任务超时后,会告诉React超时了。
  2. !includesBlockingLane(lanes),即不包含阻塞优先级,也就是优先级在defaultLane以下。如果不主动调用并发相关api,更新都是阻塞,不可中断的。
  3. !includesExpiredLane(root, lanes),即不包含超时优先级,这是React自身对超时更新的判断,与第一点Scheduler的判断不同。

超时指的是低优先级更新从被调度起,等待更新的时间超过了限制。例如,同时存在多个更新,低优先级的更新要让位于高优先级更新,当某时又有高优先级更新被调度,而低优先级更新此时等待过久,即便在优先级上更低,但还是超时的低优先级更新先执行。

这些条件归总就是两条:没超时、可中断。

相关api

这里不会介绍相关api的用法,也不会探究它们的实现,因为并发特性并不是由这些api实现的,它们仅仅是让开发者使在应用中使用并发特性的工具。相反,下面会简略地陈述这些api与并发特性的关系。

startTransition

startTransition(() => setState(state))会将本次的更新从原来的优先级改为过渡优先级TransitionLane。这样,那次更新就会采用并发渲染。

useTransition

const [isPendding, startTransition] = useTransition()会在使用startTransition时,获得本次是否有正在pendding的过渡更新的信息。

useDeferredValue

const deferredValue = useDeferredValue(value)会将新的value带来的更新标记为延迟优先级DeferredLane(全优先级中最低的),并且只在React包含非紧急更新时才返回新的值。