由一个面试题来看React更新流程

388 阅读7分钟

面试题

前段时间看到了这样一道面试题:

import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom/client";

ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

function App() {
  const [count, setCount] = useState(-1);
  useEffect(() => {
    setCount(0);
  });
  // 打印几次?
  console.log("app render", count);

  return <p>hello world {count}</p>;
}

我觉得应该是两次:
第一次 mount,打印一次。
然后调度flushPassiveEffect, 进 useEffect, 由 setCount 发起一次更新调度, 打印第二次。
继续调度flushPassiveEffectsetCount的值此时还是0, 和memorizedState一致, 那应该bailout了。

然而事实是三次...(╯°□°)╯︵ ┻━┻

这里有一篇关于这个问题的解释: useState更新相同的State,函数组件执行2次

大意是说React使用双fiber树(A/B树), 在每次渲染中他们交替扮演着current/WIP树的角色, 两棵树上每个节点通过alternate连接。 每次更新都是在WIP树操作, 更新完两棵树交换,此次的WIP树变成了current, 而current则在下次更新时作为WIP, 由此可见双树间的状态可能是不一致的。

上面代码中两次setState(0), 第一次修改了其中一棵树的状态,第二次则是为了同步另一棵树的状态才会执行渲染。 这样其实已经可以解释这道面试题了, 但是我还想再挖挖具体的实现细节, 来解释一下下面两个疑问:

  1. 对于一次和现存state值相同的setState, React内部有几次bail out的机会呢?
  2. 每次bail out都是在什么地方, bail out的机制又是什么?

顺便再梳理一下React的更新流程。

关于useState

React hook的原理很多文章都介绍过,这里不再赘述,我们简单聊一下和后面调试相关的部分。

render阶段每次执行函数组件时,遇到Hook函数会根据当前的状态来选择具体调用的HooksDispatcher, 具体到useState, mount时调用mountState, update时调用updateState

// file: ReactFiberHooks.new.js
const HooksDispatcherOnMount = {
  // ...
  useState: mountState,
  // ...
};

const HooksDispatcherOnUpdate: Dispatcher = {
  // ...
  useState: updateState,
  // ...
};
  • mountState主要是初始化hook对象,挂载到fiber的memoizedState属性上, 创建hook对象的更新队列queue, 初始化state值作为hook对象的memoizedState属性。

    此外绑定当前fiber以及hook对象的更新队列到dispatchSetState函数, 最后返回初始state和绑定参数后的dispatchSetState

    // file: ReactFiberHooks.new.js
    function mountState(initialState){
      // 生成hook对象, 赋值给当前fiber.memoizedState
      const hook = mountWorkInProgressHook();
    
      // 获取初始state值
      if (typeof initialState === 'function') {
        initialState = initialState();
      }
      hook.memoizedState = hook.baseState = initialState;
    
      //生成更新队列, 赋值给hook.queue
      const queue = {...};
      hook.queue = queue;
    
      const dispatch = queue.dispatch = dispatchSetState.bind(
        null,
        currentlyRenderingFiber,
        queue,
      );
      return [hook.memoizedState, dispatch];
    }
    
  • updateState实际调用的是updateReducer, 基本上就是获取当前hook对象, 根据hook.queue计算新的state值, 返回这个值和hook.queue.dispatch

    // file: ReactFiberHooks.new.js
    // currentHook是current fiber上的hook对象
    // workInProgressHook是将要被赋值给wip fiber.memoizedState上的hook对象
    let currentHook = null;
    let workInProgressHook = null;
    function updateReducer(reducer, initialArg, init) {
      // 设置 currentHook,workInProgressHook, 返回 workInProgressHook
      const hook = updateWorkInProgressHook();
      const queue = hook.queue;
      const current = currentHook;
    
      // ...
      // 根据queue计算一个新的state, 赋值给hook.memoizedState
      // ...
    
      // 这里的dispatch是不变的
      const dispatch = queue.dispatch;
      return [hook.memoizedState, dispatch];
    }
    

    从上面的代码里可以看出, setState或者说源码中的dispatchSetState参数里的fiber在每次更新后是不变的, 结合上面说到的React在更新时使用双缓冲fiber树,dispatchSetState里的fiber将会一直是第一次mount时创建的A树上的fiber节点,理解这点对接下来的调试过程很重要。

开始调试

dispatchSetState打上断点,运行第一次:

Screenshot 2022-05-26 113945.png 此次dispatchSetState执行是在mount完成后,由flushPassiveEffect发起执行了useEffect内的setState(0)

根据上一节的分析,这里的fiber是在mount时绑定的,A树上的fiber节点,我们称之为FiberA, 此时因为mount已经执行完了, 所以FiberA上的lanes为0, 并且是紧接着mount之后的flushPassiveEffect,还没有B树,所以FiberA的alternate也为null, 满足进入eagerState计算分支的条件。

在这个分支内,我们发现了第一个可能bail out的点:这里的eagerState如果和currentState一致,会直接return,不去安排更新。

不过在这个面试题的例子中, 此处eagerState0,而currentState-1,不能满足bail out的条件, 因此需要安排更新。

接下来进scheduleUpdateOnFiber, 第一个需要注意的点是,FiberA的lanes在这里会合并此次更新的lane:

Screenshot 2022-05-26 115642.png

此次更新安排是在ensureRootIsScheduled,通过scheduleCallback进行的, 这个函数实际是调用的schduler库的scheduleCallback函数,基本上就是以某个优先级来安排某个回调, 跟我们这次调试的目的关系不大, 这里就跳过了, 在我们的例子中, 只要知道schduler将以NormalPriority的优先级安排performConcurrentWorkOnRoot回调就好:

Screenshot 2022-05-26 120718.png

我们给performConcurrentWorkOnRoot打个断点, 然后恢复运行, 再次pause时可以看看现在的call stack:

Screenshot 2022-05-26 121517.png

接下来就是常规的更新了, 我们给updateFunctionComponent打上断点, 直接跳转到这里, 此时current为FiberA,current.alternate, 也就是workInProgress为FiberB,在执行函数组件之前可以看到, FiberA/B的hook对象是一样的。(FiberB上的hook对象就是从A上复制的)

Screenshot 2022-05-26 144936.png

在执行了renderWithHooks之后, FiberB上的Hook对象被更新了, FiberA的则保持不变:

Screenshot 2022-05-26 145557.png

接着向下可以看到第二个bail out的地方:

Screenshot 2022-05-26 145930.png

这里 didReceiveUpdate 是一个全局变量, 用于标识本次render, 当前fiber是否存在更新。

关于这次bailout下面会再讲到, 这里跟随调试的进度, didReceiveUpdatetrue, 不满足bail out的条件, 所以继续向下执行。

到这次更新渲染完成,也即第二次打印,接下来就是关键了,首先还是由flushPassiveEffect发起执行useEffect内的setState(0), 再次进入 dispatchSetState, 此时current指向B树, 但scope内的Fiber依然是FiberA, 这点可以通过fiber.memoizedState看出: Screenshot 2022-05-26 145931.png

并且因为上一轮完成更新的是FiberB, FiberA上之前在安排更新时merge的update lanes没变, 那么这次就不会进入dispatchSetState函数的那个计算eager state的分支, 也就更加不会bail out。

// file: ReactFiberHooks.new.js
function dispatchSetState(fiber, queue, action) {
  // ...
  // fiber.lanes此时还是上一次scheduleUpdateOnFiber时merge的, 不是NoLanes, 不满足条件
  if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
    }
  // ...
}

既然不会bail out, 接下来就是又一轮的render。

正确答案是打印三次, 那么这就是最后一次render。我们需要追踪的深入一些,看看React具体是如何bail out下一次render的。

还是直接跳到updateFunctionComponent,这次我们进入renderWithHooks, 在一系列初始化工作后,开始执行函数组件。

首先进useState, 之前说过,更新阶段的useState实际是调用updateReducer, 上面贴过updateReducer的源码,现在我们主要关注这一部分: Screenshot 2022-05-26 145932.png 可以看到, 这次render, hook对象上的memoizedState和scope内的newState是相等的, 也就是计算出的新state相比之前的没有变化,这里就跳过了markWorkInProgressReceivedUpdate, 这个函数是做什么的呢?直接看源码:

// file: ReactFiberBeginWork.new.js
export function markWorkInProgressReceivedUpdate() {
  didReceiveUpdate = true;
}

就是设置全局变量didReceiveUpdate, 我们上面说过,updateFunctionComponent函数在最后会通过这个变量来判断是否bail out,也就是第二个bail out的地方。

接下来是useEffect,最终执行的是updateEffectImpl, 在这里会把PassiveEffectFlag merge到current fiber.flags上,这个flags会影响React收集fiber上的passive effect: Screenshot 2022-05-26 145933.png 接下来就是第三次打印,之后renderWithHooks退栈,返回updateFunctionComponent函数, 这一次因为前面didReceiveUpdate的设置被跳过了, 进入bail out分支, 首先是bailoutHooks函数:

// file: ReactFiberHooks.new.js
export function bailoutHooks(current,workInProgress,lanes) {
  workInProgress.updateQueue = current.updateQueue;
  // 这里移除了PassiveEffect和UpdateEffect两个flags
  workInProgress.flags &= ~(PassiveEffect | UpdateEffect);
  // 清除这次更新的lanes
  current.lanes = removeLanes(current.lanes, lanes);
}

这个函数主要是做一些清理工作, 我关注的是他移除了PassiveEffect和UpdateEffect两个flags, 也就是说这次的useEffect产生的effect函数不会被收集, 更不会被执行, 所以其实压根就不会有第三次setState, 自然就不会有下一次render和第四次打印了。

到这其实这个面试题以及我后来的两个问题都有答案了, 但还是让我们继续往下完成这次调试吧, 接下来是bailoutOnAlreadyFinishedWork函数:

// file: ReactFiberBeginWork.new.js

function bailoutOnAlreadyFinishedWork(current,workInProgress,renderLanes){
  // ...
  // 检查当前fiber子树是否有需要更新的
  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
    // 无, 直接跳过整棵子树
    return null;
  }
  // 子树上还有更新, 复制子节点到wip fiber并返回
  cloneChildFibers(current, workInProgress);
  return workInProgress.child;
}

就是对两种情况的判断, 如果子树没有需要更新的就bail out, 反之就从子节点开始继续render阶段的工作。

结束

其实把面试题改一改, setState(0)放onClick handler里, 同一个state也会渲染两次, 这个useEffect就是来迷惑人的😂。