十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

React对象状态更新指南:从useState到immer与状态归一化

React对象状态更新指南:从useState到immer与状态归一化 在 React 项目里要说哪个问题最值得被反复提醒我提名对象状态。useState 存了一个对象然后直接改对象的某个属性页面纹丝不动或者表单里输入一个字符输入框卡顿、丢字再或者数据明明变了列表就是不刷新。这些问题我几乎每次带新人都能遇到它们往往不是 React 本身有多难而是没搞懂一件事对象是引用类型状态更新必须返回一个“新对象”。这篇内容就围绕对象状态这一主题展开。我会从 React 的更新机制讲起把 useState 的正确写法、嵌套对象的更新套路、useReducer 和 immer 的适用场景、状态归一化与性能优化一次理清最后放一段真实项目里的重构案例和排查经验。不管你是刚学 React 的新手还是已经写了一阵子但在复杂状态上总翻车的老手这篇文章都值得当作一份可以直接上手的参考来用。1. 先搞懂一件事对象状态为什么不会自动更新1.1 React 的状态机制setState 不是“改数据”而是“换新值”很多同学在写 React 时大脑里还是 JQuery 时代“改了数据页面就同步”的惯性。但在 React 里state 是触发渲染的开关useState返回的setXxx并不是把数据改掉那么简单它的核心逻辑是告诉 React“我的状态值变了请重新执行组件函数”。然后 React 会拿新的状态值和旧的状态值做一次比较如果判断“变了”就重新渲染组件如果判断“没变”就什么都不做。这个判断用的不是也不是而是Object.is。对基本类型来说1和1永远相等数字变了就是变了但对象是引用类型两个变量只要指向同一个内存地址它们就永远被认为是“同一个值”。所以在 React 眼里user.name 李四这样的操作根本不算状态变化。对象的地址没变引用没变React 自然认为没东西需要更新。这一点是整个对象状态问题的根源也是所有“页面不刷新”现象的幕后黑手。1.2 引用类型才是罪魁祸首我们用一段代码演示一下最经典的翻车现场const [user, setUser] useState({ name: 张三, age: 25 }); function handleUpdate() { // 错误直接修改了对象属性然后想把“同一个对象”塞回去 user.name 李四; setUser(user); // 页面不会有任何变化 }这段代码看起来好像没毛病改了名字又把 user 交给了 setUser。但实际上setUser(user)传入的 user 和当前 state 里的 user 是同一个对象引用React 在更新队列里一对比发现引用完全一致就直接跳过了这次更新。你可能会在控制台打印看到 user.name 确实变成了“李四”但 UI 就是不刷新这就是典型的“数据变了视图没变”。再往深一层说如果某个子组件通过 props 接收了这个 user 对象并且用React.memo做了优化它更不会重新渲染。因为 props 的引用也没变memo 的比较直接返回了“相等”。这个问题在多人协作的代码里特别隐蔽因为不是每次都会立刻被发现而是等到“某个模块数据不同步”时才暴露出来。1.3 那什么叫不可变更新理解了引用问题之后不可变更新的含义就很清楚了不要修改原对象而是创建一个新对象把需要保留的数据展开进去再修改或新增目标字段。用生活化的比喻来说就是在复印件上改字而不是在原件上涂改React 需要看到“这是一个新文档”它才愿意重新走一遍流程。setUser({ ...user, // 先把原来的字段全部展开 name: 李四, // 再覆盖需要改的字段 });这段代码每次执行都会生成一个全新的对象React 对比新旧引用发现不是同一个对象于是认定状态发生了变化组件重新渲染。这也是 React 官方文档一直强调“把 state 当作只读”的根本原因。后面我们提到的所有方案本质都是围绕“如何高效、不易出错地产生新对象”来展开的。2. 从基本功开始useState 对象更新的正确写法2.1 展开运算符最常用的“换新”姿势对象状态最简单的更新方式就是用展开运算符生成新对象。注意展开的顺序很关键它直接决定覆盖方向// 这两种写法结果完全不一样 setUser({ age: 30, ...user }); // age 会被 user 里的 age 覆盖最终 age 还是旧值 setUser({ ...user, age: 30 }); // 先把 user 展开age 再覆盖这才是想要的我自己见过不少次因为展开顺序写反导致字段更新无效的 bug尤其是从后端接口拿数据后回填表单的场景。所以我的习惯是展开运算符永远写前面要修改的字段写后面统一格式避免团队里每个人写法不同。还有一个容易忽略的点展开运算符只做一层浅拷贝。如果对象里还有对象内层对象仍然是引用。比如const [profile, setProfile] useState({ user: { name: 张三, address: { city: 杭州 }, }, }); // 错误只展开外层内层 user 还是同一个引用 setProfile({ ...profile, user: { ...profile.user, address: profile.user.address } });这种浅拷贝带来的问题会在下一节嵌套对象里集中爆发。这里先记住结论改第几层就要展开到第几层。2.2 函数式更新什么时候必须用 setXxx(prev ...)setXxx除了直接传值还可以传一个函数函数接收上一个状态返回新状态setUser((prev) ({ ...prev, age: prev.age 1 }));函数式更新最大的价值在于你操作的不再是闭包捕获的旧值而是 React 保证的最新值。看一个典型场景快速连点按钮function handleClick() { // 写法一基于闭包里的 user两次更新用的是同一个旧值 setUser({ ...user, count: user.count 1 }); setUser({ ...user, count: user.count 1 }); // 结果count 只加了 1因为第二次覆盖了第一次 // 写法二函数式更新 setUser((prev) ({ ...prev, count: prev.count 1 })); setUser((prev) ({ ...prev, count: prev.count 1 })); // 结果count 加了 2每次都基于最新值 }另外在setTimeout、事件监听、异步回调里更新状态时函数式更新也是更稳妥的选择。因为闭包里捕获的 user 可能是组件某次渲染时的旧值而异步回调执行时最新状态可能已经变了。我处理定时器倒计时、请求返回后更新数据时一律用函数式写法踩过一次“状态不同步”的坑之后就长记性了。2.3 嵌套对象怎么处理逐层展开的完整示例嵌套对象是对象状态里最磨人的点。假设我要改三层结构里的 city 字段const [profile, setProfile] useState({ user: { name: 张三, address: { city: 杭州, district: 西湖区 }, }, }); setProfile((prev) ({ ...prev, user: { ...prev.user, address: { ...prev.user.address, city: 上海, }, }, }));这个写法是正确的但确实不好看也很容易漏掉某一层展开。我见过最典型的错误是只展开了前两层内层 address 还是旧引用结果 UI 里城市变了、区县却没跟着刷新或者两个地方数据不一致。如果你发现自己的项目里大量出现这种五层、六层的连续展开不要硬扛这通常是重构信号。我的建议是优先考虑后面要讲的 useReducer、immer 或者状态归一化。嵌套对象不是不能写而是超过三层以后出错的概率会呈指数级上升代码的可读性也会断崖式下降。操作是否影响原对象典型写法直接修改属性是且不触发更新user.name 李四浅拷贝展开否但内层仍是引用{ ...user }逐层展开否内层也换新{ ...prev, user: { ...prev.user } }深拷贝否但成本高JSON.parse(JSON.stringify(obj))这里顺便说一句不要为了方便在 setState 里用JSON.parse(JSON.stringify(obj))做深拷贝。一来性能差二来会把undefined、Date、函数这类值处理得乱七八糟。深拷贝不是解决不可变更新的正确思路后面讲的 immer 才是更优雅的解法。3. 应对复杂对象状态的三种进阶方案3.1 useReducer把状态操作收口到一个个 action当对象状态有多个字段、且字段之间存在联动关系时散落一地的setState很快就会失控。比如一个购物车有用户信息、商品列表、优惠券、备注每个操作都要记住展开运算符的写法改着改着就漏字段。这时候可以换成useReducer。它把“状态长什么样”和“状态怎么变”分开管理所有的修改逻辑都收口在 reducer 函数里组件里只负责派发 actionconst initialState { user: null, items: [], coupon: null, remark: , }; function cartReducer(state, action) { switch (action.type) { case SET_USER: return { ...state, user: action.payload }; case ADD_ITEM: return { ...state, items: [...state.items, action.payload] }; case UPDATE_ITEM_QTY: return { ...state, items: state.items.map((item) item.id action.payload.id ? { ...item, qty: action.payload.qty } : item ), }; case SET_COUPON: return { ...state, coupon: action.payload }; case SET_REMARK: return { ...state, remark: action.payload }; default: return state; } } // 组件里 const [state, dispatch] useReducer(cartReducer, initialState); dispatch({ type: ADD_ITEM, payload: { id: 1, name: 咖啡, qty: 1 } }); dispatch({ type: SET_COUPON, payload: SAVE50 });useReducer 最大的收益不是代码量变少而是状态变更逻辑变得可预测、可测试。每个 action 都是一个语义化的事件reducer 是纯函数同样的输入永远得到同样的输出。排查问题时只需要看 action 类型和对应的分支逻辑不需要在十几个 setState 里猜来猜去。什么时候该从 useState 切到 useReducer我的判断标准是当同一个状态对象里有两个以上字段需要同步更新或者某个更新逻辑要在多个组件里复用就值得切了。纯粹为了少写几行代码去用 useReducer反而有点过度设计。3.2 immer像改普通对象一样改嵌套状态如果嵌套对象实在绕不开把 immer 引入项目是性价比很高的选择。immer 提供了一个produce函数你可以在它的回调里像修改普通对象一样直接改“草稿”它会自动帮你生成一份新对象import { produce } from immer; // 用法一配合 setState setProfile( produce((draft) { draft.user.address.city 上海; draft.user.address.district 浦东新区; draft.items.push({ id: 3, name: 新商品 }); }) );注意这里没有写展开运算符没有逐层拷贝直接改 draft 的属性就行。immer 内部通过 Proxy 拦截了所有修改操作记录变更路径最终输出一个全新的不可变对象原对象完全不受影响。用 immer 之后代码的可读性和 Spring 全家桶里的持久层操作有点像逻辑一顺到底。它的性能也足够好不是无脑深拷贝而是结构共享只复制变更路径上的节点其余部分原样复用。实测下来在普通业务页面里性能开销可以忽略不计。// 用法二在 reducer 里配合使用 function cartReducer(state, action) { return produce(state, (draft) { switch (action.type) { case ADD_ITEM: draft.items.push(action.payload); break; case UPDATE_ITEM_QTY: const item draft.items.find((i) i.id action.payload.id); if (item) item.qty action.payload.qty; break; case SET_COUPON: draft.coupon action.payload; break; } }); }这一版 reducer 的直观程度比纯展开运算符高了一个量级。这里要补充一句immer 不是必须的。它只是一把好用的工具核心还是不可变更新的思想。如果你的状态嵌套不超过两层用原生展开完全够用一旦嵌套变深、操作变多或者有大量数组元素的增删改再考虑上 immer收益最大。3.3 状态归一化从根上消灭深层嵌套前端状态里很多深层嵌套其实是被后端接口结构“带偏”了。接口返回一个店铺对象里面有商品数组商品里又有评论、标签整个状态树越挂越深更新任何一个底层节点都要经历一场展开运算符的马拉松。状态归一化的思路是把嵌套结构改成扁平结构用 id 作为索引。这是从 Redux 官方文档的 Normalizr 方案里演变出来的实践核心思想是“数据存储时尽量平铺关联关系用 id 来表达展示时再做组装”。// 归一化之前 const shop { id: 1, name: 咖啡店, products: [ { id: 101, name: 拿铁, tags: [{ id: 9, name: 热销 }] }, { id: 102, name: 美式, tags: [{ id: 10, name: 新品 }] }, ], }; // 归一化之后 const state { shop: { id: 1, name: 咖啡店 }, products: { 101: { id: 101, name: 拿铁, tagIds: [9] }, 102: { id: 102, name: 美式, tagIds: [10] }, }, tags: { 9: { id: 9, name: 热销 }, 10: { id: 10, name: 新品 }, }, productIds: [101, 102], };归一化带来的直接影响是更新某个商品、某个标签时只需要改一层对象不再需要一层层展开。副作用也很明显存储结构变“丑”了展示前需要做一次组装。如果项目里没有复杂的数据关系或者接口字段本来就不深归一化是过度设计。它适合数据量大、引用关系多、需要缓存和跨模块共享状态的场景。3.4 引用稳定性与性能memo、useMemo 和 useCallback 的正确用法对象状态更新还有个连带话题引用稳定性。每次组件渲染时函数体里新创建的对象、数组、函数都是全新的引用。如果把这些直接传给子组件即使数据“看起来一样”子组件也会因为 props 引用变化而重新渲染。最经典的问题是把一个固定配置对象直接写在组件里function Parent() { const config { theme: dark, size: md }; // 每次 render 都是新对象 return Child config{config} /; }即使 config 的内容永远不变只要 Parent 重新渲染config 的引用就会变Child 如果用 React.memo 包裹也会被重新渲染。正确的做法是把不依赖组件内部状态的配置对象放到组件外面或者用 useMemo 缓存const CONFIG { theme: dark, size: md }; // 移到组件外 // 或者 const config useMemo(() ({ theme: dark, size: md }), []);同理传给子组件的回调函数如果依赖某个对象状态需要配合 useCallback 和 useMemo 一起用否则每次状态更新都会生成新函数缓存对象也会失效。这里的关键不是把所有东西都缓存而是搞清楚只有当你明确知道子组件渲染成本高、或者子组件内部依赖了引用比较时才需要做这些优化。否则为了性能优化写一堆 useMemo反而是另一种代码负担。我的习惯是先用 React.memo 包裹高频更新的子组件再用 useMemo/useCallback 固定那些“不依赖状态变化”的 props。先测量再优化不要凭空猜性能问题。4. 高频翻车现场对象状态出错排查实录4.1 直接改对象页面没有任何反应这是最经典的问题前面分析过原因这里再给一个排查套路。当页面不刷新时先看一眼你更新状态的方式// 错误的三种典型写法 user.name 李四; setUser(user); // 引用没变 const newUser user; newUser.name 李四; setUser(newUser); // 还是同一个引用 arr.push(newItem); setArr(arr); // 数组也一样引用没变排查方法很简单更新前后各打印一次对象引用看是否发生变化。写一个辅助函数也好或者在 setState 前后加一行console.log重点看两次输出的对象地址是否相同。如果相同就是没按不可变更新来写。console.log(before, user); setUser({ ...user, name: 李四 }); console.log(after, user);在浏览器控制台里展开对象时要注意控制台展示的可能是展开时的最新值所以最好用JSON.stringify打印避免干扰判断。4.2 连续 setState 只生效了最后一次React 18 之后在事件处理函数里的多个 setState 会被自动批处理batching也就是合并成一次渲染。这本身是性能优化但如果代码里依赖了“先改 A 再立刻读 A”就会踩坑function handleClick() { setCount((c) c 1); setCount((c) c 1); console.log(count); // 打印出来的还是旧值因为渲染还没发生 }setState 不是同步的它更像是一个请求提交给 ReactReact 决定什么时候真正更新。想在更新后立刻读取最新状态不要依赖同步逻辑而是放在 useEffect 里监听或者干脆用函数式更新确保每次都基于最新值计算。如果业务场景确实需要在批处理中间同步读出 DOM比如某些第三方图表需要拿最新数据重绘可以用 React 的flushSyncimport { flushSync } from react-dom; flushSync(() { setCount((c) c 1); }); // 这里再读取相关状态就是最新的了不过flushSync会破坏批处理的性能优化能不用就不用它不是用来解决业务逻辑顺序问题的常规方案。4.3 受控表单输入丢字、光标乱跳表单里出现输入一个字光标跳到末尾、或者输入框内容被丢掉大概率是 onChange 里对对象状态的更新不完整。看这段代码const [form, setForm] useState({ name: , address: { city: } }); function handleChange(e) { // 错误只覆盖了第一层属性 setForm({ ...form, [e.target.name]: e.target.value }); } // 如果这里想改 form.address.city就会直接丢字段受控组件里输入框的 value 直接来自 React 状态渲染值由状态决定。一旦 setState 更新了状态但没有保留原本存在的字段比如把 address 丢了组件会用 undefined 去渲染浏览器自然就会出现不可预测的表现。排查这类问题最快的方式是看 setState 之后整个对象里到底保留了哪些字段大多数丢字问题都出在漏展开。正确的做法是专门为嵌套字段写更新函数或者直接用 immer 处理。在表单场景里字段一多immer 的收益会非常明显因为这正是逐层展开最容易出错的地方。4.4 数组对象状态的增删改细节对象状态里经常混着数组数组的不可变更新也有固定套路很多同学踩坑是因为惯性用了 push、splice// 错误 items.push(newItem); setItems(items); // 正确 setItems((prev) [...prev, newItem]); // 删除某项 setItems((prev) prev.filter((item) item.id ! targetId)); // 修改其中一项 setItems((prev) prev.map((item) item.id targetId ? { ...item, qty: newQty } : item ) );一句话总结当你知道目标是“新增/替换/删除数组元素”时先用 push、splice 这类会改变原数组的方法换成 filter、map、展开运算符。如果整段代码是在 immer 的 produce 里操作 draft那 push 和 splice 又可以放心用了因为 draft 的修改是被 immer 转换的不会污染原状态。4.5 用 React DevTools 和一段小钩子快速定位引用问题排查引用是否变化我常用的两个手段。第一个是 React DevTools 的高亮渲染功能在设置里打开 Highlight updates when components render刷新页面后操作 UI哪些组件被重新渲染会高亮显示。如果改了状态但组件没高亮说明状态更新没有导致预期渲染大概率是引用没变。第二个是写一个usePrevious小钩子用来对比渲染前后的状态值function usePrevious(value) { const ref useRef(); useEffect(() { ref.current value; }, [value]); return ref.current; } // 组件里 const prevUser usePrevious(user); useEffect(() { if (prevUser prevUser ! user) { console.log(user 引用变化了); } }, [user]);这种排查方式比较直接可以看到每一次渲染前后引用的变化情况定位是哪个环节的更新没有产生新对象。5. 实战复盘一次购物车对象状态的重构过程5.1 问题代码散落的属性更新与丢失的字段之前接手过一个购物车页面状态是一个多层嵌套对象用户信息、商品列表、优惠券、收货地址、备注。原始代码里全是这种写法// 变更优惠券 setCart({ ...cart, coupon: selectedCoupon }); // 变更商品数量 setCart({ ...cart, items: cart.items.map((item) item.id id ? { ...item, qty: qty } : item ), }); // 变更备注 setCart({ ...cart, remark: text });表面上看功能都运行但有几个隐患已经冒出来了一是某次改动里漏了展开cart.address导致地址在切换商品后直接变空二是三个不同的 setState 散落在不同的事件处理函数里阅读代码时完全看不出“购物车状态有哪些合法操作”三是任何一次更新都要从头展开整个 cart新增字段时很容易遗漏。这种代码最可怕的地方在于它看起来能跑但每次改动都是在雷区里走路。只要有人往 cart 里加了一个新字段忘了在所有 setState 里补展开就会出现一个只有特定路径才会触发的幽灵 bug。5.2 重构方案useReducer 清晰 action 设计重构的第一步是把状态的变更操作统一收敛成 actionconst initialState { user: null, items: [], coupon: null, address: null, remark: , }; function cartReducer(state, action) { switch (action.type) { case ADD_ITEM: return { ...state, items: [...state.items, action.payload] }; case REMOVE_ITEM: return { ...state, items: state.items.filter((i) i.id ! action.payload) }; case UPDATE_QTY: return { ...state, items: state.items.map((item) item.id action.payload.id ? { ...item, qty: action.payload.qty } : item ), }; case SET_COUPON: return { ...state, coupon: action.payload }; case SET_ADDRESS: return { ...state, address: action.payload }; case SET_REMARK: return { ...state, remark: action.payload }; default: return state; } }组件内部所有对 cart 的操作统一变成 dispatchdispatch({ type: UPDATE_QTY, payload: { id: 3, qty: 2 } }); dispatch({ type: SET_COUPON, payload: SAVE50 }); dispatch({ type: SET_ADDRESS, payload: newAddress });重构之后最明显的变化所有修改状态的入口集中在一处action 类型就是一份“状态操作清单”新增字段只需要在 reducer 里对应分支处理不再需要满屏找 setCart。某次出现字段丢失问题直接看 reducer 的返回逻辑就能定位不会再从组件里一行行查展开写法。由于购物车逻辑本身不算特别深我这次没有引入 immer用原生展开已经足够。如果当时嵌套再深一层或者数组操作更频繁我会选择在 reducer 里套produce两者并不冲突。5.3 从这次重构里提炼的选型经验复盘之后我把对象状态方案选型的思路总结成一条判断路径可以作为日常开发时的一个参考状态复杂度推荐方案典型信号字段少、嵌套浅useState 展开运算符一个对象两三个字段每次改一个字段联动、操作多useReducer同一状态多个字段需要同步变更多层嵌套、数组频繁改动useReducer immer展开运算符出现三层以上或大量 map/filter数据关系复杂、跨模块共享状态归一化 状态库多个实体互相引用需要缓存和查找选型没有标准答案关键是识别信号。如果你的代码里每次 setState 都要写一大段展开运算符并且经常担心漏字段这就是切方案的时机。最后说一点我自己在实际项目里养成的两个习惯。第一团队协作时我会在代码规范里加一条规则禁止在组件里直接对 state 对象做属性赋值必须通过 setState 生成新引用。第二新项目一上来就装 immer 并不丢人它和 lodash 一样是通用工具但前提是团队先理解不可变更新的原理而不是只会调用 API。理解了原理再选工具这才是安全的路径。
返回列表