1. 为什么 state 改了页面却没动,这事比想象中更常见
刚接触 React 那阵子,我遇到过一个特别让人抓狂的现象:明明在控制台里打印出来的 state 已经是新值了,页面上显示的还是旧数据。刷新一下浏览器,新数据又出来了。当时我一度怀疑是浏览器缓存问题,折腾了半天才发现,问题出在我改 state 的方式上——我直接修改了原对象,React 根本没察觉到数据发生了变化。
这个坑其实非常普遍。React 的核心理念是数据驱动视图,state 变了,组件重新渲染,UI 跟着更新。但这句话有个隐含前提:React 得能“感知到” state 变了。如果你改数据的方式让 React 觉得“这跟之前一样啊”,它就不会触发重新渲染,页面自然纹丝不动。
这篇文章就是把我这些年踩过的、以及带新人时看别人踩过的坑,系统梳理一遍。我会把state 数据改变但页面不更新的 7 种典型情况逐一拆开,每一种都讲清楚三件事:为什么会这样、怎么排查、怎么改。不管你是刚学 React 的新手,还是写了几年但偶尔还会被这类问题绊一下的老手,应该都能从里面找到自己遇到过的那一款。
需要先说明的是,下面涉及的代码示例都是基于常见实践构造的模拟场景,不指向任何具体项目。React 版本以目前主流的函数组件 + Hooks 写法为主,类组件的情况我也会在相关位置补充说明,因为很多老项目还在用。
2. 先搞懂 React 判断“要不要重新渲染”的底层逻辑
在逐个拆解那 7 种情况之前,有必要先把 React 的更新机制讲明白。不理解这个,你改代码就是碰运气;理解了,很多问题一眼就能看出症结。
2.1 React 靠什么决定重新渲染
React 的更新流程大致是这样的:你调用setState(函数组件里是useState返回的 setter),React 会把这次更新排进队列,然后在合适的时机重新执行组件函数,拿到新的 JSX,再和上一次的 JSX 做对比(也就是常说的 diff),最后把差异部分更新到真实 DOM 上。
关键在于,React 判断“要不要重新执行组件函数”,很大程度上依赖于引用是否变化。对于对象、数组这类引用类型,React 比较的是内存地址,不是里面的内容。你改了对象里的某个属性,但对象本身的引用没变,React 就认为“还是同一个东西”,跳过更新。
这就像你家的门牌号没变,快递员就认为还是原来那户人家,哪怕屋里的人已经换了一拨。React 看的就是这个“门牌号”。
2.2 引用比较和浅比较的区别
这里要区分两个概念。引用比较是直接比内存地址,obj1 === obj2这种。浅比较是只比第一层属性,React 的memo、PureComponent用的就是浅比较。
很多人出问题,是因为混淆了这两者。比如你用setState传了一个新对象,但新对象和旧对象的第一层属性值完全一样,浅比较就会认为“没变化”,从而跳过渲染。这种情况在React.memo包裹的组件里特别容易遇到。
2.3 批处理和异步更新的影响
还有一个容易被忽略的点:React 的 state 更新是异步的,而且在事件处理函数里会做批处理。也就是说,你在一次事件里连续调用多次setState,React 可能只渲染一次。这本身是性能优化,但如果你在调用setState之后立刻去读 state,读到的还是旧值,就会产生“我明明改了怎么没变”的错觉。
理解了这三层机制,后面的 7 种情况就都好解释了。下面进入正题。
3. 七种 state 改了页面不更新的典型情况逐个拆解
3.1 情况一:直接修改对象或数组,引用没变
这是最常见、也是新手最容易踩的一个坑。看下面这段代码:
const [user, setUser] = useState({ name: '张三', age: 20 }); function handleChange() { user.name = '李四'; setUser(user); }你调用了setUser,传进去的还是原来那个user对象。React 拿到这个对象,一比较引用,发现跟之前是同一个,直接跳过更新。页面上的名字还是“张三”。
正确做法是创建一个新对象:
function handleChange() { setUser({ ...user, name: '李四' }); }数组同理。很多人习惯用push、splice去改数组,这些方法都是原地修改,引用不变。应该用扩展运算符或者concat、slice这类返回新数组的方法。
注意:这个问题在类组件的
this.setState里同样存在。如果你写this.state.list.push(item)然后this.setState({ list: this.state.list }),一样不会更新。
3.2 情况二:setState 传了相同的值,被 React 跳过
React 有一个优化:如果你setState传入的值和当前 state 完全相等(用Object.is比较),它会直接跳过这次更新。对于基本类型,这很直观:
const [count, setCount] = useState(0); setCount(0); // 还是 0,不触发渲染但麻烦的是对象和数组。假设你每次都传一个内容一样的新对象:
setUser({ name: '张三', age: 20 }); // 第一次有效 setUser({ name: '张三', age: 20 }); // 内容一样,但引用不同这种情况下,因为引用不同,React 其实会重新渲染。但如果你用了React.memo或者PureComponent,浅比较发现内容一样,子组件就不会重新渲染。这就是为什么有时候父组件明明更新了,子组件却不动。
排查这类问题,可以在组件里加一句console.log('render'),看看组件函数到底有没有被执行。如果执行了但页面没变,那问题可能出在子组件的 memo 上;如果压根没执行,那就是 state 更新被跳过了。
3.3 情况三:在 React.memo 或 PureComponent 下浅比较失效
React.memo会对 props 做浅比较,如果 props 没变,就跳过渲染。问题在于,如果你传的 props 里有对象或函数,每次父组件渲染都会创建新的引用,浅比较就会认为“变了”,导致 memo 失效。反过来,如果你希望它更新,但传的对象内容变了引用没变,它又会认为“没变”,导致不更新。
看这个例子:
const Child = React.memo(({ data }) => { return <div>{data.value}</div>; }); function Parent() { const [obj, setObj] = useState({ value: 1 }); function handleClick() { obj.value = 2; setObj(obj); // 引用没变,Parent 都不更新,Child 更不会更新 } return <Child data={obj} />; }这里 Parent 自己都没重新渲染,Child 自然也不会动。解决办法还是那句话:改数据要创建新引用。
如果确实需要传递深层嵌套的对象,可以考虑用useMemo缓存,或者用不可变数据方案(比如 immer)来保证每次更新都产生新引用。
3.4 情况四:useState 的初始值只在首次渲染生效
这个坑很多人踩过。useState(initialValue)里的initialValue只在组件第一次渲染时用一次,后续渲染会被忽略。如果你把 props 或者计算逻辑放在初始值里,期望它跟着变,那就错了。
function Component({ initialCount }) { const [count, setCount] = useState(initialCount); // 当 initialCount 变化时,count 不会跟着变 }如果你确实需要根据 props 同步更新 state,有几种做法。一是用useEffect监听 props 变化然后setCount;二是用key强制重新挂载组件;三是干脆把那个值做成派生值,不用 state 存。
提示:用
useEffect同步 props 到 state 时,要注意避免无限循环。依赖数组要写对,setState 的条件要判断清楚。
3.5 情况五:异步操作里读到了旧的 state
React 的 state 更新是异步的,在同一个函数里,你调用setState之后立刻读 state,读到的还是旧值。这在异步操作里特别容易出问题:
function handleClick() { setCount(count + 1); console.log(count); // 还是旧值 }更隐蔽的是在setTimeout或者 Promise 里:
function handleClick() { setTimeout(() => { setCount(count + 1); // 这里的 count 是闭包捕获的旧值 }, 1000); }如果连续点击,每次拿到的都是同一个旧 count,最后只加了一次。解决办法是用函数式更新:
setCount(prev => prev + 1);函数式更新能拿到最新的 state,不受闭包影响。这是处理依赖前一个 state 的场景时的标准做法。
3.6 情况六:多个 setState 被批处理合并
React 18 之后,批处理的范围扩大了,不仅在事件处理函数里,在 Promise、setTimeout、原生事件里也会批处理。这意味着你连续调用多次setState,React 可能只渲染一次。
function handleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1); // 最终只加 1,因为三次都基于同一个旧 count }如果你期望加 3,就得用函数式更新:
setCount(prev => prev + 1); setCount(prev => prev + 1); setCount(prev => prev + 1);批处理本身不是 bug,是性能优化。但如果你不了解它,就会觉得“我改了三次怎么只变了一次”。理解批处理,能帮你写出更符合预期的代码。
3.7 情况七:状态提升或组件通信导致更新了“另一个” state
最后一种情况比较隐蔽,问题不在 state 本身,而在数据流。比如你把 state 放在了父组件,子组件通过 props 接收,但你在子组件里改了另一个不相干的 state,然后期望父组件的数据跟着变。这显然不会发生。
还有一种情况是,你用了 Context 或者状态管理库,但更新的是 A 组件的 state,B 组件订阅的是另一份数据。数据源不统一,就会出现“我明明改了怎么没反应”。
排查这类问题,关键是理清数据流:这个数据存在哪里、谁在改、谁在读。用 React DevTools 看组件的 props 和 state 变化,能很快定位。
4. 一套可复用的排查流程和实操建议
知道了这 7 种情况,接下来更重要的是:遇到问题时怎么快速定位。我总结了一套自己的排查流程,基本能覆盖大部分场景。
4.1 从控制台日志开始定位
第一步永远是加日志。在组件函数体里加console.log('组件名 render'),在setState前后加日志打印新旧值。看组件函数有没有被执行:
- 如果没执行,说明更新被跳过了,重点查引用是否变化、是否传了相同值。
- 如果执行了但页面没变,说明问题在渲染逻辑或子组件的 memo 上。
这个简单的动作能帮你把问题范围缩小一半。
4.2 用 React DevTools 看更新链路
React DevTools 的 Components 面板能实时看到每个组件的 props 和 state。你可以勾选“Highlight updates”,这样每次组件重新渲染时,页面上对应的区域会闪一下边框。哪个组件闪了、哪个没闪,一目了然。
如果父组件闪了子组件没闪,那多半是 memo 或浅比较的问题。如果该闪的都没闪,那就是 state 更新本身没触发。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 改了对象属性页面不变 | 直接修改原对象,引用未变 | 打印对象引用 | 用扩展运算符创建新对象 |
| 连续 setState 只生效一次 | 批处理 + 闭包旧值 | 打印每次的 state | 用函数式更新 |
| 子组件不更新 | memo 浅比较认为没变 | 看 DevTools 是否闪烁 | 保证 props 引用变化 |
| 初始值不跟随 props | useState 初始值只生效一次 | 检查是否用了初始值 | 用 useEffect 同步或改 key |
| 异步里 state 是旧值 | 闭包捕获 | 打印异步里的 state | 用函数式更新或 ref |
| 数组 push 后不更新 | 原地修改 | 检查是否用了 push/splice | 用 concat/slice/扩展 |
| Context 更新不生效 | 订阅了不同的数据源 | 检查 Provider 和 Consumer | 统一数据源 |
这张表我建议存下来,遇到问题先对号入座,能省不少时间。
4.4 几个我踩过的坑和实操心得
第一个坑是关于useEffect依赖数组的。我曾经写了一个 effect,依赖里放了对象,结果每次渲染对象引用都变,effect 无限执行。后来学乖了,依赖里尽量放基本类型,或者用useMemo把对象缓存起来。
第二个坑是关于setState的合并。类组件里this.setState是浅合并,函数组件里useState是替换。这个区别我一开始没注意,导致在函数组件里想只改一个字段,结果把整个对象覆盖了。函数组件里必须手动展开旧对象。
第三个坑是关于key的。列表渲染时如果key用了索引,删除或排序后组件状态会错乱。这个虽然不是直接的“不更新”问题,但表现上很像。用唯一 id 做 key 是铁律。
5. 从根源上避免这类问题的编码习惯
与其每次出问题再排查,不如从一开始就养成好习惯。下面这几条是我这些年总结下来最有效的。
5.1 永远不要直接修改 state
这是最重要的一条。不管是对象还是数组,改 state 一律创建新引用。可以用扩展运算符、map、filter、concat这些不改变原数据的方法。如果嵌套层级深,手写展开很痛苦,可以考虑用 immer 这类库,它让你用“看起来像直接修改”的写法,底层自动生成新引用。
5.2 依赖前一个 state 时用函数式更新
只要新 state 依赖旧 state,就用setState(prev => ...)的形式。这样不管批处理怎么合并、闭包捕获的是哪个值,都能拿到最新的。这个习惯能帮你避开一大类异步和批处理的坑。
5.3 合理使用 memo,但别滥用
React.memo是优化手段,不是默认配置。用之前先确认组件渲染开销大、且 props 变化不频繁。用了之后要保证 props 的引用稳定性,否则 memo 反而成了 bug 来源。函数类型的 props 用useCallback包一下,对象类型的用useMemo。
5.4 保持数据流清晰
state 放在哪个组件、谁负责改、谁负责读,这些要提前想清楚。能用派生值算出来的,就不要用 state 存。多个组件需要共享的数据,提升到共同父组件或者用 Context。数据流清晰了,很多“改了没反应”的问题根本不会出现。
5.5 用 TypeScript 帮忙兜底
如果项目允许,上 TypeScript。它能在编译期就发现很多类型不匹配的问题,比如你传了一个readonly数组却想 push,或者 setState 的类型不对。虽然不能完全避免运行时问题,但能挡掉一部分低级错误。
6. 几个容易被忽略的边界场景
除了上面 7 种主要情况,还有几个边界场景值得单独提一下,它们不常出现,但一旦遇到就很难查。
6.1 严格模式下的双调用
React 的 StrictMode 在开发环境下会故意双调用某些函数,包括组件函数、useState 的初始化函数等。这是为了帮你发现副作用问题。如果你在组件函数里直接改 state,双调用会放大问题。看到日志打印两次不要慌,先确认是不是 StrictMode 导致的。
6.2 并发特性下的更新优先级
React 18 引入了并发特性,useTransition、useDeferredValue这些 API 会让更新有优先级之分。如果你用了这些 API,低优先级的更新可能会被高优先级的打断或延迟。表现上就是“我改了但没马上更新”。这不是 bug,是设计如此。理解优先级机制,能帮你正确使用这些 API。
6.3 服务端渲染和 hydration 不匹配
如果项目用了服务端渲染,客户端 hydration 时如果初始渲染结果和服务端不一致,React 会报错并可能丢弃部分更新。这种情况通常和随机数、日期、浏览器 API 有关。解决办法是保证首次渲染结果一致,把依赖浏览器环境的内容放到useEffect里。
6.4 第三方库直接操作 DOM
有时候你引入了某个第三方库,它直接操作了 DOM,绕过了 React 的更新机制。React 下次渲染时发现 DOM 和它记忆中的不一致,就可能出现各种奇怪现象。解决办法是用 ref 把 DOM 交给库管理,或者用库提供的 React 封装版本。
7. 写在最后:几个我常用的调试小技巧
调试这类问题,工具和技巧能帮你省很多时间。我平时用得最多的几个:
第一个是console.log配合JSON.stringify。打印对象时直接 log 看到的是引用,展开可能已经变了。用JSON.stringify(obj)能定格当时的内容,对比前后两次很直观。
第二个是给 setState 包一层。写一个自定义 hook,在 setter 里打印旧值和新值,这样每次更新都有记录,排查起来很方便。
第三个是二分法注释。当组件很大、逻辑很杂时,把怀疑的部分一段段注释掉,看问题是否还在。虽然笨,但有效。
第四个是简化复现。把出问题的组件单独抽出来,去掉无关逻辑,写一个最小复现。很多时候在简化的过程中,问题原因自己就浮现出来了。
这些技巧没什么高深的,但都是实战里磨出来的。React 的 state 更新机制设计得很巧妙,大部分“不更新”的问题,根源都在于我们没按它的规则来。理解了引用、批处理、闭包这几件事,再配合一套排查流程,这类问题基本都能快速解决。