likes
comments
collection
share

2023 年,你是时候要掌握 react 的并发渲染了(4) - lane 模型(1)

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

聚焦 lane 优先级

回到 react 的源码中,它的优先级体系是由 lane 优先级来主导的。从上面的「事件优先级 -> lane 优先级」小节我们也知道,所谓的「事件优先级」其实也是 lane。 一提到 react 的优先级,lane 是无处不在的。那到底什么是 lane 呢?

什么是 lane 模型?

lane 模型的引进者 Andrew Clark第一版 lane 模型实现的 PR 上面是如此说到:

The type of the bitmask that represents a task is called a Lane. The type of the bitmask that represents a batch is called Lanes.

翻译过来就是:

  • 能够表示一种类型任务的位掩码称之为「lane」;
  • 能够表示一批任务(由不同类型任务所组成)的位掩码称之为「lanes」;

从值的角度来看,位掩码就是一个二进制数。所谓的「lane」就是只有一位为1,其他位都是0 的二进制数;而「lanes」就是有多个位为1,其他位都是0 的二进制数。

截止 react@18.2.0,react 有 31 种 lane:

lane 的种类是固定的,但是 lanes 则随时都可以通过组合多个 lane 来产生,所以没必要记住有多少种 lanes。在 react 的源码中内置几种 lanes 也只是图个便利而已。

// react@18.2.0/packages/react-reconciler/src/ReactFiberLane.old.js
export const TotalLanes = 31;
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;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000001000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane7: Lane = /*                        */ 0b0000000000000000001000000000000;
const TransitionLane8: Lane = /*                        */ 0b0000000000000000010000000000000;
const TransitionLane9: Lane = /*                        */ 0b0000000000000000100000000000000;
const TransitionLane10: Lane = /*                       */ 0b0000000000000001000000000000000;
const TransitionLane11: Lane = /*                       */ 0b0000000000000010000000000000000;
const TransitionLane12: Lane = /*                       */ 0b0000000000000100000000000000000;
const TransitionLane13: Lane = /*                       */ 0b0000000000001000000000000000000;
const TransitionLane14: Lane = /*                       */ 0b0000000000010000000000000000000;
const TransitionLane15: Lane = /*                       */ 0b0000000000100000000000000000000;
const TransitionLane16: Lane = /*                       */ 0b0000000001000000000000000000000;

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

export const SomeRetryLane: Lane = RetryLane1;

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

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

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

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

如果把所有的 lane(不是 lanes) 从上到下叠到一块,我们就能看到一个视觉的规律:1 这个数字会从低位到高位依次出现在对应的位置上。这就好像是一条具有 31条车道的高速公路,每一条车道都有规定的行驶速度,每一辆车都会根据它的速度被分配到一条车道上。这就是 Andrew Clark 当初采用这两个术语名的所想表达的意象吧。

Note: these names are not final. I know using a plural is a bit confusing, but since the name appears all over the place, I wanted something that was short. But I'm open to suggestions.

就像 Andrew Clark 当年所补充说明的那样(这两个术语名也许不是最终的名字,他还鼓励大家给出更好的名字)名字是可以变的,但是名字后面你的语义是不会变的。

lane 模型的技术本质就是「二进制 + 按位运算」。它的巧妙之处,它除了表示值,它还能表示「某种类型任务的存在性」和「一批任务」的语义:

  • lane - 既可以用来表示「一个优先级值」,也能用来指示「当前存在一个或者多个具有这种优先级的任务」;
  • lanes - 用来表示当前存在多个由不同类型任务组成的(一个 lane 就是代表一种优先级,一种优先级就代表一种类型的任务)的任务集合,即「一批任务」;

为什么从 expirationTime 模型转到 lane 模型中来?

这个问题的答案可以 Andrew Clark 在第一版 lane 模型实现的 PR review 文档中找到答案。简单来说,促使 react 团队将 expirationTime 模型重构为 lane 模型的一个契机是:react 团队 为 Suspense API 引入了一个新语义。从前 Suspense API 只是用于跟 React.lazy() 结合来做 code splitting,现在它还可以用于异步数据请求。异步数据请求是一个 IO-bound 的任务,那么基于 expirationTime 模型无法很好地帮助 react 团队实现「正在挂起(等待网络数据的回传)的高优先级的异步数据请求任务不要阻塞低优先级的任务」的这个需求。

总结起来,用十进制值表示任务优先级存在两个不足点:

  1. 数值既用来表示一个优先级也用来表示一批优先级。单个优先级跟批量优先级的概念耦合在一块了;

    在 expirationTime 模型,如果要表示「一批优先级」,那么你就要这样写:

    const isTaskIncludedInBatch =
      taskPriority <= highestPriorityInRange &&
      taskPriority >= lowestPriorityInRange;
    

    也就是说,你还是用数值来表示「一批优先级」,只不过是用区间值 。假如,我现在需要移除优先级为 1000 的任务,那么你还需要在全局建一个数组,用于存放本批次需要排查的优先级值,然后,在判断的时候去排除它:

      const excludedTasks = [1000];
      const isTaskIncludedInBatch = taskPriority <= highestPriorityInRange && taskPriority >= lowestPriorityInRange && !excludedTasks.include(taskPriority);
    

    哇,你也看出了基于 expirationTime 的优先级模型的表达力很差,导致需要用很多代码才能表示一个概念。而基于 lane 的优先级模型则简单地用一个有多个位掩码的二进制来表示即可,比如:

    const batchOfTasks = 0b0000111011111;
    

    而判断某个任务是不是归属于这一批任务,只需要这么判断即可:

    const batchOfTasks = 0b0000111011111;
    
    const isTaskIncludedInBatch = (task & batchOfTasks) !== 0;
    

    即使后面要移除某个或者多个优先级的 task,也特别简单:

    const batchOfTasks = 0b0000111011111;
    
    const batchOfTasksAfterRemoveSomeTasks = batchOfTasks & ~task;
    
  2. 无法满足 react 中高频的优先级运算场景对优先级运算高效性和便利性方面的要求;

    一条 lane 就可以表示多个具有相同优先级的任务。而在 expirationTime 模型中,这需要多个十进制值来表示。这无疑会占用更多的内存。而从运算的性能来看,按位运算肯定是高效于普通的算术运算,这就不需要赘述了。鉴于,优先级运算的普遍性和高频性,react 太需要提到运算的高效性和便利性了。而基于二进制的 lane 模型刚好能满足我们的这个需求。

小结

综上所述,基于二进制位掩码表现力的 lane 模型很巧妙地解决了上面所指出的 expirationTime 模型 的两个不足点。

lane 的基本运算及含义

react 团队把所有对 lane 的相关操作都放在 react@18.2.0/packages/react-reconciler/src/ReactFiberLane.old.js 模块里面了。在这没多的 lane 操作里面,有几个是常用的基础运算。从这些基础运算我们可以看出,其实 lanes 就是集合。基础的 lanes 运算就是集合运算。下面我们简单地过一过 :

  1. 判断两个 lanes 集合之间是否有交集:
export function includesSomeLane(a: Lanes | Lane, b: Lanes | Lane) {
  return (a & b) !== NoLanes;
}

翻译为任务角度的话就是,判断两批任务之前是否共同拥有同样优先级的任务。

  1. 判断两个 lanes 集合是否是父子集的关系:
export function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane) {
  return (set & subset) === subset;
}
  1. 合并两个 lanes 集合:
export function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
  return a | b;
}
  1. 从一个 lanes 集合中把某个 lane 或者 lanes 集合给移除掉,相当于求一个集合的补集:
export function removeLanes(set: Lanes, subset: Lanes | Lane): Lanes {
  return set & ~subset;
}
  1. 求两个 lanes 集合的交集:
export function intersectLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
  return a & b;
}

lane 的生命周期

我们对 react 的源码关于 lane 的那部分稍作总结,就可以看到 lane 是存在一个生命周期。lane 的生命周期可以分为几个阶段:

  1. 产生 - lane 是从无到有来生产出来;
  2. 分发 - 产生的 lane 最终是被分发到 update 对象,普通的 fiber 节点和 fiber root node 上面去;
  3. 消费 - lane 最终是参与到末端消费,即用于某种用途。

下面,我们来深入看看这几个阶段。

1. 产生 - requestUpdateLane()

一般情况下,当我们发出一个更新请求的时候,react 就会着手生产出一个 lane。这个 lane 会在它的下一个阶段被分发出去。

我们用户能发出更新请求的地方不多,也就那么几个地方:

  • react 应用的初始挂载的时候 - root.render(<App />);
  • class component 的 setState()(还有replaceState()forceUpdate);
  • function component 版本的 setState()useState()或者useReducer() 所返回的 dispatch 方法);

如果我们去翻看一下这些 API 的具体实现源码,你会发现这些 API 的实现都是采用同样的三部曲:

  1. 创建 update 对象
  2. 把 update 入队了 update queue 中
  3. 最后,发起一次界面更新请求

不信我?下面我摘抄出一些源码给你看看。下面这个 dispatchSetState() 就是上面提到的 dispatch 方法:

  function dispatchSetState(fiber, queue, action) {
    const lane = requestUpdateLane(fiber);

    // 1. 创建 update 对象;
    const update = {
      lane,
      action,
      hasEagerState: false,
      eagerState: null,
      next: null,
    };

    if (isRenderPhaseUpdate(fiber)) {
      // 2. 把 update 入队了 update queue 中;
      enqueueRenderPhaseUpdate(queue, update);
    } else {
      ...
      // 2. 把 update 入队了 update queue 中;
      const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

      if (root !== null) {
        ...
        // 3. 最后,发起一次界面更新请求
        scheduleUpdateOnFiber(root, fiber, lane, eventTime);
        ...
      }
    }
  }

我们再来看看 class component 的 setState() 函数的实现:

const classComponentUpdater = {
    ...
    enqueueSetState(inst, payload, callback) {
      const fiber = get(inst);
      const eventTime = requestEventTime();
      const lane = requestUpdateLane(fiber);
      // 1. 创建 update 对象;
      const update = createUpdate(eventTime, lane);
      update.payload = payload;

      if (callback !== undefined && callback !== null) {
        update.callback = callback;
      }
      // 2. 把 update 入队了 update queue 中;
      const root = enqueueUpdate(fiber, update, lane);

      if (root !== null) {
        // 3. 最后,发起一次界面更新请求
        scheduleUpdateOnFiber(root, fiber, lane, eventTime);
        entangleTransitions(root, fiber, lane);
      }
    },
    ...
}

聪明如你,一定看到了三部曲,也证明我所言不虚。讲解三部曲并不是本文的主题。本文的主题是「优先级机制」,是「lane 模型」。所以,看到我这么提醒,你就知道注意到什么了吗?

没错,react 在每一次创建 update 对象之前都会做一个动作:请求一条 lane - requestUpdateLane()。啊哈,这才是我们要讨论的重点。lane 是如何产生的逻辑就是藏在这个函数里面。下面我们来分析分析这个函数(函数所在文件是react@18.2.0/packages/react-reconciler/src/ReactFiberWorkLoop.old.js)。

function requestUpdateLane(fiber) {
  // Special cases
  const mode = fiber.mode;

  if ((mode & ConcurrentMode) === NoMode) {
    return SyncLane;
  } else if (
    (executionContext & RenderContext) !== NoContext &&
    workInProgressRootRenderLanes !== NoLanes
  ) {
    // This is a render phase update. These are not officially supported. The
    // old behavior is to give this the same "thread" (lanes) as
    // whatever is currently rendering. So if you call `setState` on a component
    // that happens later in the same render, it will flush. Ideally, we want to
    // remove the special case and treat them as if they came from an
    // interleaved event. Regardless, this pattern is not officially supported.
    // This behavior is only a fallback. The flag only exists until we can roll
    // out the setState warning, since existing code might accidentally rely on
    // the current behavior.
    return pickArbitraryLane(workInProgressRootRenderLanes);
  }

  const isTransition = requestCurrentTransition() !== NoTransition;

  if (isTransition) {
    // updates at the same priority within the same event. To do this, the
    // inputs to the algorithm must be the same.
    //
    // The trick we use is to cache the first of each of these inputs within an
    // event. Then reset the cached values once we can be sure the event is
    // over. Our heuristic for that is whenever we enter a concurrent work loop.

    if (currentEventTransitionLane === NoLane) {
      // All transitions within the same event are assigned the same lane.
      currentEventTransitionLane = claimNextTransitionLane();
    }

    return currentEventTransitionLane;
  } // Updates originating inside certain React methods, like flushSync, have
  // their priority set by tracking it with a context variable.
  //
  // The opaque type returned by the host config is internally a lane, so we can
  // use that directly.
  // TODO: Move this type conversion to the event priority module.

  const updateLane = getCurrentUpdatePriority();

  if (updateLane !== NoLane) {
    return updateLane;
  } // This update originated outside React. Ask the host environment for an
  // appropriate priority, based on the type of event.
  //
  // The opaque type returned by the host config is internally a lane, so we can
  // use that directly.
  // TODO: Move this type conversion to the event priority module.

  const eventLane = getCurrentEventPriority();
  return eventLane;
}

requestUpdateLane() 里面, react 罗列生产 lane 的五种情况。从它使用提前 return 写法(if...else if...else...的简约版)我们就知道,这五种情况是存在优先级的:

  1. 当前是同步渲染模式;
  2. 在 render 阶段触发了更新;
  3. 当前存在 transition 类型的更新任务;
  4. 在 react 内部方法处中触发了更新;
  5. 在 react 外部方法处中触发了更新;

当前是同步渲染模式

在 react@18.2.0 中,react 会将 root(fiber root node) 标记为 ConcurrentRoot:

const LegacyRoot = 0;
const ConcurrentRoot = 1;

function createRoot(container, options) {
    ...
    const root = createContainer(
      container,
      ConcurrentRoot,
      null,
      isStrictMode,
      concurrentUpdatesByDefaultOverride,
      identifierPrefix,
      onRecoverableError
    );
    ...
}

ConcurrentRoot 会沿着这个调用栈 (createContainer() -> createFiberRoot() -> createHostRootFiber() )透传到 createHostRootFiber() 函数里面:

function createHostRootFiber(
  tag,
  isStrictMode,
  concurrentUpdatesByDefaultOverride
) {
  let mode;

  if (tag === ConcurrentRoot) {
    mode = ConcurrentMode;

    if (isStrictMode === true) {
      mode |= StrictLegacyMode;
    }
  } else {
    mode = NoMode;
  }

  return createFiber(HostRoot, null, null, mode);
}

至此,tag 就变成 mode,即 concurrent mode。最后,host root fiber 的上 mode 会随着 react 应用的 fiber 树的初始构建而散播 - 所有的 fiber node 的 mode 属性的默认值被设置为跟 host root fiber 的上 mode属性值一样的值。

也就是说,在 react@18 中默认是「concurrent mode」。而与之相反, react@17 默认的是「sync mode」。有了这些知识铺垫,我么那就能看明白下面的代码:

...
const mode = fiber.mode;

    if ((mode & ConcurrentMode) === NoMode) {
      return SyncLane;
    }
...

在 react@17 中, fiber node 的 mode 属性值不是 ConcurrentMode(极有可能是 0,待确认),那么 react 就默认分配 SyncLane 给它。换句话说,这个 case 主要是针对 react@17 - react@17 默认是以同步渲染模式来完成界面更新的。

在 render 阶段触发了更新

上面源码中,针对这个 case 的处理方式以及原因,注释已经解释得很清楚了。也就说,如果你是在 render 阶段来触发一个更新请求的,那么 react 就会从当前的 workInProgressRootRenderLanes 众多 lane 随意选择一条 lane 给你。这么做的效果就相当于「啥都没做」。这是因为 workInProgressRootRenderLanes | newLane 还是等于 workInProgressRootRenderLanes,所以,渲染流程继续,不会被打断。

以上两个 case 都是边缘用例,不用太在意。下面要说的才是大头。

当前存在 transition 类型的更新任务

    ...
    const isTransition = requestCurrentTransition() !== NoTransition;

    if (isTransition) {
      // updates at the same priority within the same event. To do this, the
      // inputs to the algorithm must be the same.
      //
      // The trick we use is to cache the first of each of these inputs within an
      // event. Then reset the cached values once we can be sure the event is
      // over. Our heuristic for that is whenever we enter a concurrent work loop.

      if (currentEventTransitionLane === NoLane) {
        // All transitions within the same event are assigned the same lane.
        currentEventTransitionLane = claimNextTransitionLane();
      }

      return currentEventTransitionLane;
    }
    ...

从变量名我们就知道这是处理 transition 的应用场景了。首先,我们来看看,react 是如何决定是否有 transition 类型的更新任务存在的。

const NoTransition = null;
function requestCurrentTransition() {
  return ReactCurrentBatchConfig.transition;
}

那 react 是在哪里对 ReactCurrentBatchConfig.transition 赋值的呢?答案就在 startTransition()

  function startTransition(setPending, callback, options) {
    ...
    ReactCurrentBatchConfig.transition = {};
    ...

    try {
      ...
      callback();
    } finally {
      ...
    }
  }

从上面代码中的 callback 其实就是我们传递给 startTransition() 的那个实参。拿代码例子做个说明:

function App(){
    const [count, setCount] = useState(0)
    const [isPending, startTransition] = useTransition();

    const handleClick = ()=>{
        startTransition(()=>{
            setCount(c=> c+1)
        })
    }

    return (
        <button onClick={handleClick}>click to transition<button>
    )
}

那上面代码中的 ()=>{setCount(c=> c+1)} 就是 callback。从三部曲理论,我们知道,我们调用 setState(), 最终是会调用 requestUpdateLane()。所以,这里我们就看出调用 startTransition() 实际上先后发生了两件事情:

  1. 首先, 对全局变量 ReactCurrentBatchConfig.transition赋予一个空对象,标记当前存在 transition 任务;
  2. 然后,才在 requestUpdateLane() 里面去访问这个全局变量,用于判断是否当前存在 transition 类型的任务。

这是典型的标记法。但是我纳闷的是,为什么要赋值为一个空对象,改一个可读性高一点的名字,然后赋予 boolean 值不是更直观一点吗?比如下面这样:

// 标记的时候
let hasPendingTransitionTasks = true;

// 判断的时候
const isTransition = hasPendingTransitionTasks === true;

好了,到这里,我们知道 react 是如何知道是否有 transition 任务存在的了。那么,接下来 react 是如何产生所需要的 lane 呢?

简单来说,react 会从 16 条 transition lane 里面依次取一条 lane:第一个 transition 任务就取 TransitionLane1,第二个 transition 任务就取 TransitionLane2...以此类推。当取到 TransitionLane16之后,下一个 transition 任务又从TransitionLane1 开始。如此往复循环。

上面描述的,其实就是 claimNextTransitionLane() 要做的事情。

在 react 内部方法中触发了更新

当当前的更新任务没有命中上面的三种情况的时候,react 开始去读取 全局变量currentUpdatePriority 的值,让这个值来决定所要返回的 lane。

毫不例外,我们要追溯的是 currentUpdatePriority 的值是怎么来的。

xxx 作为 react@18.2.0/packages/react-reconciler/src/ReactEventPriorities.old.js 模块里面的一个全局变量,改变它的值的只有两个方法:

  • setCurrentUpdatePriority()
  • runWithPriority()

这里,我们主要研究的是setCurrentUpdatePriority()。通过全局搜索,你不难发现,调用它的地方大多是 UI 事件相关的地方。下面就罗列几个地方给你看看:

  • dispatchDiscreteEvent()
  • dispatchContinuousEvent()

这两个方法都是在一个叫 createEventListenerWrapperWithPriority 的函数中被调用:

function createEventListenerWrapperWithPriority(
  targetContainer,
  domEventName,
  eventSystemFlags
) {
  const eventPriority = getEventPriority(domEventName);
  let listenerWrapper;

  switch (eventPriority) {
    case DiscreteEventPriority:
      listenerWrapper = dispatchDiscreteEvent;
      break;

    case ContinuousEventPriority:
      listenerWrapper = dispatchContinuousEvent;
      break;

    case DefaultEventPriority:
    default:
      listenerWrapper = dispatchEvent;
      break;
  }

  return listenerWrapper.bind(
    null,
    domEventName,
    eventSystemFlags,
    targetContainer
  );
}

这个函数要实现的功能在它的函数名称里面早就说明清楚了。它职责就是为不同类型的事件提前内置了一个优先级

这里有两个要点要说一下:

  • 如何内置?;
  • 如何提前?;

如何内置?对于离散型事件和连续型事件,react 会通过调用 setCurrentUpdatePriority() 这个方法来在dispatchDiscreteEvent函数或者dispatchContinuousEvent函数设置全局变量 currentUpdatePriority:

  function dispatchDiscreteEvent(
    domEventName,
    eventSystemFlags,
    container,
    nativeEvent
  ) {
    ....

    try {
      setCurrentUpdatePriority(ContinuousEventPriority);
      ...
    } finally {
     ...
    }
  }

   function dispatchContinuousEvent(
    domEventName,
    eventSystemFlags,
    container,
    nativeEvent
  ) {
    ....

    try {
      setCurrentUpdatePriority(ContinuousEventPriority);
      ...
    } finally {
     ...
    }
  }

对于,除了离散型事件和连续型事件之外的事件呢?react 并没有提前内置一个优先级,而是合并到后面要讲述的那个情况来处理。

如何提前?这个提前是指相对于 requestUpdateLane()函数调用的时机的提前。我们不妨看看下面这种图。这是对点击事件的合成事件处理器进行调用追踪(console.trace())的结果:

2023 年,你是时候要掌握 react 的并发渲染了(4) - lane 模型(1)

通过这张截图,我们看到,react 在调用我们的合成事件处理器之前很早就在全局设置好了更新请求的优先级了。

其实,合成事件处理器只是这里提到的「react 内部方法」的一种。凡是在 react 源码里面的,react 团队能控制得住的都是方法都是 『react 内部方法』(全局搜索setCurrentUpdatePriority(字符串,你会发现有 19 处调用),这正如注释上面所说的:

Updates originating inside certain React methods, like flushSync, have their priority set by tracking it with a context variable.

在内部源码中,react 是通过全局变量 currentUpdatePriority 来提前设置当前更新所对应的优先级的。这里面当然是包括我们最关心的 react 合成事件处理器(比如,click 事件,change 事件等等)这种类型的方法。

在 react 外部方法中触发了更新

既然上面提到了「react 内部方法」这个概念,那从「概念是相对性的」这个原理出发,我们就知道还有个「react 外部方法」。那什么是 react 外部事件呢?在讨论优先级这个上下文,从源码的实现来看,凡是 react 控制不住的代码(比如,无法提前注入一些代码的等)。比如说,常见的:promise.then(callabck)setTimeout(callback) 中的 callback 就是这里的『外部方法』

事实上,react 团队是把『如何给外部方法所触发的更新指定优先级』这件事情交给了 react host(也就是 react renderer)来决定。具体来说,就是所有的 react host 需要实现getCurrentEventPriority() 这个方法。你在 react 代码库中全局搜搜一下: function getCurrentEventPriority 字符串,我们就能清楚地看到下面 host 对 getCurrentEventPriority() 这个方法的实现:

  • ReactARTHost
  • ReactDOMHost
  • ReactFabricHost
  • ReactNativeHost
  • ReactTestHost

2023 年,你是时候要掌握 react 的并发渲染了(4) - lane 模型(1)

现在,我们只关注 ReactDOM 这个 host。它对 getCurrentEventPriority() 是这样的:

export function getCurrentEventPriority(): * {
  const currentEvent = window.event;
  if (currentEvent === undefined) {
    return DefaultEventPriority;
  }
  return getEventPriority(currentEvent.type);
}

可以看出,react 会尝试去访问 window.event 这个 MDN 不推荐的属性,对于上面提到的类似于 promise 或者 setTimeout 等 web API,window.event 肯定是 undefined。所以,这种情况下,requestUpdateLane() 返回的 lane 就是 DefaultLane

在上一个 case 的研究中,我们也指出了:「除了离散型事件和连续型事件之外的事件呢?react 并没有提前内置一个优先级,而是合并到后面要讲述的那个情况来处理」。这些之外的事件,在上个 case 中调用getCurrentUpdatePriority() 返回的是 NoLane(因为这些 DOM 事件并没有提前去设置 currentUpdatePriority 这个全局变量,所以,getCurrentUpdatePriority() 返回的是 currentUpdatePriority 的默认值 - NoLane),所以它就进入了当前这个 case 中处理。

在这个 case 中,根据 ReactDOM 对 getCurrentEventPriority() 的实现,虽然,window.event属性值可能有值,但是,当它进入 getEventPriority(currentEvent.type) 的时候,因为它们不是离散型事件或者连续型事件,所以最终还是回退为默认值: DefaultLane。这类型的方法有多媒体类型的事件处理器,比如图片资源的 loaderror 事件等。

综上所述,无论是在 react 外部方法触发的更新还是说在非离散型事件和非连续型事件处理器中触发的更新,requestUpdateLane() 一律返回 DefaultLane

小结

通过上面对这五种情况的研究,我们知道 react 是这样生产 lane 的:

  • 如果当前是同步渲染模式,返回 SyncLane
  • 如果是在 render 阶段触发了更新请求,不会生产一个新 lane;
  • 如果当前的更新请求是 transition 类型的更新请求,则从 16条 transition lane 中选择一条返回;
  • 如果当前是在离散型事件触发了更新请求,返回 SyncLane;
  • 如果当前是在连续型事件触发了更新请求,返回 InputContinuousLane;
  • 如果当前是在其他 DOM 事件(排除「离散型事件」和「连续型事件」)中触发了更新请求,返回 DefaultLane;
  • 如果当前是在 react 外部方法中触发了更新请求,返回 DefaultLane;

鉴于 react 对这些「常量 lane 的赋值」和「lane 的值越小,则代表优先级越高」的定性,那么从优先级比较的角度出发,我们可以得到这样的结论:

离散型事件中触发的更新请求的优先级 >
连续型事件中触发的更新请求的优先级 >
其他 DOM 事件中触发的更新请求的优先级 =
react 外部方法中触发的更新请求的优先级 >
startTransition 中触发的更新请求的优先级

2. 分发

生产出来的 lane 将会分发出去。所谓「分发」,就是 lane 既作为标记(标志着有更新任务)也作为优先级存储在某个地方。沿着界面更新流程的方向去看,我们可以看到 lane 将会以下面的顺序进行分发出去:

  1. 分配给 uddate 对象
  2. append 到 root.pendingLanes
  3. 分配给 fiber node

1. 分配给 update 对象

产生出来的 lane 会马上分配给接下来创建的 update 对象。这一点,无论是 class component 的setState()的实现还是 function component 的 dispatch() 的实现都是看得一清二楚的:

function createUpdate(eventTime, lane) {
    const update = {
      eventTime,
      lane,
      tag: UpdateState,
      payload: null,
      callback: null,
      next: null,
    };
    return update;
  }

const classComponentUpdater = {
    isMounted,
    enqueueSetState(inst, payload, callback) {
        ...
        const lane = requestUpdateLane(fiber);
        const update = createUpdate(eventTime, lane);
        update.payload = payload;
        ...
    }
}
function dispatchSetState(fiber, queue, action) {
    const lane = requestUpdateLane(fiber);
    const update = {
      lane,
      action,
      hasEagerState: false,
      eagerState: null,
      next: null,
    };
    ...
}

虽然,我没有穷尽源码所有创建 update 对象地方去做验证,但是,我们不妨大胆一点去设想:每一个 update 对象都会被「贴上」lane 这个标签。

2. append 到 root.pendingLanes - markRootUpdated()

一个更新请求对应一个更新对象,react 不会拉掉任何一个用户表露过的更新界面的意愿。用什么来记录这些意愿呢?那就是 root.pendingLanes。上面生产出来的 lane 会立即被追加到 fiber root node 的 pendingLanes 属性上,用来表达「当前存在某种优先级的任务」。这个动作会在请求调度的入口函数scheduleUpdateOnFiber() 里面发生:

function scheduleUpdateOnFiber(root, fiber, lane, eventTime) {
   markRootUpdated(root, lane, eventTime);
   ...
}

markRootUpdated() 实现里面,最核心的代码只有一行:

function markRootUpdated(root, updateLane, eventTime) {
  root.pendingLanes |= updateLane;
   ...
}

可以看出,这行代码做的就是我们所说的「上面生产出来的 lane 会立即被追加到 fiber root node 的 pendingLanes 属性上,用来表达“当前存在某种优先级的任务”」。

注意,scheduleUpdateOnFiber() 发生在真正开启一个界面更新流程之前。正是这一步骤为后面 「react 从众多任务里面选择一种类型的任务去执行」奠定了基础。也就是说,用户触发多个更新请求的时候,react 会这么做:

  1. 首先,先把用户的更新意愿以及任务的优先级登记在册;
  2. 然后,再决定执行哪个任务。

3. 分配给 fiber node - markUpdateLaneFromFiberToRoot()

为什么不把本小节跟上一节合并一起讲?因为 root(fiber root node)不是真正的 fiber node。

除了 root.render(应用根组件) 这种情况,所有的更新请求都是从某个组件里面发出来。把 lane 分配给 fiber node, 一来是用来表示「该 fiber node 是所触发更新的源头」,而来表示「该更新请求具有某种优先级」。

分配给 fiber node,更准确地说是分配给触发更新的那个源头组件之上的所有 fiber node。这是一个往上追溯的过程。而这个「分配」又可以拆分为两个小动作:

  • 把 lane 追加到 fiber.lanes 属性上;
  • 把 lane 追加到 fiber.childLanes 属性上;

react 会在进入真正的界面更新流程之前做这个大动作。简单来说,是markUpdateLaneFromFiberToRoot()方法实现了这个大动作。具体展开来讲就是,react 在进入一个全新的界面更新流程之前必定会调用一个叫做prepareFreshStack() 的函数。顾名思义,这个函数就是在界面更新流程之前做一些 clean up 的工作,好让流程「从新开始」。换个代码的角度来阐述,就是对一大堆的全局变量进行赋初始值的工作。而 prepareFreshStack() 函数在完成一大堆全局变量的赋值工作后,最后做的一件事情是:finishQueueingConcurrentUpdates()

在上一个渲染周期的 render 阶段所触发的更新请求会被暂时记录在一个叫做 concurrentQueues 的全局变量中。在下一个渲染周期开始之前,react 就是调用finishQueueingConcurrentUpdates() 来消化这些存储在 concurrentQueues 数组中的 update 对象。怎么消费呢?那就是遍历 concurrentQueues 数组,对数组中每一组记录(四个元素为一组记录)进行以下的操作:

  • 第一,把所有属于同一个 fiber node 的 update 对象连接到一块,组成一个链表结构 - update queue;
  • 第二,调用 markUpdateLaneFromFiberToRoot()

沿着以下的调用栈:

prepareFreshStack() -> 
finishQueueingConcurrentUpdates() ->
markUpdateLaneFromFiberToRoot()

我们终于来到了本小节的主角 - markUpdateLaneFromFiberToRoot()

  function markUpdateLaneFromFiberToRoot(sourceFiber, update, lane) {
    // Update the source fiber's lanes
    sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
    let alternate = sourceFiber.alternate;

    if (alternate !== null) {
      alternate.lanes = mergeLanes(alternate.lanes, lane);
    } // Walk the parent path to the root and update the child lanes.

    let isHidden = false;
    let parent = sourceFiber.return;
    let node = sourceFiber;

    while (parent !== null) {
      parent.childLanes = mergeLanes(parent.childLanes, lane);
      alternate = parent.alternate;

      if (alternate !== null) {
        alternate.childLanes = mergeLanes(alternate.childLanes, lane);
      }

      if (parent.tag === OffscreenComponent) {
        ... // 省略的这部分代码是关于即将来临的 <Offscreen> 组件。它不是本文的主题相关的东西。
      }

      node = parent;
      parent = parent.return;
    }

    // 省略的这部分代码是关于即将来临的 <Offscreen> 组件。它不是本文的主题相关的东西。
    ...
  }

我们再省略了上面流程对 alternate 的处理,于是乎,我们的代码就简化为:

  function markUpdateLaneFromFiberToRoot(sourceFiber, update, lane) {

    // 1. Update the source fiber's lanes
    sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
    ...
    let parent = sourceFiber.return;
    let node = sourceFiber;

    // 2. Walk the parent path to the root and update the child lanes.
    while (parent !== null) {
      parent.childLanes = mergeLanes(parent.childLanes, lane);

      ...
      node = parent;
      parent = parent.return;
    }
    ...
  }

到这里,markUpdateLaneFromFiberToRoot() 函数到底做了什么就不用我说了吧,一目了然啊。不过,这里还是总结一下:

  1. 把当前生产出来的 lane 追加到触发更新的源头 fiber 节点的 lanes 属性上;
  2. 从当前的源头 fiber 节点开始,沿着 fiber tree 一直往上追溯到 host root fiber。给所有途经的 fiber node 的 childLanes 添加标签 - 追加上当前生产的 lane。

也许你会很好奇,这么做为了啥?这么做是为了,找到真正需要渲染的那棵子 fiber tree。因为,你也知道,我们触发一个更新,react 并不会渲染整棵完整组件树。react 只会渲染以你调用了「setState()」那个组件为根组件的子组件树。

不好懂?鲨叔为你娓娓道来。假设我们有下面的组件树:

<A>
  <B>
    <C>
      <D />
    </C>
  </B>
  <E>
    <F />
  </E>
</A>

当前,我在 <C> 组件里面触发了一个更新。那么经过 markUpdateLaneFromFiberToRoot() 的作用后,我们将会得到这样的一颗带有 lane 标记的 fiber 树:

2023 年,你是时候要掌握 react 的并发渲染了(4) - lane 模型(1)

注意,圆括号的第一个值是 lane。并且,我假设在调用 ``markUpdateLaneFromFiberToRoot()之前所有的 fiber node 的laneschildLanes的属性值都是为NoLanes`(即0)。

3. 消费 - 用于什么样的用途

鉴于文章篇幅问题,「消费」小节放在下篇文章《2023 年,你是时候要掌握 react 的并发渲染了(4) - lane 模型(2)》中去讲述,敬请期待。