# 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 component
和class 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
, 因为我们把count
从0
改为了1
. 当我们后续连续点击按钮的时候, state
并没有发生改变, 此时我们应该可以忽略这些setCount
才对.
但是实际上在我们第二次点击按钮的时候, 仍然会触发App render
, 直到第三次点击按钮的时候才会停止render
.

这是为啥, 一定是react
的bug
, 我们直接来帮它修复一下. react
的setCount
最终调用的其实是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
才对.
而这段代码的表现是, 当count
从0
->1
的时候, 能正常工作. 再次点击按钮的时候, 你会发现count
变成了2
. 导致这一切的根源就是我们之前加的那段===
的逻辑, 我们跟着代码走一遍.
currentCount
:1
.- 调用
setCount(1)
. 1(currentCount) === 1
, 得到true
,return
.- 调用
setCount(2)
. 1(currentCount) === 2
, 得到false
, 执行后续代码.- 调用
setCount(1)
. 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
团队的理念似乎并不在此.