React state 更新不渲染的七种原因与解决方案
1. 从一次线上白屏说起为什么改了 state 页面却纹丝不动刚接触 React 那会儿我遇到过一个至今印象深刻的场景。一个列表页点击加载更多按钮后控制台里明明打印出了新的数组长度从 10 变成了 20可页面上还是那 10 条数据纹丝不动。我反复刷新、反复打断点甚至怀疑是不是浏览器缓存出了问题。折腾了快两个小时最后才发现——我在更新数组时用了push而不是返回一个全新的数组。React 根本没检测到 state 发生了变化自然不会触发重新渲染。这个坑几乎每一个 React 开发者都会踩只是踩的姿势各不相同。有人是直接修改了对象属性有人是在异步回调里读到了旧值有人是用了useState却忘了它更新是异步的还有人把组件写成了纯函数却指望它自己感知变化。表面上看都是数据变了页面没变但背后的根因可能完全不同解决办法也完全不一样。这篇内容就是把这些年我遇到过的、以及带新人时反复被问到的state 改了但页面不更新的情况系统性地梳理一遍。我会把每一种情况的触发场景、底层原因、复现方式、修复方案都讲清楚并且告诉你为什么 React 要这么设计。不管你是刚学 React 的新手还是写了几年但偶尔还会被这类问题绊一下的老手应该都能从中找到自己需要的那一块。核心关键词就一个state 数据改变但页面不重新渲染我们围绕它把七种典型情况逐个拆开。需要先建立一个共识React 的重新渲染靠的不是数据变了这个事实而是state 的引用变了这个信号。理解了这句话后面七种情况你基本能自己推导出大半。2. 先搞懂 React 的更新判定逻辑不然改了也白改在逐个拆解之前有必要把 React 判断要不要重新渲染的底层机制讲透。很多人之所以反复踩坑是因为脑子里对这套机制是模糊的只能靠试错。一旦你把原理理顺很多问题根本不用查资料就能定位。2.1 setState 之后到底发生了什么当你调用setState或者useState返回的更新函数时React 并不是立刻去改数据、立刻去刷新页面。它做的事情大致分三步第一把新的值存进内部的更新队列第二安排一次调度schedule把这次更新标记为待处理第三在合适的时机通常是当前事件处理函数执行完之后批量处理这些更新重新执行组件函数生成新的虚拟 DOM再和旧的做对比最后把差异应用到真实 DOM 上。这里有个关键点React 重新执行组件函数靠的是有更新被调度了这个事件而不是它主动去监听你的数据有没有变。也就是说如果你绕过了setState直接改了一个对象或数组React 压根不知道发生了什么调度根本不会发生页面自然不动。2.2 引用比较React 判断变了的唯一标准那 React 怎么知道这次setState传进来的值算不算变了呢答案是引用比较Object.is。对于基本类型数字、字符串、布尔比较的是值本身对于对象、数组、函数这些引用类型比较的是内存地址。这就解释了一个非常常见的困惑你有一个对象{name: a}你把它改成{name: b}从内容上看确实变了但如果你是在原对象上改的内存地址没变React 一比较发现还是同一个引用于是判定没有变化跳过渲染。// 错误示范引用没变React 认为没更新 const [user, setUser] useState({ name: a }); user.name b; setUser(user); // 传进去的还是同一个对象引用 // 正确示范返回新对象引用变了 setUser({ ...user, name: b });注意React 官方文档里反复强调 state 要当作不可变数据immutable来对待根本原因就在这里。不是 React 挑剔而是它靠引用比较来保证性能——如果每次都做深比较大对象会非常慢。2.3 批量更新与异步为什么你读到的还是旧值另一个高频困惑是setState之后马上console.log打印出来的还是旧值。这不是 bug而是设计。React 在事件处理函数中会把多次setState合并成一次批量更新而且更新是异步生效的。你在同一个函数里连续调用两次setCount(count 1)两次读到的count都是同一个旧值最终只加了一次。// 期望加 2实际只加了 1 setCount(count 1); setCount(count 1); // 正确写法用函数式更新拿到最新的前一个值 setCount(prev prev 1); setCount(prev prev 1);理解了这三层机制——调度靠 setState 触发、变化判定靠引用比较、更新是异步批量的——你就有了排查所有页面不更新问题的底层坐标系。接下来七种情况本质上都是在这三个环节里出了岔子。3. 七种改了不渲染的典型情况逐个拆解下面这七种情况我按踩坑频率从高到低排列。每一种我都会给出可复现的代码、根因分析和修复方案你可以对照自己的代码逐个排查。3.1 直接修改对象或数组引用没变这是出现频率最高的一种没有之一。典型表现是用push、splice、sort、reverse改数组或者用obj.key value改对象属性然后调用setState把原对象传回去。// 错误直接 push引用没变 const [list, setList] useState([1, 2, 3]); const addItem () { list.push(4); setList(list); // 同一个引用不渲染 }; // 正确返回新数组 const addItem () { setList([...list, 4]); };根因就是 2.2 节讲的引用比较。修复思路是永远返回新对象/新数组。常用的不可变操作有数组用扩展运算符、concat、slice、map、filter对象用扩展运算符、Object.assign。对于嵌套层级较深的数据手写扩展运算符会很痛苦这时候可以考虑用immer这类库它允许你用看起来像直接修改的写法底层自动生成新的不可变数据。实操心得我在团队里推过一个简单规则——只要看到setXxx的参数和传进来的变量是同一个名字就要警惕。比如setList(list)这种写法十有八九有问题除非你确定list本身就是个新引用。3.2 在异步回调或定时器里读到了闭包旧值第二种高频情况发生在异步场景。你在useEffect或者setTimeout里读取 state发现拿到的永远是初始值。这不是 state 没更新而是闭包捕获了旧的那次渲染的变量。const [count, setCount] useState(0); useEffect(() { const timer setInterval(() { console.log(count); // 永远是 0 }, 1000); return () clearInterval(timer); }, []); // 依赖数组为空闭包锁死在初始渲染根因是每次渲染都会生成一份新的count变量而useEffect的依赖数组为空意味着这个 effect 只在首次渲染后执行一次它内部的回调捕获的是首次渲染时的那份count后续更新它看不到。修复方式有两种。一是把count加进依赖数组让 effect 在count变化时重新执行二是用useRef保存一个最新值引用或者用函数式更新setCount(prev ...)来避免直接读取闭包里的值。// 方案一依赖数组补全 useEffect(() { const timer setInterval(() console.log(count), 1000); return () clearInterval(timer); }, [count]); // 方案二用 ref 保存最新值 const countRef useRef(count); useEffect(() { countRef.current count; });3.3 更新的是嵌套对象的深层属性当你有一个多层嵌套的 state比如{ user: { profile: { name: a } } }你想改name如果只做了浅拷贝深层引用还是旧的React 依然可能判定没变。// 错误只拷贝了最外层 setState({ ...state, user: { ...state.user, profile: { name: b } } }); // 上面这个其实是对的因为每一层都新建了 // 真正容易错的是这种 state.user.profile.name b; setState({ ...state }); // 外层新了但 user.profile 还是旧引用这里要区分清楚React 只比较你传给setState的那个顶层引用。如果你传的是{ ...state }顶层确实变了React 会重新渲染。但问题在于如果子组件用了React.memo或者shouldComponentUpdate做了浅比较它拿到的user引用没变就会跳过渲染导致父组件渲染了但子组件没更新。修复原则是要改哪一层就把从根到那一层的路径上所有对象都新建一遍。层级深了确实繁琐这也是immer这类工具存在的意义。3.4 在 render 期间直接改 state有些人为了根据 props 计算 state会在组件函数体里直接调用setState结果要么死循环要么行为诡异。// 错误render 期间改 state function Comp({ value }) { const [derived, setDerived] useState(0); setDerived(value * 2); // 每次渲染都调用触发无限循环 return div{derived}/div; }根因是setState会触发重新渲染而重新渲染又会执行到这一行再次setState形成死循环。React 会检测到并抛出Too many re-renders的错误。正确的做法是如果这个值能从 props 直接算出来就不要放进 state直接在渲染时计算如果确实需要缓存用useMemo。// 正确直接计算 function Comp({ value }) { const derived value * 2; return div{derived}/div; }3.5 组件被 memo 包裹但 props 比较失效React.memo会对 props 做浅比较如果比较结果相等就跳过渲染。问题在于如果你传了一个每次渲染都新建的对象、数组或函数浅比较永远不相等memo 就失效了反过来如果你传的对象内容变了但引用没变memo 又会错误地跳过更新。// 父组件 Child config{{ size: 10 }} / // 每次渲染都是新对象memo 失效 // 或者 const config useRef({ size: 10 }); config.current.size 20; // 改了内容引用没变 Child config{config.current} / // memo 认为没变跳过渲染修复方式对于函数用useCallback包裹对于对象和数组用useMemo缓存对于确实需要内容变了就更新的场景给React.memo传第二个参数自定义比较函数。const config useMemo(() ({ size }), [size]); Child config{config} /3.6 状态提升不到位兄弟组件各持一份这种情况比较隐蔽。你把同一份数据在两个组件里各存了一份 stateA 组件更新了自己的B 组件那份没动于是 B 显示的还是旧数据。表面看是B 没更新实际是数据源分裂了。function Parent() { return ( ChildA / {/* 内部有自己的 count */} ChildB / {/* 内部也有自己的 count */} / ); }修复思路是状态提升把共享的 state 提到最近的公共父组件通过 props 往下传通过回调往上改。这样数据只有一份任何一处更新所有用到它的组件都会重新渲染。function Parent() { const [count, setCount] useState(0); return ( ChildA count{count} onChange{setCount} / ChildB count{count} / / ); }3.7 在类组件里误用 this.setState 或直接改 this.state虽然现在函数组件是主流但维护老项目时类组件的坑还是绕不开。最常见的两个错误一是直接this.state.count 1二是this.setState里传了原对象。// 错误一直接改 this.state this.state.count 1; // React 完全不知道 // 错误二setState 传原对象 this.setState({ list: this.state.list }); // 引用没变 // 正确 this.setState({ count: 1 }); this.setState(prev ({ list: [...prev.list, newItem] }));另外类组件里setState也是异步批量的如果你想在更新后拿到最新值要用setState的第二个参数回调或者在componentDidUpdate里处理。this.setState({ count: 1 }, () { console.log(this.state.count); // 这里才是最新值 });4. 一张排查表从现象快速定位到根因七种情况讲完你可能会觉得道理都懂但真遇到问题时还是不知道从哪下手。所以我把这些情况整理成一张排查表按现象来索引方便你在实际调试时快速对号入座。现象最可能的根因快速验证方法修复方向数组 push 后不更新引用未变打印新旧引用是否相等返回新数组异步回调里读到旧值闭包捕获在回调里打印 state补依赖或用 ref父组件更新子组件不动memo 浅比较检查 props 引用useMemo/useCallback深层属性改了不生效浅拷贝不彻底逐层打印引用逐层新建或 immer控制台报 Too many re-rendersrender 期间 setState看报错栈改为直接计算两个组件数据不同步状态分裂检查 state 定义位置状态提升类组件改了没反应直接改 this.state检查赋值方式用 setState这张表我建议你截图存下来遇到问题时先扫一眼现象列基本能锁定方向。当然实际项目里问题往往是复合的比如闭包旧值叠加引用未变这时候就要结合 2 节的底层机制去分析。提示调试这类问题时console.log打印引用地址直接打印对象浏览器会显示引用比打印内容更有用。如果两次打印的对象在控制台里是同一个可展开的引用基本就能确认是引用没变。5. 几个能少走弯路的实操习惯原理和排查方法都讲完了最后分享几个我在实际项目里养成的习惯能帮你从源头上减少这类问题。第一把 state 设计得尽量扁平。嵌套层级越深不可变更新越痛苦出错的概率越高。如果数据结构天然复杂考虑用useReducer集中管理或者引入immer降低心智负担。第二能用派生值就不要用 state。很多数据不同步的问题根源是把本可以直接算出来的东西存成了 state。记住一句话能从现有 state 或 props 算出来的就不要单独存一份。第三给关键更新加断言。在开发环境里我习惯在setState前加一句console.assert或者简单的引用检查确保传进去的确实是新引用。上线前再移除成本很低但能拦住不少低级错误。第四善用 React DevTools 的 Highlight updates 功能。它能高亮显示哪些组件在重新渲染一眼就能看出该渲染的没渲染还是不该渲染的渲染了比盲猜高效得多。第五遇到诡异问题时先怀疑引用再怀疑闭包。这两个是绝大多数页面不更新问题的元凶按这个顺序排查命中率很高。这些习惯看起来都是小事但积累下来能让你在写 React 时少熬很多个夜晚。state 更新不渲染这个问题本质上不是 React 难而是它的更新模型和很多人的直觉不一致。一旦你把引用比较 异步批量 闭包捕获这三件事刻进脑子里再回头看那些曾经让你抓狂的 bug会发现它们其实都挺讲道理的。