Skip to main content
overcache

useState 回调的执行时机

源于论坛上一个问题:

import {useState} from "react";

export default function StateUpdate() {
    const [A, setA] = useState(1)
    console.log('A', 'render', A)
    return (
        <div>
            <button onClick={() => {
                setA(prev => {
                    console.log('A', 'prev1', prev)
                    return prev + 1
                })
                setA(prev => {
                    console.log('A', 'prev2', prev)
                    return prev + 1
                })
                setA(prev => {
                    console.log('A', 'prev3', prev)
                    return prev + 1
                })

                console.log('A', 'state', A)
            }}>
                button A {A}
            </button>
        </div>
    )
}

问: 为什么以上代码的执行的执行顺序是

A prev1 1  
A state 1  
A prev2 2  
A prev3 3

而不是

A state 1  
A prev1 1  
A prev2 2  
A prev3 3

bailout

在最新的react源码(commit: 1d5667a12)中, 当调用 setState 时执行的是 dispatchSetState, 其源码如下:

// react/packages/react-reconciler/src/ReactFiberHooks.js

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const 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.
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;

        try {
          const currentState: S = (queue.lastRenderedState: any);
          const 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 (is(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);
            return;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
    }
  }

  markUpdateInDevTools(fiber, lane, action);
}

可以看到因为当前 fiber.lanes 为空, 并且 fiber.alternatenull (为什么? 因为是刚刚完成第一次渲染, 除了 FiberRootNode.current 指向的 Fiber, 其他所有 Fiber.alternate 都是 null), 即 fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes) 为 true, 所以会进入一个 bailout 的判断流程: 如果 setState 执行的结果和当前的 state 状态一致, 那就没必要进行后续的 reconcile 和 render 流程, 由此达到优化性能的目的.

所以这就是为什么 A prev1 1 会先被打印.

mergeLanes

因为第一个 setState 得到的状态(A=2)和现有状态(A=1)不一致, 所以没有进入 bailout 而是进入了 enqueueConcurrentHookUpdate , 在这个函数内, 当前更新的lane(值=2)被合并到 fiber.lanes 中, 最后 fiber.lanes 也变成了2, 表示当前 Fiber 有一个更新等待执行, 且其优先级为2.

所以后续的 setState, 由于当前 fiber 有待处理的更新, 就不可能做提前退出的优化了, 因此不会再进入 bailout 的判断. 他们的回调函数也被推迟到 reconcile 阶段执行.

因此跳过他们后, console.log('A', 'state', A) 紧接着被执行, 并打印A state 1

processUpdateQueue

后续的 processUpdateQueue 就不用赘述了, react 依次执行回调更新了 state, 所以打印的顺序是:

A prev2 2  
A prev3 3

扩展1: 再次点击

再次点击按钮, 观察到打印的顺序变成了:

A state 1  
A prev1 1  
A prev2 2  
A prev3 3

这又是为什么? 或者应该问为什么以下语句是false?

 fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)

打印出 fiber.lanes 的值, 居然是上一次渲染时的值2. 难道每次渲染后这个值不应该被清空吗? 毕竟这个值表示着当前fiber有待执行的更新呀?

扩展2: bailout

以下的代码又会产生什么样的打印顺序呢?

import {useState} from "react";

export default function StateUpdate() {
    const [A, setA] = useState(1)
    console.log('A', 'render', A)
    return (
        <div>
            <button onClick={() => {
                setA(prev => {
                    console.log('A', 'prev1', prev)
                    return 1
                })
                setA(prev => {
                    console.log('A', 'prev2', prev)
                    return 1
                })
                setA(prev => {
                    console.log('A', 'prev3', prev)
                    return 1
                })

                console.log('A', 'state', A)
            }}>
                button A {A}
            </button>
        </div>
    )
}

你应该想到了, 因为每次 setState 得到的 state 值都会当前值一致, 后续的 setState 仍然会执行然后对比, 尝试进行优化. 所有的 setState 都产生了一样的状态, 结果就是后续的 reconcile和render 步骤都跳过了.