likes
comments
collection
share

React性能优化

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

目录

前言

本文是React源码学习系列第六篇,该系列整体都基于React18.0.0版本源码。旨在学习过程中记录一些个人的理解。该篇介绍React优化策略。

优化手段

React作为一款“重运行时”框架,拥有多个“性能优化”相关的API。

  1. shoudComponentUpdate
  2. PureComponent
  3. React.memo
  4. useMemo
  5. useCallback

因为React无法像Vue一样在编译时做出优化,所以这部分工作放在运行时交由开发者完成。React内部有一套完整的运行时优化策略,开发者调用性能优化API的本质就是命中这些优化策略。我们可以从两方面来提升性能。

  1. 编写“符合性能优化策略的组件”,命中优化策略。
  2. 调用性能优化API,命中优化策略。

例子1

function App() {
    const [num, setNum] = useState(0);
    console.log('App render', num);
    return <div onClick={() => setNum(1)}>
        <Child />
    </div>;
}
function Child() {
    console.log('child render');
    return <span>child</span>;
}
  1. 首次渲染时,打印:App render 0; child render
  2. 第一次点击div时,打印:App render 1; child render
  3. 第二次点击div时,打印:App render 1;
  4. 第三次点击div时,不会打印。

分析

为什么第二次点击时没有打印child render?这是一种“发生在render阶段”的优化策略,被称为bailout。命中该策略的组件的子组件会跳过reconcile流程(即子组件不会进入render阶段)。

为什么第三次及之后的点击不会打印?这是因为App、Child都未进入render阶段,这是一种“发生在触发状态更新时”的优化策略,被称为eagerState。命中该策略的更新不会进入schedule阶段,也不会进入render阶段。

eagerState策略

eagerState策略逻辑很简单,如果某个状态更新前后没有变化,则可跳过后续更新流程。我们知道state是基于update计算而来,计算过程发生在render阶段的beginWork中。eagerState(急迫的state)表示:在当前fiberNode中不存在待执行的更新时(就是说当前产生的update是fiberNode的第一个update),计算state不会受到其他update的影响。可以将这一计算过程提前到schedule阶段之前执行。

useState举例

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
    const lane = requestUpdateLane(fiber);
    const update: Update<S, A> = {
        lane,
        action,
        hasEagerState: false,
        eagerState: null,
        next: (null: any),
    };
    enqueueUpdate(fiber, queue, update, lane);
    const alternate = fiber.alternate;
    // 判断wip、current的lanes是否为NoLanes
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // 上次计算时使用的reducer
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        try {
          const currentState: S = (queue.lastRenderedState: any);
          // 直接计算出state
          const eagerState = lastRenderedReducer(currentState, action);
          // 将计算出来的状态存储在update上。
          update.hasEagerState = true;
          update.eagerState = eagerState;
          // Object.is
          if (is(eagerState, currentState)) {
            // 状态与当前状态相同,直接return
            return;
          }
        }
      }
    }
    // 没有命中优化策略,发起调度
    const eventTime = requestEventTime();
    const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
}

通过代码可以看到如果Object.is(eagerState, currentState)为true,说明state没有变化,命中eagerState策略,不会发起一个调度。 有的同学可能会疑惑,如果不为true的情况下会不会多做一次计算state的过程?由于这是“当前fiberNode”的第一个更新,在它之前不会有update影响它的计算结果,所以可以将eagerState保存下来,在beginWork中计算state时,可以直接使用update.eagerState,不需要重新计算。这就是update数据结构中如下字段的意义。

const update = {
    // 是否是eagerState
    hasEagerState: false,
    // eagerState的计算结果
    eagerState: null
}

提问

那第二次点击num前后都为1,为什么App组件没有命中eagerState策略?

 if (fiber.lanes === NoLanes &&(alternate === null || alternate.lanes === NoLanes)
) {
    //...
}

我们可以看到要想命中eagerState策略,必须wip和current的lanes都为NoLanes。

lanes消费过程

发起一个调度的时候,会把lanes合并的wip和current的lanes中。

sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
let alternate = sourceFiber.alternate;
if (alternate !== null) {
  alternate.lanes = mergeLanes(alternate.lanes, lane);
}

beginWork开始后会重置workInProgress.lanes。

workInProgress.lanes = NoLanes;

commit阶段会切换fiber树

root.current = finishedWork;

第一次点击,wip、curent的lanes都不为空。调度执行更新任务。

  1. beginWork执行后,wip.lanes重置为NoLanes;
  2. commit后wip与current树互换。

第二次点击时,wip的lanes不为空,所以无法命中eagerState策略。虽然没有命中eagerState策略,但是这次点击没有打印“child render”,说明App命中了bailout策略,复用了App的current节点。

bailout策略

beginWork的目的是生成当前FiberNode的子FiberNode。有两种路径:

  1. 通过reconcile流程生成子FiberNode。
  2. 通过bailout策略复用子FiberNode。

命中bailout策略策略表示子FiberNode没有变化可以复用。即以下字段没有变化。

  1. state
  2. props
  3. context

beginWork

if (current !== null) {
  const oldProps = current.memoizedProps;
  const newProps = workInProgress.pendingProps;
  // 全等
  if (oldProps !== newProps || hasLegacyContextChanged()) {
    didReceiveUpdate = true;
  } else { // props和legacy context都没有改变。
    // 检查是否有pending update or context change。
    const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
      current,
      renderLanes,
    );
    if (
      !hasScheduledUpdateOrContext &&
      // error或suspense 忽略。
      (workInProgress.flags & DidCapture) === NoFlags
    ) {
      // 没有更新内容
      didReceiveUpdate = false;
      // 命中优化策略
      return attemptEarlyBailoutIfNoScheduledUpdate(
        current,
        workInProgress,
        renderLanes,
      );
    }
    if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
      // 这是仅存在于遗留模式的特殊情况,忽略它。
      didReceiveUpdate = true;
    } else {
      // fiberNode上有本次更新的lanes,但是props和context都没改变,设为false,后面还有机会命中bailout策略。
      // 如果更新导致state的值发生变化或者有consumer的值产生变化,后面会把它设为true。
      didReceiveUpdate = false;
    }
  }
}

bailout策略条件

1. oldProps === newProps:

注意是全等。这就需要父FiberNode命中bailout策略,复用老节点才有可能。 HostRootFiber默认情况下满足oldProps === newProps。

2. Legacy Context(旧的Context API)没有变化。

3. fiberNode.type没有变化

例子1中的Child组件是在App组件内定义的,每次App render都会创建一个新的Child引用,所以对于Child来说,fiberNode.type是始终变化的。所以尽量不要在组件内定义组件,以免无法命中bailout策略。

4. 当前fiberNode没有更新。

checkScheduledUpdateOrContext方法,主要检查当前Fiber是否有本次更新的lanes。

// 保留关键代码
function checkScheduledUpdateOrContext(
  current: Fiber,
  renderLanes: Lanes,
): boolean {
  const updateLanes = current.lanes;
  // 该Fiber是否有本次更新的lanes
  if (includesSomeLane(updateLanes, renderLanes)) {
    return true;
  }
  return false;
}

命中bailout策略

如果满足以上条件就会进入bailoutOnAlreadyFinishedWork方法。该方法会进一步判断“优化可以进行到何种程度”。

bailoutOnAlreadyFinishedWork

检查子树是否有更新。

  1. 子树没有更新:直接跳过整个Diff过程。
  2. 子树有更新,复用该FiberNode,继续执行子树beginWork。
function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // 检查子树是否有本次更新的lanes。
  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
      // 子树本次没有要更新的lanes,return null 跳过整该fiber的beginWork
      return null;
  }
  // 子树有更新。克隆fiber并继续。
  cloneChildFibers(current, workInProgress);
  return workInProgress.child;
}

没有命中bailout策略

如果没有命中bailout策略,会根据tag进入不同的分支进行处理,还有两种命中bailout策略的可能。

1.开发者调用性能优化API

第一次判断是否命中bailout策略时,props是全等判断,要满足该条件比较困难。性能优化API的工作原理就是改写此判断。

React.memo

该API创建的fiberNode的tag为MemoComponent。在beginWork中的逻辑是updateMemoComponent。满足以下条件命中bailout策略。

  1. 不存在更新。
  2. 经过比较(默认浅比较)后props未变化。
  3. ref不变
function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
): null | Fiber {
  const currentChild = ((current.child: any): Fiber);
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes,
  );
  if (!hasScheduledUpdateOrContext) {
    const prevProps = currentChild.memoizedProps;
    // 比较函数,默认是浅比较
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      // 命中bailout策略
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
}

PureComponent和shoudComponentUpdate

if (!shouldUpdate && !didCaptureError) {
  // 命中bailout策略
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}

shouldUpdate受checkShouldComponentUpdate方法影响。

  1. shoudComponentUpdate通过返回值影响shouldUpdate变量。
  2. PureComponent通过浅比较影响shouldUpdate变量。
  3. 注意:PureComponent组件如果存在shoudComponentUpdate方法,shoudComponentUpdate方法返回值优先级高。
  4. 最终影响是否命中bailout策略。
function checkShouldComponentUpdate(
  workInProgress,
  ctor,
  oldProps,
  newProps,
  oldState,
  newState,
  nextContext,
) {
  const instance = workInProgress.stateNode;
  if (typeof instance.shouldComponentUpdate === 'function') {
    // 根据shouldComponentUpdate返回值决定是否需要更新
    let shouldUpdate = instance.shouldComponentUpdate(
      newProps,
      newState,
      nextContext,
    );
    return shouldUpdate;
  }
  // PureComponent 浅比较
  if (ctor.prototype && ctor.prototype.isPureReactComponent) { 
    return (
      !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
    );
  }
  return true;
}

2.虽然有更新,但是state没有变化

第一次判断是否命中bailout策略时,还有一个条件是“当前fiberNode没有更新”。没有更新state肯定没有变化,有更新的话需要先计算出state,判断其有没有变化。

FC组件

在beginWork中的逻辑是updateFunctionComponent。满足以下条件命中bailout策略。

  1. current !== null
  2. didReceiveUpdate为false
// 组件render
nextChildren = renderWithHooks(
  current,
  workInProgress,
  Component,
  nextProps,
  context,
  renderLanes,
);
if (current !== null && !didReceiveUpdate) {
  // 命中bailout策略
  bailoutHooks(current, workInProgress, renderLanes);
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
// reconcile流程
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;

计算state时会执行updateReducer方法(useState、useReducer后会执行的内部方法)。

if (!Object.is(newState, hook.memoizedState)) {
  // 把didReceiveUpdate设为true
  markWorkInProgressReceivedUpdate();
}

例子2

function App() {
    const [num, setNum] = useState(0);
    return <>
        <input value={num} onChange={(e) => setNum(e.target.value)} />
        <p>num is {num}</p>
        <ExpensiveCpn />
    </>;
}
function ExpensiveCpn() {
    const now = performance.now();
    while(performance.now() - now < 100) {};
    return <p>耗时的组件</p>;
}

该例子在input输入内容时,会有明显卡顿,是因为ExpensiveCpn组件没有命中bailout策略。因为App中会触发state改变,所以App不会命中bailout策略,意味着“ExpensiveCpn对应的JSX”是App render的返回值。在ExpensiveCpn beginWork的时候“判断是否命中bailout策略”时oldProps !== newProps,未命中bailout策略。会执行耗时的render,造成卡顿。

优化

在不调用性能优化API的情况下,把可变部分与不变部分拆开。

  1. HostRootFiber命中bailout策略。
  2. App没有state变化,且是HostRootFiber命中bailout策略复用而来。满足oldProps === newProps。命中bailout策略。
  3. ExpensiveCpn没有state变化,且是App命中bailout策略复用而来。满足oldProps === newProps。命中bailout策略。
  4. 命中bailout策略,不会执行耗时的render。
function App() {
    return <>
        <Input />
        <ExpensiveCpn />
    </>;
}
function Input() {
    const [num, setNum] = useState(0);
    return <>
        <input value={num} onChange={(e) => setNum(e.target.value)} />
        <p>num is {num}</p>
    </>;
}
function ExpensiveCpn() {
    const now = performance.now();
    while(performance.now() - now < 100) {};
    return <p>耗时的组件</p>;
}
export default App;

总结

React中包含两种优化策略。

  1. eagerState策略
  2. bailout策略

eagerState策略需要满足的条件比较苛刻,开发时不必强求。开发者应该追求写出“满足bailout策略的组件”。

  1. 即使不使用性能优化API,只要满足一定条件,也能命中bailout策略
  2. 将可变部分与不变部分分离,使不变部分能够命中bailout策略。

参考

React设计原理 - 卡颂。

转载自:https://juejin.cn/post/7397352785606639651
评论
请登录