React 性能优化相关

107 阅读6分钟

bailout

React 是一个注重运行时性能的库,所以在运行时做了大量的性能优化,其中一个性能优化就是在 Render 阶段去判断当前树以及子树是否存在 baiout,从而跳过 render,实现优化,下面一起来看看具体怎么做的吧。

beginWork 阶段

if (
  oldProps !== newProps ||
  hasContextChanged() || // Force a re-render if the implementation changed due to hot reload:
  workInProgress.type !== current.type
) {
  // If props or context changed, mark the fiber as having performed work.
  // This may be unset if the props are determined to be equal later (memo).
  didReceiveUpdate = true;
  // 如果oldProps 和 newProps 是相同的
  // 上下文相同
  // fiberNode.type没有变化,比如没有从DIV变为 UL
} else {
  var hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes
  );

  if (
    !hasScheduledUpdateOrContext && // If this is the second pass of an error or suspense boundary, there
    // may not be work scheduled on `current`, so we check for this flag.
    (workInProgress.flags & DidCapture) === NoFlags
  ) {
    // No pending updates or context. Bail out now.
    didReceiveUpdate = false;
    // 进入bailout 策略
    return attemptEarlyBailoutIfNoScheduledUpdate(
      current,
      workInProgress,
      renderLanes
    );
  }
}

在 beginWork 阶段,进入优化策略的条件有 4 个,分别是

  1. oldProps 和newProps完全相等。
  2. Legacy Context(旧的 API)没有变化
  3. fiberNode.type没有变化(新旧 fiberNode 没变化)
  4. 是否存在更新

如果这四个条件都满足,会进入 bailout 进行优化

    function bailoutOnAlreadyFinishedWork(
      current,
      workInProgress,
      renderLanes
    ) {
      if (current !== null) {
        // Reuse previous dependencies
        workInProgress.dependencies = current.dependencies;
      }

      {
        // Don't update "base" render times for bailouts.
        stopProfilerTimerIfRunning();
      }

      markSkippedUpdateLanes(workInProgress.lanes); // Check if the children have any pending work.

      // 跳过孩子更新
      if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
        // The children don't have any work either. We can skip them.
        // TODO: Once we add back resuming, we should check if the children are
        // a work-in-progress set. If so, we need to transfer their effects.
        {
          /*KaSong*/ logHook("bailoutOnAlreadyFinishedWork", "skip children");
          return null;
        }
      } // This fiber doesn't have work, but its subtree does. Clone the child
      // fibers and continue.

      // 当前节点没有需要做的工作,但是子树有需要更新的操作,所以需要克隆当前节点,返回当前节点的子节点。
      // 因为当前节点可能粒度太大了,返回子节点的话还可以继续判断,可能存在更小粒度的更新可以跳过。
      cloneChildFibers(current, workInProgress);
      /*KaSong*/ logHook(
        "bailoutOnAlreadyFinishedWork",
        "cloneChildFibers",
        workInProgress.child
      );
      return workInProgress.child;
    }
  1. 通过childLanes判断子树是否需要跳过,如果不存在更新的话,整颗子树可以直接跳过
  2. 跳过当前 FiberNode 节点的更新,继续reconciler后续的节点。

使用了性能优化 API

刚进入 beginWork 构造树的时候会检测当前节点是否可以命中 bailout,但是条件比较苛刻。React 为开发者提供了 API 可以命中策略,如 React.memo。下面一起来看看

var hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
  current,
  renderLanes
);

if (!hasScheduledUpdateOrContext) {
  // This will be the props with resolved defaultProps,
  // unlike current.memoizedProps which will be the unresolved ones.
  var prevProps = currentChild.memoizedProps; // Default to shallow comparison

  var compare = Component.compare;
  compare = compare !== null ? compare : shallowEqual;

  if (
    compare(prevProps, nextProps) &&
    current.ref === workInProgress.ref
  ) {
    return bailoutOnAlreadyFinishedWork(
      current,
      workInProgress,
      renderLanes
    );
  }
} // 

代码比较清晰,可以看到,先判断当前 FiberNode 是否存在更新,如果不存在更新,然后会调用 memo 中的compare 函数判断上一个 Props 和当前 props 是否相等,如果相等,也会进入 bailout 策略。所以需要满足三个条件才能进入:

  1. 当前 FiberNode 不存在更新
  2. prevProps 等于currentProps。
  3. ref 没变

虽有更新,但 state 未发生变化

beginWork 阶段会根据当前 FiberNodetag 生成不同的 FiberNode,当走到 Function 节点时,也会有判断 bailout 的条件,直接看源码

function updateFunctionComponent(){
  nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes
  );
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(
      current,
      workInProgress,
      renderLanes
    );
  }

}

renderWithHooks可以理解成去执行当前 FiberNode 的 FC 函数,去生成对应的节点。然后会根据didReceiveUpdate变量去判断是否应该 bailout。

执行 renderWithHooks 时会调用 useState 方法,在里面会设置didReceiveUpdate的值。直接看源码

function updateReducer(reducer, initialArg, init) {
  // 判断计算出的结果是否跟原来的结果一致,如果一致,对didReceiveUpdate进行标记,这个变量是 bailout 优化的手段
  if (!objectIs(newState, hook.memoizedState)) {
    markWorkInProgressReceivedUpdate();
  }
}

function markWorkInProgressReceivedUpdate() {
  didReceiveUpdate = true;
}

可以看到当运行 useState 时,去计算最新的 state 和旧的 state 是否一致,如果一致,didReceiveUpdate为 false,就会进入bailoutOnAlreadyFinishedWork进行运行时优化。

Context

bailout和Context结合起来有一些有意思的点,下面我们一起分析一下。

处理 Context 的地方在下面的源码中

function beginWork{
  // ... 
  case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);
}
function updateContextProvider(current, workInProgress, renderLanes) {
      // ...
      {
        if (oldProps !== null) {
          var oldValue = oldProps.value;

          if (objectIs(oldValue, newValue)) {
            // No change. Bailout early if children are the same.
            if (
              oldProps.children === newProps.children &&
              !hasContextChanged()
            ) {
              return bailoutOnAlreadyFinishedWork(
                current,
                workInProgress,
                renderLanes
              );
            }
          } else {
            // The context value changed. Search for matching consumers and schedule
            // them to update.
            // 生产者生产出新的数据,通知消费者进行更新,会将子树的 lanes 设置为 renderlanes
            propagateContextChange(workInProgress, context, renderLanes);
          }
        }
      }

      var newChildren = newProps.children;
      reconcileChildren(current, workInProgress, newChildren, renderLanes);
      return workInProgress.child;
    }
  1. 这个组件会检测oldValue 和 newValue 是否一致,会通过额外的判断来决定是否进入bailoutOnAlreadyFinishedWork。
  2. 如果不一致,直接propagateContextChange。
  3. propagateContextChange会寻找 Context Consumer(比如用了Consumer或者说用了 useContext),然后给这些组件的 FiberNode.lanes全部附加 renderLanes(渲染优先级),让全部重新渲染。
  4. 这里可以得出,即使子树的父级 FiberNode 命中了 bailout 策略,但是由于子级被附加了 Lanes,所以不会完全跳过子树的 beginWork。所以会存在性能问题

如何命中bailout

在做 React 项目中,或多或少会遇到一些性能问题,那么如何解决性能问题是开发者比较关心的点。而 React 又是重运行时的库,所以我们需要命中他的 bailout 就可以解决性能问题。

这里借由卡老师提供的案例来分析一下出现的性能问题

import React, { useState, useEffect, ReactNode } from "react";

export default function App() {
  const [num, updateNum] = useState(0);
  return (
  
      <input value={num} onChange={(e) => updateNum(+e.target.value)} />
      <p>num is {num}</p>
      <ExpensiveCpn />
    </div>
  );
}

function ExpensiveCpn() {
  let now = performance.now();
  while (performance.now() - now < 100) {}
  console.log("耗时的组件 render");
  return <p>耗时的组件</p>;
}

当在 input 中触发 state 变化时,会明显感觉到卡顿,是因为每次 state 更新,会重新 render【ExpensiveCpn】组件导致的。

分析一下ExpensiveCpn没有命中 bailout 的原因,在 App 组件中触发 state 更新,App 本身不会命中 bailout,而每次 render,ExpensiveCpn又会重新渲染,所以ExpensiveCpn也不会命中。

为了使得ExpensiveCpn命中 bailout,可以进行视图的分离,如下:


function Input() {
  const [num, updateNum] = useState(0);

  return (
    <>
      <input value={num} onChange={(e) => updateNum(+e.target.value)} />
      <p>num is {num}</p>
    </>
  );
}

export default function App() {
  return (
    <>
      <Input />
      <ExpensiveCpn />
    </>
  );
}

function ExpensiveCpn() {
  let now = performance.now();
  while (performance.now() - now < 100) {}
  return <p>耗时的组件</p>;
}
  1. 通过将 state 分离到 input 组件中,input 只会触发他自身的 render,其他的组件都会命中 bailout。所以达到了性能优化的目的。

下面再来看一个例子

import React, { useState, useEffect, ReactNode } from "react";

export default function App() {
  const [num, updateNum] = useState(0);
  return (
    <div title={num + ""}>
      <input value={num} onChange={(e) => updateNum(+e.target.value)} />
      <p>num is {num}</p>
      <ExpensiveCpn />
    </div>
  );
}

function ExpensiveCpn() {
  let now = performance.now();
  while (performance.now() - now < 100) {}
  console.log("耗时的组件 render");
  return <p>耗时的组件</p>;
}
  1. 这种情况下,因为父级要用 num,所以无法分离,所以需要思考别的情况。

下面是优化的策略

import React, {useState, useEffect} from 'react';
import {bindHook, utils, getLibraryMethod} from 'log';

const {log, COLOR: {SCHEDULE_COLOR, RENDER_COLOR, COMMIT_COLOR}} = utils;

// bindHook('beginWork', (current, wip) => {
//   log(RENDER_COLOR, `beginWork`, getLibraryMethod('getComponentNameFromFiber')?.(wip));
// })  

function InputWrapper({children}: {children: React.ReactNode}) {
  const [num, updateNum] = useState(0);

  return (
    <div title={num + ''}>
    <input value={num} onChange={(e) => updateNum(+e.target.value)} />
    <p>num is {num}</p>
    {children}
  </div>
  )
}

export default function App() {
  
  return (
    <InputWrapper>
      <ExpensiveCpn />
    </InputWrapper>
  );
}


function ExpensiveCpn() {
  let now = performance.now();
  while (performance.now() - now < 100) {}
  console.log('耗时的组件 render');
  return <p>耗时的组件</p>;
}
  1. 通过使用 children 属性,使得他命中 bailout 策略。
  2. children 是父级的属性,引用是不会变的,所以可以命中 bailout 策略。

原则

可变部分不变部分分离,使不变部分可以命中 bailout。