react - 关于 react 为什么要从 ExpirationTime 切换到 lane 的一次考古

1,183 阅读7分钟

react - 关于 react 为什么要从 ExpirationTime 切换到 lane 优先级的一次考古

最近本来打算出一篇关于 lane 优先级的文章,但是却卡在了 react 为什么要将 ExpirationTime 优先级更换为 lane这部分。我在网上找了很多搜索工作,但是很少找到关于这个话题的细节讨论。官方关于这个改动也只有零星的 issue 可以参考。目前大部分的文章中谈到 lane 优先级的出现是因为高优先级 IO 任务会阻塞了低优先级的 CPU 任务并且还附带了相关的实例代码。但是我在复现这个问题的过程中发现切换为 lane 的原因肯能并非如此。这篇文章将会以 React-16.12.0 来证明这个结论。

从一个 issue 说起

一切还要从 react 的一个 issue 谈起。有位老哥在 react 的 issue 中提出了一个问题: Some questions about lanes。大概意思是你们这个 lane 优先级看起来 excited,但是你们有什么例子可以用来说明吗?这时候 react 内部的一位大佬就贴上了 codesandbox 上的示例代码: before lan。可能是由于版本的问题,这段代码以及无法正常运行。因为里面涉及到了 Suspense, Promise,useTransaction 所以也被很多文章当作 高优先级 IO 任务阻塞低优先级 CPU 任务的一个示例。我现在直接将这段代码贴出来:

import React, {
    Suspense,
    useState,
    useEffect,
    useTransition,
    Fragment
  } from "react";

  const sleep = (durationMs) =>
    new Promise((resolve) => setTimeout(() => resolve(), durationMs));
  const wrapPromise = (promise) => {
    let result;
    promise.then(
      (value) => {
        result = { type: "success", value };
      },
      (value) => {
        result = { type: "error", value };
      }
    );
    return {
      read() {
        if (result === undefined) {
          throw promise;
        }
        if (result.type === "error") {
          throw result.value;
        }
        return result.value;
      }
    };
  };
  const createResource = (durationMs) => {
    return wrapPromise(sleep(durationMs).then(() => "FETCHED RESULT"));
  };
  function Sub({ count }) {
    const [resource, setResource] = useState(undefined);
    const [startTransition, isPending] = useTransition({ timeoutMs: 4000 });
    console.info(`resource = ${resource}`)
    return (
      <div>
        <button
          onClick={() => {
            startTransition(() => {
                console.info("start resource")
                setResource(createResource(10000));
                console.info("end resource")
            });
            // setResource(createResource(40000))
          }}
        >
          CLICK ME
        </button>
        <pre>{JSON.stringify({ count, isPending}, null, 2)}</pre>
        {resource === undefined ? "Initial state" : resource.read()}
      </div>
    );
  };
  function EmptyWrap(props){
    return (
      props.children
    )
  }
  function LaneTest(props){
    const [s, setS] = useState(0);
    useEffect(() => {
      const t = setInterval(() => {
        console.info("=================================== start =============================")
        setS((x) => x > 10 ? x:  x + 1);
      }, 1000);
      return () => {
        clearInterval(t);
      };
    }, []);

    const a = (
      <>
        <button onClick={() => {
          console.info("============================== start ==============================")
          setS(s + 1)

        }}>+1</button>
          <Suspense fallback={<div>loading...</div>}>
            <Sub count={s} />
          </Suspense>
        <span>{s}</span>
      </>
      )
    return a;
  };  

  export default LaneTest;

实际上代码非常简单,使用了 Promise 以及 Suspense 模拟一次 IO 请求。关于 Suspense 我这里大概提一下原理,当我们在 Suspense 中 raise 一个类型为 Promise 的异常时 react 会捕获这个 Promise 并将 Suspense 子组件切换为 fallback 中的内容,等这个 promise resolve 时再渲染真正的子组件内容。总的来说,就是将 query - pending state - complete 这三个状态管理通用化了。具体流程就是: 发起请求 - Suspense 展示 fallback - 请求完成展示真实子组件

但是有时如果请求很快完成就会出现 fallback 一闪而过的状态造成用户体验不佳。这个问题 react 也考虑到了,推出了 useTransition 这个 hook,其中可以传入一个参数来控制 fallback ,使其可以延迟展示。 假设将其设置为 { timeoutMs: 1000 } 则表示发起请求的 1s 内不展示 fallback 界面,如果请求 1s 内完成了就直接显示真实的子页面,否则超过 1s 再开始展示 fallback。避免了 fallback 闪屏的现象。

这段代码的运行结果如下:

Honeycam 2022-05-08 15-56-33.gif

当点击按钮后,请求已经发出,但此时并没有立即显示 fallback。同时 count 也停止增加,等到 useTransition timeout 之后才显示 fallback 内容 loading...。这里有一点符合我们的预期,但也有一旦不符合我们的预期:

  • 符合预期: fallback 等到 useTransition timeout 之后才显示
  • 不符合预期: fallback 虽然没显示,但是 count 也不再增加,看起来像整个界面被卡住了

但是真的是高优先级的 IO 任务阻塞了低优先级的 CPU 任务吗

谁阻塞了谁

从上面的现象可以看出来,我们的渲染任务的确被阻塞了。但是现在有了新的问题:

  • 到底是被谁阻塞的?
  • 阻塞到哪个步骤了?
  • 阻塞的时候有真正执行的任务吗?

让我们先来做一个实验把 startTransaction 去掉,保留 Suspense。我们会发现,去掉 startTransaction 后点击按钮会立即显示 loading...。但是 count 却不会出现卡住的现象。我们在去掉 startTransaction 保留了Suspense 以及模拟的 IO 任务的情况下发现页面并没有被。因此到这里我们得出了第一个结论:

页面卡住,并不是因为高优先级 IO 任务的阻塞

此时第一个问题还没有真正的回答,我们现在知道阻塞渲染另有其人,但是到底是什么呢?经过我长时间对源码的调试以及阅读,阻塞渲染的原因实际上是:

同时使用 Suspense 以及 startTransaction

这哥俩可以说是缺一不可,但凡你换掉其中的一个页面都不会被阻塞。但是熟话说得好:我咋知道你是不是乱编的,just show me the code。接下来我们从源码的角度来分析整个问题。

首先找 ReactFiberWorkLoop.js 中的 finishConcurrentRender 这个函数。这个函数的作用就是根据 exitStatus 来做各种操作,然后后续会渲染 真实 dom。注意这里已经到最后的 diff 提交阶段了。如果我们同时使用了 Suspense 以及 startTransaction 那么 existStatus 就为 RootSuspendedWithDelay,并且此时代码会计算出一个 msUntilTimeout,表示离 startTransaction timeout 的时间,如果这个时间还大于 10,react 就会说这 timeout 还早着呢,到了再说把,这把先不渲染了:

 // Don't bother with a very short suspense time.
        if (msUntilTimeout > 10) {
          // The render is suspended, it hasn't timed out, and there's no
          // lower priority work to do. Instead of committing the fallback
          // immediately, wait for more data to arrive.
          root.timeoutHandle = scheduleTimeout( // 调度一个 timeout 后执行的任务
            commitRoot.bind(null, root),
            msUntilTimeout,
          );
          console.info("commit delay")
          break;
        }

可以看出调度了一个 msUntilTimeout 的回调就直接 break 了,没有调用后续的 commitRoot,自然也没有任何真正的 dom 改变发生。而此时其他需要提交的 diff 瑟瑟发抖,怎么的就你要延迟提交我们就得跟着遭殃啊?没错 react 就是这么任性,就算同一个任务中还有其他的 diff 需要提交 react 也一并延迟了。这也就是我们点击按钮后为什么 count 会停止增加的原因。渲染发生,当然也就看不到最新的 count。但是实际上状态的计算并没有被阻塞,只是 complete 阶段没有提交 diff。到这里,开头提出的 3 个问题我们已经可以回答了:

  • 到底是被谁阻塞的: 被 Suspense 和 startTransaction 双剑合并阻塞的,当然我认为称为跳过更合适
  • 阻塞到哪个步骤了: 阻塞(跳过)了 complete work
  • 阻塞的时候在做什么: 该干啥干啥,该 diff 还 diff,只不过最后不给渲染

这三个问题回答完了,但是这个问题的本质我们还没有找到。什么情况导致了渲染 count 的时候 existStatus 被设置为 RootSuspendedWithDelay 呢?我们继续搜索会发现,在 ReactFiberCompleteWork.jscompleteWork 中当组件类型为 SuspenseComponent 时且满足条件 current.memoizedState === null && workInProgress.memoizedState !== null 时就会调用 renderDidSuspendDelayIfPossible 这个函数,将 existStatus 设置为 RootSuspendedWithDelay:

if (
    workInProgressRootExitStatus === RootIncomplete ||
    workInProgressRootExitStatus === RootSuspended
  ) {
    console.info("set workInProgressRootExitStatus = RootSuspendedWithDelay")
    workInProgressRootExitStatus = RootSuspendedWithDelay;
  }

那么为什么我们点击了 ' CLICK ME ' 按钮之后这个 wip.memoizedState 就不为空了呢? 在 ReactFiberBeignWork.jsupdateSuspenseComponent 函数中如果满足条件 const didSuspend = (workInProgress.effectTag & DidCapture) !== NoEffect,最终会进入到:

if (nextDidTimeout) {
        workInProgress.memoizedState = SUSPENDED_MARKER;
        workInProgress.child = primaryChildFragment;
        return fallbackChildFragment;

这个分支,在这里会将 workInProgress.memoizedState = SUSPENDED_MARKER。这也就是为什么在 completeWork 中 workInProgress.memoizedState 不为空的原因。现在问题又变成了 const didSuspend = (workInProgress.effectTag & DidCapture) !== NoEffect 这个条件什么时候会满足? 在之前提到过,如果我们要进行 io 请求,首先要抛出一个 Promise,并且会由 react 去捕获。如果捕获到了就会给就近的 Suspense 打上这个 effectTag。也就是说,一旦当我们抛出 Promise,这个 effectTag 就会打上,也就会导致后面跳过渲染。最后回到自己编写的代码,什么情况会抛出 Promise:

function Sub({ count }) {
    const [resource, setResource] = useState(undefined);
    const [startTransition, isPending] = useTransition({ timeoutMs: 4000 });
    console.info(`resource = ${resource}`)
    return (
      <div>
        <button
          onClick={() => {
            startTransition(() => {
                console.info("start resource")
                setResource(createResource(10000));
                console.info("end resource")
            });
            // setResource(createResource(40000))
          }}
        >
          CLICK ME
        </button>
        <pre>{JSON.stringify({ count, isPending}, null, 2)}</pre>
       // 当 resource !== undefined,时会调用 resource.read(),如果此时的 promise 还没有 resolve 就会抛出 promise
        {resource === undefined ? "Initial state" : resource.read()}
      </div>
    );
  };

也就是说只要 resource !== undefined 并且这个 promise 还没有被 resolve 就会被抛出。而 resource 初始化为 undefined。当我们点击 onclick 后会调用:

onClick={() => {
            startTransition(() => {
                console.info("start resource")
                setResource(createResource(10000));
                console.info("end resource")
            });
            // setResource(createResource(40000))
          }}

也就是只要这个 setResource(createResource(10000)) 被执行了,就会抛出 promise,也就会导致最后的跳过渲染问题。那么现在重点来了,如果两个 setState 的优先级相同,react 会复用同一个任务来处理,此时我们代码里面有两个 setState,分别为:

  • 用于增加 count 的 setS((x) => x > 10 ? x: x + 1),每秒增加一个
  • 用于对 resource 进行赋值的 setResource(createResource(10000))

那么有没有可能这两个 setState 的优先级相同,导致在进行 count 的渲染和 Suspense 子节点渲染在同一个任务呢?没错,我们终于找到了真正的答案!!!我们知道如果是用户事件触发的 setState,react 会根据事件的类型来选择不同的优先级,如果是其他地方的 setState,例如 setTimeout,react 会采用一个默认优先级。而 useTransition 也有相同的控制优先级的操作,它会根据 timeout 的时长来计算优先级,时长越长,优先级越低。而我们选取的 { timeoutMs: 4000 } 正好计算出来的优先级与默认优先级相同,导致这两个 setState 在同一个 task 中处理。因此我们最终的结论是:

由于计算 count state 以及 setResouce 的优先级一致,导致渲染 count 的任务与处理 Suspense 的任务实际上是同一个任务。此时 Suspense 需要延迟渲染,导致了渲染 count 的任务也延迟进行。

到这里,我们基本找出了页面被阻塞的原因。虽然说和 IO 有一点关系,但的确不是高优先级的 IO 任务阻塞了低优先级的 CPU 任务,因为正因为两个任务的优先级相同才导致了阻塞或者是跳过的问题。

lane 可以解决这个问题吗?

在我们知道了问题的原因后,当然也想知道如何解决这个问题。实际上答案非常简单,既然原因是 useTransition 的优先级和其他任务优先级一致,那么我们只需要降低 useTransition 计算出的优先级,让其小于任何其他任务的优先级即可。这个想法也非常好验证: 由于用户点击是一个优先级非常高的事件,我们在点击 CLICK ME 按钮触发 setResource 后继续点击 +1 按钮增加 count 这时可以看到 count 的渲染完全不受影响:

Honeycam 2022-05-08 17-40-11.gif

并且 fallback 渲染也和我们预期一致。原因也很好解释:

由于 click 事件产生的渲染优先级非常高,导致计算 setResource state 计算被直接跳过。因此在渲染的任务中, Sub 组件的 resource 为 undefined,如果 resource 为 undefined 就不会抛出 Promise 异常,自然也没有延迟渲染 fallback 的行为了,此时 count 可以正常更新。

至于如果不清楚为什么 setResource 会被跳过,可以参考我的另一篇文章:update Queue 原理

那么说了这么多,lane 可以解决这个问题吗?当然可以, lane 优先级是由二进制位来控制的,并且为 1 的那一位越靠近右边优先级就越高,那么只需要在 lane 里面把 useTransition 的优先级设置得比其他优先级都靠左,这个问题就完美解决了。那么 react 真的是这么做的吗? 我只能说 react 的开发者肯定比我聪明,让我们直接看 react 18.1.0 中对优先级定义的源代码:

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

export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000001;

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

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

const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000000100000;

const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111111000000;

这里的 TransitionLanes 代码所有 useTransition 计算时可以取的范围。可以看出,确实是比其他任务都靠左。也就是 useTransition 能计算出的优先级不可能大于其他任务的优先级,即便是 DefaultLane 也要大于所有的 TransitionLanes。

现在我要问出本文的终极问题:

lane 可以解决这个问题,那么 react 从 ExpirationTime 改为 lane 是为了解决这个问题吗?

我个人的答案是: 。因为这个问题就算是在 ExpirationTime 也很好解决:只要在代码中设置一个 useTransition 计算优先级的上限并保证其小于其他的优先级即可。那么 react 到底为什么要将ExpirationTime 换成 lane 呢?说实话,这个问题我目前还没办法回答,可能真的只有 react 的成员才知道正真目的了把。

总结

经过分析,我们知道 IO 任务并不会阻碍 CPU 任务,因此这也不是从 ExpirationTime 切换的 lane 优先级的原因。具体的原因我也不知道,如果有知道的大佬还不吝赐教,在评论区贴出你的观点。

感谢

感谢 nero 大佬提供的 react 源码调试环境,为我调试这个问题带来了很多方便。nero 大佬对 react 也有也有深刻的理解,想深入学习 react 源码的伙伴请戳大佬的 github React的秘密