Bailout策略的原理

145 阅读9分钟

本章节主要是讲解react 性能优化中的一个bailout策略,下一节我们会讲解另外一个策略eagerState。

  1. bailout策略:减少不必要的子组件render
  2. eagerState策略:不必要的更新,没必要开启后续调度流程

性能优化的条件

在编写React的代码时候,一般不需要我们主动去考虑一些性能优化点,当出现性能瓶颈的时候,才会去分析和查找性能优化点。

我们知道React每次更新都是从根节点开始的,也就是说,React会从根调和直到叶子结点。

性能优化的一般思路都是讲【变化的部分】与 【不变的部分】进行分离,这样就可以明确的知道,那些子节点是需要重新渲染的。 命中「性能优化」的组件可以不通过reconcile生成wip.child,而是直接复用上次更新生成的wip.child。

注意:

命中性能优化的组件的子组件(而不是他本身)不需要render

在React中,变化的部分主要是分为以下几点:

  • State
  • Props
  • Context

如果这些状态没有发生变化的话,我们就可以不用再次重新渲染已经存在的子组件,比如下图中的白色的节点就是不需要重新渲染的。

image.png

Bailout的介绍

什么是bailout

Bailout 是 React Fiber 架构的一部分。它指的是在 reconciliation(协调)阶段中,React 根据状态(state)、属性(props)和上下文(context)的变化,判断是否需要更新组件。

如果 React 判断组件的输出不需要变化,跳过该组件的子树的 reconciliation 和渲染过程,从而提升性能。

想象你在开一家餐馆,每次有客人进来点餐时,服务员都会去厨房告诉厨师。如果客人点的餐没有变化,服务员实际上不需要再去告诉厨师,因为厨师已经知道客人点了什么。

在 React 中,父组件就像服务员,子组件就像厨师。每次父组件重新渲染时,子组件也会重新渲染。如果父组件的 props 或 state 没有变化,子组件不需要重新渲染,这就是 bailout 策略的核心思想。

命中bailout策略

命中 「性能优化」bailout策略)的组件可以不通过reconcile生成wip.child,而是直接复用上次更新生成的wip.child

Bailout的策略存在于beginWork中,我们需要判断变化的部分是否发生了变化,如果没有发生变化,我们就不需要更新。

bailout四要素:

  1. props不变

比较props变化是通过 「全等比较」,使用React.memo后会变为 「浅比较」

  1. state不变

两种情况可能造成state不变:

  • 不存在update
  • 存在update,但计算得出的state没变化
  1. context不变
  2. type不变

如果Div变为P,返回值肯定变了。

下面的图片是整体的bailout流程 image.png

fiber.lanes标记

我们之前讲解flag副作用的标识的时候,有一个subtreeFlags标记子树中所有副作用。为了判断 「bailout四要素」 中的 「state不变」,需要判断当前fiber是否存在未执行的update。 我们需要给每一个fiber添加2个属性。

  1. lanes: 保存当前fiberNode「所有未执行更新对应的lane」
  2. childLanes: 保存当前fiberNode的子节点中所有的未执行的更新

实际例子

export default function App() {
    const [num, update] = useState(0);
    console.log("App render ", num);
    return (
       <div onClick={() => {update(1)}}>
        <Cpn />
      </div>
   );
}

  
function Cpn() {
    console.log("cpn render");
    return <div>cpn</div>;
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

这是一个简单的react的渲染代码,包裹一个父组件和一个子组件。在点击的过程中,并没有改变子组件的四要素。按照bailout策略,当命中的时候,Cpn应该不会再进行渲染。

初始化: image.png

第一次点击: image.png

第二次点击: image.png

第三次点击:就什么都不会执行了。

这里从初始化到第二次点击的时候,cpn render不执行了,就是命中了bailout的策略。我们看到了当App命中了bailout的时候,它本身还是会渲染,但是子组件就不会进行渲染了。

第三次是命中了另外一个策略eagerState

代码实现

基于我们之前实现的非suspense的情况下代码进行改造,我们这次实现的bailout策略不兼容suspense的情况。

beginWork部分

我们新增一个标识didReceiveUpdate默认我们设置为false, 就是默认命中bailout策略,不接受更新。

// 是否能命中bailout
let didReceiveUpdate = false; //(默认命中bailout策略,不接受更新)

export function markWipReceivedUpdate() {
  didReceiveUpdate = true; // 接受更新,没有命中bailout
}

由于性能优化是针对更新的部分,对应挂载mount阶段是不存在的,所以我们要判断条件在current !== null

export const beginWork = (wip: FiberNode, renderLane: Lane) => {
  // 四要素 -> 判断是否变化 (props state context type)
  didReceiveUpdate = false;
  const current = wip.alternate;
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = wip.pendingProps;
    // props 和 type
    if (oldProps !== newProps || current.type !== wip.type) {
      didReceiveUpdate = true; // 不能命中bailout
    } else {
      console.warn("命中bailout --- 满足props 和 type");
      // state context比较
      const hasScheduledStateOrContext = checkScheduledUpdateOrContext(
        current,
        renderLane
      );
      if (!hasScheduledStateOrContext) {
        // 四要素中的 state / context 不变
        // 命中bailout
        didReceiveUpdate = false;

        // context的入栈、出栈
        switch (wip.tag) {
          case ContextProvider:
            const newValue = wip.memoizedProps.value;
            const context = wip.type._context;
            pushProvider(context, newValue);
            break;
          // TODO: Suspense
          default:
            break;
        }

        return bailoutOnAlreadyFinishedWork(wip, renderLane);
      }
    }
  }

  /**
   * beginWork消费update  update -> state
   */
  wip.lanes = NoLanes;
  return null;
};

beginWork的改动主要是针对变化的部分的判断,看看是否存在属性的变动

  1. 设置默认进入bailout策略,并针对更新的情况
  2. 对比props和type: 我们获取新旧的2个props, 进行对比看看是否相等,如果不相等,就不进入bailout策略,继续协调子组件部分
  3. 对比State和Context: 如果props和type相等的话,我们再看看statecontext是否相等。 主要是通过checkScheduledUpdateOrContext检查是否存在更新的lane
  4. 获取当前的fiber的更新lane,检查本次更新的lane是否存在,如果存在就说明state或者Context发生了变化,不进入bailout策略
/**
 * renderLane 代表本次更新对应的优先级
 * updateLanes 代表当前fiber所有未执行的update对应的更新的优先级
 *
 * 所以这行代码的意思是: 
 当前这个fiber中所有未执行的update对应更新的优先级中是否包含了本次更新的优先级,
 也就是本次更新当前这个fiber是否有状态会变化
 * @param current
 * @param renderLane
 */
function checkScheduledUpdateOrContext(
  current: FiberNode,
  renderLane: Lane
): boolean {
  const updateLanes = current.lanes;

  if (includeSomeLanes(updateLanes, renderLane)) {
    // 本次更新存在的优先级,在当前的fiber中存在
    return true;
  }
  return false;
}
  1. 如果命中了bailout策略,即checkScheduledUpdateOrContext的返回值为false。直接进入bailoutOnAlreadyFinishedWork的部分,并终止向下协调的逻辑
// 命中bailout
didReceiveUpdate = false;
return bailoutOnAlreadyFinishedWork(wip, renderLane);
  1. 在bailoutOnAlreadyFinishedWork中,我们主要是判断bailout的范围,看看是所有的子组件都不更新还是只是当前的子组件。cloneChildFibers主要是基于当前fiber.child,复用一个新的fiber
/**
 * 复用上一次的结果,不进行本次更新
 * @param wip
 * @param renderLane
 */
function bailoutOnAlreadyFinishedWork(wip: FiberNode, renderLane: Lane) {
  // 1. 检查优化程度
  /**
   * 如果这个检查返回false,
   * 说明当前fiber的子节点不包含任何应该在当前render lane更新的内容。这种情况下,
   * 这个fiber subtree(该节点及其所有子节点)在当前渲染过程中可以被跳过(bailout),
   * 因为没有相关的更新需要应用于这部分的DOM。
   * 因此,通过返回null来中止当前fiber的工作。
   */
  if (!includeSomeLanes(wip.childLanes, renderLane)) {
    // 检查整个子树
    if (__DEV__) {
      console.warn("bailout整课子树", wip);
    }
    return null;
  }
  if (__DEV__) {
    console.warn("bailout一个fiber", wip);
  }

  cloneChildFibers(wip);
  return wip.child;
}

复用子fiber部分

export function cloneChildFibers(wip: FiberNode) {
  // child sibling
  if (wip.child === null) {
    return;
  }
  let currentChild = wip.child;
  let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
  wip.child = newChild;
  newChild.return = wip;

  while (currentChild.sibling !== null) {
    currentChild = currentChild.sibling;
    newChild = newChild.sibling = createWorkInProgress(
      newChild,
      newChild.pendingProps
    );
    newChild.return = wip;
  }
}

复用子组件的部分主要是注意下面一句。如果我们没有命中bailout策略,重新创建createWorkInProgress的时候传递的pendingProps是一个新对象。但是命中了后,传递的是同一个引用。

  let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
  
  // beginwork当前组件结束后。在performUnitOfWork中
  // 工作完成,需要将pendingProps 复制给 已经渲染的props
  fiber.memoizedProps = fiber.pendingProps;

这就保证了我们在beginWork进行判断的新旧props判断的时候。在调和到当前子组件的时候,判断为相等,从而不进行渲染操作。这就保证了oldProps === newProps

const oldProps = current.memoizedProps;
const newProps = wip.pendingProps;

根节点和函数组件优化

根组件

除了每次进入beginWork的开始的时候进行是否进入bailout优化,还有一种情况,也可以进行优化处理,比如我们的根组件<App />组件,每次进入的时候props是一个新的空对象,但是引用不同,正常情况下进入不了bailout的判断,但是实际他本身并不需要进行额外的调和。

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

所以在进入beginWork后,我们还可以针对根组件做一个判断,比较前后2次挂在的<App />组件的内容是否发生了变化

/**
 * hostRoot的beginWork工作流程
 * 1. 计算状态的最新值  2. 创造子fiberNode
 * @param {FiberNode} wip
 */
function updateHostRoot(wip: FiberNode, renderLane: Lane) {
  // *******

  const prevChildren = wip.memoizedState; // 计算前的值
  const { memoizedState } = processUpdateQueue(baseState, pending, renderLane); // 计算最新状态
  wip.memoizedState = memoizedState; // 其实就是传入的element

  // ******

  const nextChildren = wip.memoizedState; // 子对应的ReactElement
  if (prevChildren === nextChildren) { 前后都是<App /> 内容没有变化
    // 没有变化
    return bailoutOnAlreadyFinishedWork(wip, renderLane);
  }
  
  // ******
}

函数组件

同理在函数组件的fiber调和过程中,可能存在虽然有状态的变更,但是每次变更的值都相同,这样我们也可以进行优化。

比如这个执行更新的逻辑, 在update执行的时候,我们需要判断前后2次state的值是否相等,如果相等就是说明此次更新是无效的。

   <div onClick={() => {update(1)}}>

beginWork进入到函数节点判断的时候,我们根据didReceiveUpdate的标识,可以知道本次是否需要进行调和。所有我们再进入renderWithHooks的逻辑后,可以根据state的值前后对比,看看之后是否满足bailout策略。这样虽然本身自己还是要被执行,但是子组件可以不再进行渲染。

/**
 * 函数组件的beginWork
 * @param wip
 */
function updateFunctionComponent(wip: FiberNode, renderLane: Lane) {
  const nextChildren = renderWithHooks(wip, renderLane);

  const current = wip.alternate;
  if (current !== null && !didReceiveUpdate) {
    // 命中bailout策略
    bailOutHook(wip, renderLane);
    return bailoutOnAlreadyFinishedWork(wip, renderLane);
  }
  reconcileChildren(wip, nextChildren);
  return wip.child;
}
renderWithHooks的部分逻辑

在进入renderWithHooks的时候,实际上就会调用函数组件的本身。

从之前的章节中,我们晓得update内部实际执行的是updateState,它是更新阶段通过useState返回用于实际更新state的函数。

function updateState<State>(): [State, Dispatch<State>] {
  // 找到当前useState对应的hook数据
  const hook = updateWorkInProgressHook();
  // 计算新的state逻辑
  // *******

  if (baseQueue !== null) {
    const prevState = hook.memoizedState; // 更新前的状态

    const {
      memoizedState,
      baseQueue: newBaseQueue,
      baseState: newBaseState,
    } = processUpdateQueue(baseState, baseQueue, renderLane, (update) => {
       // *******
    });

    if (!Object.is(prevState, memoizedState)) {
      // 更新前后有变化,没有命中bailout
      markWipReceivedUpdate();
    }
    // *******
  }

  return [hook.memoizedState, queue.dispatch as Dispatch<State>];
}

如果值前后发生了变化,我们就要标记didReceiveUpdate = true,跳过函数组件的bailout策略。

三次点击整体流程图