先记住这个答案
useState 把下一状态与当前状态按 Object.is 比较,相同值通常使 React 跳过由这次状态更新带来的组件及子树工作。某些情况下 React 仍可能先调用组件再忽略结果,因此不能把“相同值”解释为函数绝不会执行。对象比较关注引用,同一对象改字段后再传回去仍可能被判为相同;两个内容一样的新对象则不同。Object.is 把 NaN 与自身视为相同,但区分零与负零,这也解释了它与普通严格相等比较的少数差别。
- useState 的相等判断使用 Object.is
- 同一对象引用不会因内部修改自动变成新状态
- 跳过更新不能作为渲染函数副作用的保护
为什么数值看起来都是零却可能更新
JavaScript 的零和负零直接转成文字通常都显示零,但 Object.is 能区分它们。下面用显式格式化显示负零,避免已经发生状态变化却因为文本一样误以为 React 没有更新。再次设置同一个 NaN 则属于相同状态。
验收这类行为应检查界面和状态含义,而不是给渲染函数加计数副作用。即使某次相同值设置没有引发可见更新,之后父组件、Context 或其他 state 的变化仍可能让这个组件再次参与渲染。
import { useState } from 'react';
export default function SameValue() {
const [value, setValue] = useState(0);
const label = Object.is(value, -0) ? '-0' : String(value);
return <>
<button onClick={() => setValue(0)}>设为零</button>
<button onClick={() => setValue(-0)}>设为负零</button>
<button onClick={() => setValue(NaN)}>设为 NaN</button>
<output>数值:{label}</output>
</>;
}初始显示零,设置负零后显式显示 -0;设置 NaN 后显示 NaN,再次设置 NaN 保持相同状态。这里不承诺每一步组件函数被调用的精确次数。
对象原地修改会让相同引用判断掩盖问题
如果先修改 user.name,再调用 setUser(user),React 看到的还是同一个对象引用。这会使界面更新行为和你已经改动的内存内容不一致;后续其他更新又可能让修改突然出现在屏幕上,增加排查难度。
正确方式是返回新的变更路径对象,保护旧版本。反过来,每次都构造内容相同的新对象也不会被 useState 深比较后自动跳过;若频繁产生无意义更新,可以在业务层判断确实没有变化时直接返回前一状态。
相同值跳过不能替代纯渲染与订阅设计
组件函数必须在可能再次执行的前提下保持纯度,不能因为某条路径大概率跳过就把请求、计数或资源创建放进渲染。父层和 Context 都有独立的更新来源,某个 setter 的相等优化并不封住其他入口。
对外部可变对象,仅把它放进 state 也不会让 React 订阅内部变化。应建立明确的通知与快照契约,再让界面消费稳定的结果。不要用不断提交一个新包装对象强行刷新来长期掩盖缺失的外部状态订阅。
容易答错的地方
- Object.is 会递归比较对象里的所有字段
- 它比较对象身份,不进行深层字段遍历;同引用的内部修改和不同引用的相同内容会得到不同于深比较的结果。
- 相同值时组件绝不执行,所以 render 副作用安全
- React 不承诺组件函数在所有相同值场景都完全不被调用,其他更新也可能触发渲染;渲染副作用不能建立在跳过优化之上。
面试官还会怎么问?
返回 previous 能否表达这次操作没有变化?
可以,当业务确认状态无需改变时返回同一引用能保留身份;前提是没有先原地修改 previous,否则你已经破坏了历史内容。
为什么 setState 一个新空对象仍可能更新?
新空对象有不同身份,Object.is 不会因为字段都为空就认定相同。若业务没有变化应避免无意义的新对象,而不是期待状态系统做深比较。
组件用了 memo,还需要关注这个判断吗?
需要,memo 主要处理父层传来的 props 优化,自身 state 有独立更新语义。两者比较位置不同,不能用 memo 代替正确的状态建模。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。