# react useState 的自动优化

react中一个非常重要的概念就是state, 每个state都对应着一份ui, 这也是数据驱动视图的核心概念. 所以如果我们想改变视图那么就需要去改变state.

在传统的class component中我们通过this.setState来完成这个操作, 在function component中我们通过useState来完成这个操作. 那么问题就来了, 既然state改变会引起视图的变化, 如果我的state没变, react应该如何处理这次setState? 最直观的想法就是没必要进行重复的render, 因为state不变的情况下理论上来说视图也不应该发生变化. 那么如何判断state没有发生改变, 那不就是===么?其实, 并没有这么简单. 我们直接写一个简单的demo来试试.

online code (opens new window), 这里面同时提供了function componentclass component, 大家可以体验一下有什么区别. 我们后续的内容基于function component.

# 实操

import { useState } from "react"

const App = () => {
  const [count, setCount] = useState(0);

  console.log("App render", count)

  return (
    <div>
      <div>count: {count}</div>
      <button onClick={() => {
        console.log("click button");
        setCount(1);
      }}>set count 1</button>
    </div>
  )
}

export default App;

我们按照我们的思路来理解一下, 如果我们需要对setState的过程进行优化, 当第一次render的时候, 应该会输出App render, 这一步应该不会存在什么问题. 当我们第一次点击按钮的时候, 也会执行一次render, 因为我们把count0改为了1. 当我们后续连续点击按钮的时候, state并没有发生改变, 此时我们应该可以忽略这些setCount才对.

但是实际上在我们第二次点击按钮的时候, 仍然会触发App render, 直到第三次点击按钮的时候才会停止render.

这是为啥, 一定是reactbug, 我们直接来帮它修复一下. reactsetCount最终调用的其实是dispatchSetState方法:

function dispatchSetState(fiber, queue, action) {
  // 帮 react 修复 bug, state 一致的情况下直接 return.
  const oldState = queue.lastRenderedState;
  const newState = action;

  if(oldState === newState) {
    return;
  }
  
  {
    if (typeof arguments[3] === 'function') {
      error("State updates from the useState() and useReducer() Hooks don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().');
    }
  }

  var lane = requestUpdateLane(fiber);
  var update = {
    lane: lane,
    action: action,
    hasEagerState: false,
    eagerState: null,
    next: null
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    var alternate = fiber.alternate;

    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.
      var lastRenderedReducer = queue.lastRenderedReducer;

      if (lastRenderedReducer !== null) {
        var prevDispatcher;

        {
          prevDispatcher = ReactCurrentDispatcher$1.current;
          ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }

        try {
          var currentState = queue.lastRenderedState;
          var eagerState = lastRenderedReducer(currentState, action); // Stash the eagerly computed state, and the reducer used to compute
          // it, on the update object. If the reducer hasn't changed by the
          // time we enter the render phase, then the eager state can be used
          // without calling the reducer again.

          update.hasEagerState = true;
          update.eagerState = eagerState;

          if (objectIs(eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // It's still possible that we'll need to rebase this update later,
            // if the component re-renders for a different reason and by that
            // time the reducer has changed.
            // TODO: Do we still need to entangle transitions in this case?
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update, lane);
            return;
          }
        } catch (error) {// Suppress the error. It will throw again in the render phase.
        } finally {
          {
            ReactCurrentDispatcher$1.current = prevDispatcher;
          }
        }
      }
    }

    var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

    if (root !== null) {
      var eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
      entangleTransitionUpdate(root, queue, lane);
    }
  }

  markUpdateInDevTools(fiber, lane);
}

我们在最前面加上了我们之前说的判断逻辑, 发现state一样就直接return. 现在我们再来试一试, 第二次点击click的时候就会直接return, 避免了重复渲染, 看起来非常完美.

随后我们把这段代码合进了master, 有一天用户来反馈了, 他说他的setState发疯了, 我们一看代码, 是这样写的:

import { useState } from "react"

const App = () => {
  const [count, setCount] = useState(0);

  console.log("App render", count)

  return (
    <div>
      <div>count: {count}</div>
      <button onClick={() => {
        console.log("click button");
        setCount(1);
        setCount(2);
        setCount(1);
      }}>set count 1</button>
    </div>
  )
}

export default App;

咱也不知道他为什么要这样写, 但是他就是这样写了, 按照代码的预期, 每次点击click之后最终的count都会是1才对.

而这段代码的表现是, 当count0->1的时候, 能正常工作. 再次点击按钮的时候, 你会发现count变成了2. 导致这一切的根源就是我们之前加的那段===的逻辑, 我们跟着代码走一遍.

  1. currentCount: 1.
  2. 调用 setCount(1).
  3. 1(currentCount) === 1, 得到 true, return.
  4. 调用 setCount(2).
  5. 1(currentCount) === 2, 得到 false, 执行后续代码.
  6. 调用 setCount(1).
  7. 1(currentCount) === 1, 得到 true, return.

PS: 就是第7步, 此时经过上一个setCount, count本应该是2才对, 但是不要忘了set是一个异步的行为, 也就是说我们此时拿到的count还是1, 也就导致我们错误的认为此次不需要更新.

所以最后只有setCount(2)执行了, 也把我们的count错误的赋值为2. 如果我们想将剩下的步骤直接跳过, 应该保证这个set是首个set, 这样在state完全一致的情况下才能安全跳过剩下所有的步骤. react把这个优化称为eagerState. 这段逻辑就在我们刚刚截取的代码片段里:

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.
  var lastRenderedReducer = queue.lastRenderedReducer;

  if (lastRenderedReducer !== null) {
    var prevDispatcher;

    {
      prevDispatcher = ReactCurrentDispatcher$1.current;
      ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
    }

    try {
      var currentState = queue.lastRenderedState;
      var eagerState = lastRenderedReducer(currentState, action); // Stash the eagerly computed state, and the reducer used to compute
      // it, on the update object. If the reducer hasn't changed by the
      // time we enter the render phase, then the eager state can be used
      // without calling the reducer again.

      update.hasEagerState = true;
      update.eagerState = eagerState;

      if (objectIs(eagerState, currentState)) {
        // Fast path. We can bail out without scheduling React to re-render.
        // It's still possible that we'll need to rebase this update later,
        // if the component re-renders for a different reason and by that
        // time the reducer has changed.
        // TODO: Do we still need to entangle transitions in this case?
        enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update, lane);
        return;
      }
    } catch (error) {// Suppress the error. It will throw again in the render phase.
    } finally {
      {
        ReactCurrentDispatcher$1.current = prevDispatcher;
      }
    }
  }
}

其中lanes表示更新的优先级, 也就是说如果存在这个标志, 那么就意味着在之前已经调用过set方法了, 不能粗暴的直接省略后面的过程.

所以react本身其实是已经做了这种优化, 但是为什么我们第二次点击的时候还是会触发一次render呢?我们知道react中每个组件其实存在两个fiber.

  • current fiber: 表示当前组件的fiber
  • workInProgress fiber: 表示正在更新中的fiber

react在完成渲染后只会清除掉wip fiber上的lanes, 所以current fiber上的lanes其实并没有清空, 而我们看代码是需要两个fiber上的lanes都为NoLanes才认为本次更新能够通过eagerState的方式进行优化.

其实我对这部分逻辑有一些不理解, 在我的认知中, 如果wip fiber上的lanes已经是NoLanes已经能够满足eagerState的触发条件了, 或者说在render完毕去清空lanes时同步清空两个fiber上的lanes. 总之我认为应该有办法在第二次click的时候就停止重复渲染才对. 至于为什么最终呈现的是这个效果, 大家如果有知道的欢迎告诉我.

值得一提的是这个优化只存在于function component中, 如果你在class component里面重复setState就会发现react依然会每次都重新进行渲染, 就如我们在文章开头给出的例子一样. 此时如果我们想避免重复渲染, 我们可以利用shouldComponentUpdate来进行手动优化.

# 总结

这只是react内部做的一个小优化, 最关键的点就在于set是一个异步行为, 所以让看起来很简单的===逻辑变得复杂.

react团队给我的感觉是他们并不在乎这些小优化. 或许这也是为什么会多了一次render的原因: 这个地方是可以优化, 但没必要, 因为我们已经提供了各种api给到用户可以进行手动优化. 他们似乎更在乎从整体架构上去解决一些render缓慢的问题, 这也就是react 18诞生的理由. 或许这也是为什么很多人觉得vue性能比react好的原因, vue在内部就已经自动帮开发者做了很多优化, 但是react团队的理念似乎并不在此.

# 参考资料

  1. https://segmentfault.com/a/1190000041531553 (opens new window)