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.alternate
为 null
(为什么? 因为是刚刚完成第一次渲染, 除了 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 步骤都跳过了.