ARTICLE DETAIL

资讯详情

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

React 消息数组拼接与显示:不可变数据、key 管理与性能优化全攻略

React 消息数组拼接与显示:不可变数据、key 管理与性能优化全攻略 做前端时间写一个实时通知面板遇到一个很诡异的现象后端消息推过来了我明明把数据 push 进了数组页面就是纹丝不动。排查了半天发现是自己直接修改了 state 里的数组React 根本不知道数据变了。从那以后我对“React 消息数组拼接与显示”这件事特别敏感因为这里面藏着的坑不踩一次真的记不住。这篇文章就围绕 React 里消息数组的拼接与显示从不可变数据、key 管理、渲染性能到分页加载的竞态处理完整拆一遍。不管你是刚开始写 React 的小白还是写了一阵子但总被列表渲染的奇怪问题折磨的开发者看完应该都能少走不少弯路。1. 消息数组拼接React状态更新的第一道坎1.1 先搞清楚 React 什么时候才会重新渲染很多人写 React 写了一段时间对“setState 之后页面会更新”这件事习以为常但从来没想过 React 到底怎么判断“要不要更新”。这个底层机制恰恰是消息数组各种诡异问题的根源。React 的更新逻辑核心是一个叫“状态快照”的东西。你可以把每一次渲染想象成给页面拍了一张照片state 的值就是这张照片里的内容。而每次 setState 做的事情是告诉 React“我这边的数据变了请重新拍一张照片。”问题来了——它怎么知道数据变了答案很简单对比引用。React 会拿新 state 和旧 state 做比较如果新旧值的引用不同它就认为数据发生了变化然后触发重新渲染。注意是“引用不同”不是“内容不同”。我打一个比方。你租了一间房子房东React只关心你住的是哪一间引用不关心你往屋里搬了什么家具内容。你如果还是住在原来的房间只是在屋里添置了几件摆设房东根本不会来检查。可如果你换了一间房哪怕里面的东西一模一样房东也会觉得“哦换了”然后重新登记一遍。这就解释了为什么直接往数组里 push 新的消息根本不会触发渲染数组还是那个数组引用没有变React 不知道你往里面塞了东西。1.2 为什么直接 push 消息数组不会触发更新我见过不少初学者写出类似这样的代码const [messages, setMessages] useState([]); const handleNewMessage (msg) { messages.push(msg); // 什么也不做或者以为 push 就会触发更新 };这里有几种情况会翻车。第一种push 之后什么都不调用。那 React 完全感知不到变化页面自然一动不动。有人会想我 push 的是 state 数组我修改了 stateReact 总该知道吧不好意思它不知道。React 对 state 的认知是“通过 setState 方法提交新值”而不是“谁动了 state 变量”。因为你 push 的时候state 变量的引用没有变化React 拿新旧引用一比较发现是同一个直接跳过更新。第二种push 之后又调用了 setMessages(messages)。这种写法更隐蔽因为看起来我确实调了 setState。但问题是传进去的数组还是原来那个数组引用完全一样。React 内部会用 Object.is 做一次比较发现旧值和新值引用一致依然不会重新渲染。这就好比你把同一份文件交给老板老板扫一眼觉得“这不是上一份嘛”直接不审批了。第三种在事件处理里 push 了然后 setState 用的是另一个新数组。这种情况页面能更新但你已经绕了一个大圈子而且一不小心就会踩到“改原 state 后引发不可预测 bug”的雷比如在 StrictMode 下组件被渲染两次消息重复。所以遇到“消息数组拼接后页面不更新”第一件事就是检查有没有直接修改原数组。如果确认没用 push再往下查 setState 有没有被正确调用。绝大多数情况下问题就出在这一层。2. 四种消息拼接方案对比从push到Immer2.1 push、concat与展开运算符消息拼接选哪个既然直接 push 不好使那正确的拼接姿势是什么我在实战中见过四种常见方案各有各的适用场景这里把它们摆在一起对比一下。第一种是 concatsetMessages((prev) prev.concat(newMessage));concat 方法会返回一个全新的数组原数组不受影响。因为新数组的引用和旧数组不一样React 能够识别变化并触发渲染。这个方案简单可靠是纯函数式的。第二种是展开运算符spreadsetMessages((prev) [...prev, newMessage]);这是我个人用得最多的方式。它和 concat 效果基本一致但更灵活比如可以很方便地在头部插入setMessages((prev) [newMessage, ...prev]);第三种是直接创建一个新数组setMessages((prev) prev.slice().concat(newMessage));不如 spread 简洁一般不会优先选择。第四种是使用 Immer 的 produceimport { produce } from immer; setMessages((prev) produce(prev, (draft) { draft.push(newMessage); }) );Immer 的底层原理是“Copy-on-Write”你写代码的时候以为自己在修改原来的数组但 Immer 在后台帮你拷贝了一份草稿所有修改都发生在拷贝上最后返回一个全新的不可变对象。这种方式对复杂嵌套结构特别友好比如消息里还带着子评论、样式配置这些深层数据手动展开运算符拼到怀疑人生的时候Immer 能救你一命。选择标准其实很简单普通拼接用 spread嵌套深用 Immer追求严谨用 concat。三个都能 work别在一棵树上吊死。2.2 函数式更新setMessages里那个prev到底有多重要我在代码评审的时候经常看到有人这样写const handleNewMessage (msg) { const nextMessages [...messages, msg]; setMessages(nextMessages); };这段代码本身没错在大多数场景下也能工作。但它有一个隐患它依赖当前渲染周期里的 messages 变量。如果在这个事件处理函数执行之前组件已经被重新渲染过多次或者消息是连续快速到达的那你拿到的 messages 可能是旧的拼出来就是错的。正确的做法是用函数式更新const handleNewMessage (msg) { setMessages((prev) [...prev, msg]); };注意区别这里 setMessages 传的是一个函数React 在调度的时 候会把“当前最新的 state”作为参数传给你的函数然后你基于这个最新值返回新数组。这样无论消息来得多快、组件渲染了多少次你拿到的 prev 永远是当前状态下的最新值。有人可能要问这两者差别有那么大吗答案是在“连续快速触发”的场景下差别很大。想象一下 WebSocket 推送消息一秒来了十几条每一条都触发一次 handleNewMessage。如果用闭包里的 messages 变量第一条和第十几条之间可能跨越了多次渲染你拼接的基础值早就过时了最终的结果就是丢消息或者消息顺序错乱。函数式更新还有一个额外的好处即使你忘记了在依赖数组里写状态也不会因为闭包过期产生 bug。比如你用 useEffect 订阅某个事件useEffect(() { const unsub messageSource.subscribe((msg) { setMessages((prev) [...prev, msg]); }); return unsub; }, []);subscribe 回调里没有直接引用 messages 变量所以 effect 的依赖数组可以放心地写空。这份代码不管消息源什么时候触发回调都能正确拼接到最新的消息数组上。这就是函数式更新的价值。2.3 复杂嵌套消息结构试试Immer聊到 Immer就不得不提那些消息结构特别复杂的场景。比如一个消息卡片本身有 id、作者、内容、时间戳下面还可能挂着回复列表、表情回应、已读状态、引用消息等等。如果每次都要手动 spreadsetMessages((prev) prev.map((msg) msg.id targetId ? { ...msg, reactions: { ...msg.reactions, [emoji]: (msg.reactions[emoji] || 0) 1, }, } : msg ) );你还得记得搞一份不可变。写起来累读起来也累一旦层级再深一点漏掉一个 spread 就可能产出 bug。用 Immer 就清爽很多setMessages((prev) produce(prev, (draft) { const target draft.find((msg) msg.id targetId); if (target) { target.reactions[emoji] (target.reactions[emoji] || 0) 1; } }) );我在实际项目里的体感是Immer 这类库带来的收益不是让你写得爽而是帮你把“不可变”这件事的认知负担外包了出去。你可以把精力集中在“我想要什么状态”而不是“我怎么安全地复制一份状态”。当然Immer 也不是银弹。如果你只是往数组末尾推一条字符串类型的简单消息用它属于杀鸡用牛刀还会引入额外的依赖和运行时开销。项目里引入的东西越少出问题的面积就越小。我的原则是简单场景用 spread复杂场景再上 Immer不为了技术而技术。3. 消息显示的核心细节key、顺序与滚动3.1 key的稳定性为何不能拿数组下标当id拼接完消息数组接着就要渲染列表。渲染列表绕不开一个东西key。React 官方文档里反复强调“不要用数组下标当 key”但很多人并不理解背后的原理只知道“不要这么做”。理解原理之后你面试也好、写代码也好都能更踏实。React 的列表渲染用的是一个 diff 算法。简单来说它会拿旧列表的每一个元素和新列表的每一个元素做比较靠 key 来判断“这个元素是旧的那个还是一个新的”。如果 key 相同React 就认为两个元素是同一个东西会复用之前的 DOM 节点只更新变化的部分如果 key 不同React 就觉得这是全新的节点会销毁旧节点、创建新节点。用数组下标当 key问题出在“下标不稳定”上。消息列表最常遇到的操作是“加载历史消息”新加载的旧消息会被拼接在数组头部。假设原来有 3 条消息下标分别是 0、1、2加载了 2 条历史消息之后原来的第一条消息下标变成了 2第二条变成了 3所有消息的下标都变了。React 一看 key 全变了以为整张列表都是新元素于是把所有消息的 DOM 节点全部销毁重建。重建本身不是致命伤顶多有点浪费性能。真正要命的是组件状态丢失。如果每条消息组件内部维护着自己的状态比如“是否展开”“是否已读”“输入框内容”一旦节点被销毁重建这些状态就全没了。用户在列表里滑着滑着突然加载了一批历史消息正在看的那条消息的展开状态被重置了体验非常糟糕。正确的做法是用消息本身的唯一标识比如服务端返回的 id。如果服务端没有 id那就前端生成一个const newMessage { id: crypto.randomUUID(), text: msg, createdAt: Date.now(), };crypto.randomUUID 现在浏览器支持已经比较好了它生成的 UUID 在很多场景下够用。也可以通过 nanoid、uuid 这些库或者自己拼一个时间戳加随机数的临时 id。核心原则只有一个key 必须在一条消息的整个生命周期里保持不变。3.2 新消息插头部还是尾部不同场景的选择消息数组拼接的方式会直接影响用户体验这里有两个方向一个是把新消息追加到末尾另一个是插到开头。具体怎么选取决于你做的产品形态。聊天场景比如微信、Slack、直播弹幕消息是“时间正序”的新消息从底部进来所以新消息要追加到尾部setMessages((prev) [...prev, newMessage]); // 追加尾部通知中心、公告列表这类场景往往是“时间倒序”的最新的放在最上面用户进来第一眼就能看到新内容。这时新消息应该插到头部setMessages((prev) [newMessage, ...prev]); // 插入头部分页加载历史消息时也有类似的区分。聊天里往下拉加载更早的聊天记录加载出来的旧消息要拼到头部setMessages((prev) [...historyMessages, ...prev]);通知中心往上拉加载更早的通知旧消息则拼到尾部setMessages((prev) [...prev, ...historyMessages]);写到这里我发现一个规律不管是拼接还是插入只要保证“数组的排列顺序等于你页面期望的显示顺序”就不容易出问题。反过来说最隐蔽的 bug 恰恰是拼反了方向数据没问题、接口没问题页面显示顺序错乱排查半天发现是拼接方向反了。这种问题光看代码很难发现因为每一行看起来都合理但整体逻辑就是拧了。我的排查办法很简单在拼接前打印一下两个数组的首尾元素确认顺序和预期一致再往下走。多花十秒能省半小时的排查时间。3.3 自动滚动到底部的正确姿势聊天消息显示还有一个绕不开的细节新消息来了之后要让视图自动滚动到底部否则用户看不到新内容。最简单的做法是维护一个底部哨兵 div新消息到来时调用 scrollIntoViewfunction MessageList({ messages }) { const bottomRef useRef(null); useEffect(() { bottomRef.current?.scrollIntoView(); }, [messages.length]); return ( div classNamemessage-list {messages.map((msg) ( MessageItem key{msg.id} message{msg} / ))} div ref{bottomRef} / /div ); }这段代码在大多数场景下都能跑但它有一个用户体验上的问题如果用户正在向上翻看历史消息突然来了一条新消息视图会被强制拉到底部用户会被粗暴地打断。我在实战中会做一个判断只有当用户已经接近底部的时候才自动滚动否则就悄悄在旁边弹一个小提示“有 N 条新消息”。实现方式也不复杂监听 message-list 这个容器的 scroll 事件判断当前滚动位置比如距离底部不足 100px 时就认为用户“在底部”。新消息到达时如果判定为在底部就滚动否则不滚。另外一个小细节连续快速到达多条新消息时不要每次都触发滚动。你可以用 requestAnimationFrame 做一下节流或者只依赖 messages.length 的变化让 useEffect 在 length 变化时运行一次即可。实际体验下来这个方案已经能覆盖绝大部分聊天面板的需求。如果消息量特别大、一屏可能渲染几百上千条 DOM 节点那要考虑虚拟列表。常用的库是 react-window 和 react-virtualized它们都支持动态高度容器。这一块展开讲又是一大篇文章但对于大部分中后台和中小型业务先别急着上虚拟列表做好自动定位和 key 管理性能已经足够顺滑。4. 实操写一个完整消息列表组件4.1 基础消息列表实现理论说了一大堆是时候落地了。我写一个比较完整的消息列表组件把前面聊到的东西都用上你可以直接抄作业。这个组件包含三块能力渲染消息列表、接收新消息并拼接、自动滚动到底部。import { useState, useEffect, useRef, useCallback } from react; function MessageList() { const [messages, setMessages] useState([]); const bottomRef useRef(null); // 模拟订阅消息源 useEffect(() { const unsub messageSource.subscribe((newMsg) { setMessages((prev) [...prev, newMsg]); }); return unsub; }, []); // 新消息到达时自动滚动到底部 useEffect(() { bottomRef.current?.scrollIntoView({ behavior: smooth }); }, [messages.length]); return ( div classNamemessage-list {messages.map((msg) ( MessageItem key{msg.id} message{msg} / ))} div ref{bottomRef} / /div ); }MessageItem 这个子组件建议用 React.memo 包一层避免父组件每次渲染时所有消息都跟着重新渲染const MessageItem React.memo(function MessageItem({ message }) { return ( div classNamemessage-item span classNameauthor{message.author}: /span span classNamecontent{message.content}/span /div ); });React.memo 做的是浅比较如果 message 对象的引用没有变化子组件就不重新渲染。因为我们在拼接消息数组时旧消息对象的引用完全没有被修改所以只会有新增加的那条消息触发渲染其余消息直接跳过。这也是不可变数据带来的额外红利它不仅让 React 的更新判断变简单也让 React.memo 这类优化手段真正生效。4.2 分页加载历史消息的竞态处理消息列表十有八九要做历史记录加载。用户往上滑触底加载更多这种交互的核心难点在于处理竞态。什么是竞态简单说就是“两个请求的返回顺序和发起顺序不一致”。比如用户快速滑了几下连续触发了三次加载第一次请求发出去了第三次请求也发出去了但第三次请求先返回了第一次请求后返回。如果你把先返回的旧数据拼到列表头部再去拼后返回的数据顺序就全乱了。我在项目里常用的方案是记录“最近一次请求的编号”只有最新请求的返回结果才允许拼接const requestRef useRef(0); const loadMore useCallback(async () { const currentRequest requestRef.current; const page await fetchMessages(cursorRef.current); if (currentRequest ! requestRef.current) { // 不是最新的请求了直接丢弃 return; } setMessages((prev) [...page.data, ...prev]); cursorRef.current page.nextCursor; }, []);用 requestRef 记录最新请求的编号每个请求发出时自增一次。返回结果到达时只有“自己的编号等于当前最新编号”才处理。这样即使先发的请求后返回也会被丢弃不会打乱列表顺序。这个方案实现成本很低只要几行代码但能避免一大堆偶发问题。如果你用的是 AbortController也可以在发现请求已经过期时主动 abort。但对于拼接列表来说单纯丢弃过期响应已经足够了不需要真的取消网络请求。4.3 用useReducer重构当状态转换变复杂时当消息列表的状态转换变得越来越复杂时比如同一时间可能有新消息插入尾部、历史消息插入头部、单条消息更新、多条消息批量替换useState 的 setMessages 散落在各处代码会越来越难读。这时候我推荐用 useReducer 把所有的“状态如何变化”收敛到一个 reducer 函数里。function messagesReducer(state, action) { switch (action.type) { case append: // 新消息追加到尾部 return [...state, ...action.messages]; case prepend: // 历史消息插入头部 return [...action.messages, ...state]; case updateOne: { // 更新单条消息 return state.map((msg) msg.id action.id ? { ...msg, ...action.patch } : msg ); } case replace: return action.messages; default: return state; } } // 组件内使用 const [messages, dispatch] useReducer(messagesReducer, []);这样做的收益很明显所有的数组拼接逻辑都集中在 reducer 里组件内部只负责 dispatch 意图比如“来了一条新消息”“加载了一批历史消息”。这正好符合单向数据流的理念行为更好预测出 bug 时也能更快定位到是哪种 action 出了问题。我在中大型项目里基本都会用 useReducer 来管理消息这类“持续变化”的列表状态。它不会让代码变多但会让代码的结构变得清晰很多。5. 常见问题与排查技巧实录5.1 消息拼接了但页面不更新这个问题的排查顺序我建议按下面几步走。先确认有没有直接修改 state 数组。查一下代码里是否存在 push、unshift、splice、直接给某个下标赋值这类操作。如有改成不可变更新方式。再确认 setState 有没有被正确调用。如果调了但传进去的是同一个引用也不会触发更新。然后确认是不是批量更新的问题。React 18 会自动批量处理状态更新如果一个事件处理函数里连续执行了多个 setStateReact 会在事件结束时统一渲染一次。如果代码里 setState 之后立刻读 DOM 的某个值可能读到的是更新前的快照。这种情况可以考虑用 flushSync但不建议日常使用因为它会破坏 React 的调度机制。最后检查是不是组件被条件渲染拦截了或者父组件把子组件整个卸载了导致你看着像“页面没更新”其实是列表组件根本不在这棵渲染树里。5.2 列表渲染顺序错乱顺序错乱的问题十有八九出在拼接方向或 key 上。先检查拼接方向。新消息应该追加尾部结果写成了插入头部加载历史消息应该插入头部结果写成了追加尾部。排查方法是打印两个数组的首尾元素和预期的展示顺序对照一下。再检查 key。key 如果不稳定比如用了数组下标React 的列表复用就会出现错位导致 DOM 节点和消息数据对不上号表现就是“明明数据是对的页面显示却乱了”。把所有列表的 key 改成消息 id这个坑基本就填平了。还有一个容易被忽略的场景消息 id 不唯一。如果两个不同的消息用了同一个 idReact 会认为它们是同一个元素导致重复的消息只显示一个。检查一下服务端返回的 id 是否真的是全局唯一的。5.3 组件卸载后还在setStateReact 18 之后在已卸载的组件上调用 setState控制台会打印一个 warning“Cant perform a React state update on an unmounted component”。虽然它不再像以前一样真的导致内存泄漏但日志满天飞也会干扰排查其他问题。这种问题最常见的原因是异步回调里调用了 setState而组件在异步操作完成之前就被卸载了。比如进入了详情页发了一个请求用户还没等到响应就返回上一页组件已经卸载请求回来后 setState 照样执行。我的推荐做法是订阅类操作在组件里清理干净清理 useEffect 返回的取消订阅函数网络请求用 AbortController 在卸载时取消。如果用的是消息订阅源务必在 cleanup 里取消订阅否则不仅会 setState还可能造成事件泄露回调被调用 N 次。useEffect(() { const unsub messageSource.subscribe((msg) { setMessages((prev) [...prev, msg]); }); return unsub; // 组件卸载时取消订阅 }, []);5.4 一些好用的排查与优化工具平时调试消息列表这类实时性比较强的功能我习惯在浏览器里做三件事。第一打开 React DevTools 的 Profiler录制一段操作看看每条消息组件的渲染耗时。如果发现所有的消息组件每次更新都重新渲染了多半是 React.memo 没有生效或者传入的 props 里包含了每次渲染都会变化的新对象。第二手动在 Network 面板里模拟慢网速。把网速调成 Slow 3G触发分页加载观察竞态情况是否符合预期。很多偶发的乱序问题在正常网速下根本重现不出来一降网速就现原形。第三用console.count检查渲染次数。在子组件里加一行 count快速观察父组件更新时子组件被渲染了多少次。一个健康的组件树里新增一条消息只有新增的那条消息组件会渲染其他组件的渲染次数应该保持不变。另外再分享一个常用的排查表遇到问题直接查症状可能原因排查方式push 后无变化直接修改 state 数组改为不可变更新setState 后无变化传入同一引用使用函数式更新消息顺序反了拼接方向写反检查合并顺序列表渲染错乱key 不稳定或重复改用消息唯一 id所有 item 都重新渲染memo 缺失或 props 不稳定检查子组件 props 引用卸载后报 warning订阅未清理useEffect 返回 unsub分页加载顺序乱竞态未处理使用请求编号丢弃过期响应提示排查消息数组问题优先用“最小复现法”。把真实数据和复杂逻辑全部剥离只保留一个简单的列表和一个按钮先在页面里重现问题。一旦问题能用 10 行代码复现原因也就基本浮出水面了。踩过几次坑之后我现在写消息列表相关代码已经有了肌肉记忆凡是往数组里加数据一律先问自己一句“这次拼接完引用变了吗”凡是渲染列表一律先确认 key 用的是不是唯一的 id凡是处理分页一律先把竞态防护写上。React 这套不可变数据的规则只要你顺着它走它也会对你很温柔。
返回列表