React消息数组拼接:从状态更新到渲染优化的完整实践
1. 消息数组拼接为什么总把页面搞乱做React开发的人大概率都写过聊天界面、通知流或者日志面板这类功能绕不开一个核心动作把新拿到的消息拼接到已有的消息数组里再交给列表渲染。我第一次做在线客服工作台的时候以为这个需求很简单无非就是setMessages([...prev, newMsg])而已。结果上线之后连续遇到几类诡异问题新消息明明是最后一条渲染出来却跑到中间去了快速连发消息时丢消息往上翻历史记录翻到一半被新消息粗暴地顶回底部。排查到最后问题全都出在拼接这个动作上而不是列表组件本身。这个标题之所以值得单独写一篇文章是因为数组拼接和消息显示看起来是两个独立环节实际上被React的状态更新机制、闭包陷阱、渲染调度和滚动处理串成了一条链路。任何一个环节处理不当最后呈现出来的就是一个看起来不太聪明的消息列表。这篇文章我会从状态更新的底层逻辑开始把拼接、去重、渲染、滚动的常见坑逐个拆开并给出可以直接抄的实践方案。1.1 setState异步与闭包经典拼接错误的根源先看一个最常见的错误写法我见过不少同学在React组件里这么写const handleNewMessage (incoming) { const next messages.concat(incoming); setMessages(next); console.log(messages.length); // 还是旧值 };这段代码的问题不在于concat本身而在于messages是从当前 render 闭包里捕获的。React 的setState是异步调度的连续触发两次handleNewMessage时第二次拿到的messages仍然是第一次 render 时的旧数组于是第二次拼接会把第一次的消息覆盖掉表现出来就是发消息偶尔丢一条。正确做法是用函数式更新让 React 在内部把最新的状态传给你const handleNewMessage (incoming) { setMessages((prev) prev.concat(incoming)); };这个区别的本质是你不再依赖外部闭包里的旧值而是直接消费 React 内部维护的最新状态。函数式更新对小规模消息列表来说已经足够但如果消息到达频率极高、单次拼接的数组很大还会引出另一个问题每次 setState 都会创建一个新的数组引用这个新数组会触发所有依赖messages的组件重新渲染性能隐患会在后面展开讲。这里想强调一个很多人忽略的细节React 18 的自动批处理会把同一个事件循环里的多次 setState 合并成一次 render但函数式更新的累计效果不会丢。换句话说即使用户在极短时间内连发 10 条消息只要每一条都走prev prev.concat(incoming)这 10 条都会按顺序被拼进去因为每次更新拿到的 prev 都是上一次更新的结果。1.2 可变数据结构与子组件缓存另一个隐形杀手另一种让消息列表乱掉的操作是直接在原数组上做变更messages.push(incoming); setMessages(messages);这行代码在 React 里等于什么都没做因为messages的引用没有变化React 会认为状态没有更新进而跳过渲染。即便某些情况下页面看起来更新了也大概率是别的状态变化顺带触发的 render属于不可控的巧合。更隐蔽的是子组件的React.memo缓存问题。假设你的消息列表项是经过 memo 包裹的const MessageItem React.memo(({ message }) {...});如果父组件每次拼接消息时不小心把旧的 message 对象也改了比如给旧消息补一个status字段那MessageItem的浅比较会发现引用变了所有历史消息项全部重新渲染。消息一多页面就开始卡顿。所以消息数组的拼接本质上要遵守一个原则消息项一旦生成就尽量不可变需要修改时用展开创建新对象setMessages((prev) prev.map((m) m.id targetId ? { ...m, status: read } : m ) );这条原则和数组本身的不可变更新配合起来才是消息列表性能稳定的基础。我见过太多只改数组引用、不改消息对象引用的项目最终都被明明只加了一条消息整个列表却闪了一下这个问题折磨。2. 追加与合并消息数组状态更新的正确姿势搞清楚底层原理之后再来看拼接动作本身。不同场景下拼接的含义并不一样纯追加、按时间戳合并、按 ID 去重合并这三个动作的代码难度是递增的。很多消息列表面临的显示错乱问题不是因为 React 用错了而是因为拼接策略本身就是错的。2.1 按时间戳合并 vs 按 ID 去重合并如果消息来源只有一个比如用户自己发送纯追加就够了const appendMessage (incoming) { setMessages((prev) [...prev, incoming]); };但真实项目里消息往往有多个来源用户发出的消息、服务端推送的对方回复、历史记录分页加载、甚至多端同步产生的消息。此时如果还按追加来处理就会遇到重复和乱序。我常用的方案是把拼接动作统一封装成一个纯函数放在组件外面方便测试export function mergeMessages(prev, incomingList) { const map new Map(); for (const item of prev) { map.set(item.id, item); } for (const item of incomingList) { // 保留已有消息的本地状态比如发送中/发送失败 map.set(item.id, { ...map.get(item.id), ...item }); } return Array.from(map.values()).sort( (a, b) a.timestamp - b.timestamp ); }这里有两个关键点。第一用Map以消息 id 为 key 做去重比filtersome的写法高效很多尤其是消息量上千时复杂度从 O(n²) 降到了 O(n)。第二合并时用{ ...old, ...new }而不是直接替换这样消息的本地状态发送成功、发送失败、重试中不会被服务端推送覆盖掉。排序我倾向于单独处理因为sort会改变原数组的顺序如果后续有插入一条本地消息的操作直接排序会打乱本地消息置顶/置底的特殊逻辑。更稳妥的做法是在合并时维护一个有序结构或者每次合并后再排序但把排序规则做成可配置的。2.2 历史消息和实时消息分开维护这算是我踩坑之后沉淀下来的重要经验不要把历史消息和实时消息放在同一个数组里做追加。历史消息是分页拉取的一般是给一个beforeId或者page参数拿到一坨旧数据实时消息是 WebSocket 推一条拼一条。两者的到达节奏完全不同混在一起很容易出现一个竞态问题——我下一章会专门讲。这里先给结论分两个独立的 state 维护渲染时合并。const [historyMessages, setHistoryMessages] useState([]); const [liveMessages, setLiveMessages] useState([]); const allMessages useMemo( () mergeMessages(historyMessages, liveMessages), [historyMessages, liveMessages] );historyMessages只负责追加老数据方向是往前插liveMessages只负责追加新数据方向是往后加。渲染层通过useMemo把两个数组合并排序这样任何一方的更新都不会影响另一方的追加逻辑心智负担小很多。当然如果消息量已经大到上万条useMemo每次重新合并排序的成本也会变高这时候就需要考虑虚拟列表。但那是后话先把逻辑正确性做好再谈性能优化。绝大多数项目卡顿根本原因不是useMemo排序慢而是消息对象引用被破坏导致的级联重渲染。2.3 本地消息与远端消息的幂等合并多说一句幂等。在弱网环境下用户点发送按钮可能触发重试同一个 pending 消息如果被插入了两次列表里就会出现两条一模一样的气泡。解决思路是给每条消息分配一个客户端生成的临时 id服务端确认时再替换成正式 id。const sendMessage async (content) { const tempId temp_${Date.now()}_${Math.random()}; appendLocalMessage({ id: tempId, content, status: sending }); try { const saved await api.send(content); // 用正式消息替换临时消息而不是再追加一条 setMessages((prev) prev.map((m) m.id tempId ? { ...saved, status: sent } : m ) ); } catch (err) { setMessages((prev) prev.map((m) m.id tempId ? { ...m, status: failed } : m ) ); } };这个模式同时解决了三个问题消息立即显示体验好、服务端确认后不会出现重复气泡、发送失败时能准确定位到那条消息做状态更新。映射替换而不是先删再加也是为了避免数组在那一瞬间出现闪烁。3. 实时推送与历史拉取的竞态处理前面提到了竞态问题这可能是消息数组拼接在真实项目里最折磨人的一个场景。我先描述一下现象用户打开聊天窗口组件挂载的同时发起两个请求——一个是拉取最近 20 条历史消息另一个是建立 WebSocket 连接接收实时消息。如果 WebSocket 的消息先到达被拼进liveMessages随后历史消息请求返回又被拼进historyMessages理论上最终渲染时两者会合并排序看起来没问题。但坑在于WebSocket 推送的那条新消息可能已经在历史消息接口的返回结果里了因为历史接口是最近 20 条恰好在拉取瞬间这条新消息已经被服务端写入。于是合并时你没有做去重或者去重逻辑只处理了 historyMessages 内部结果就是同一条消息在列表里出现两次。3.1 消息重复的根因接口数据范围重叠根本上这是数据范围重叠问题。历史接口的语义通常是给我当前会话在某个时间点之前的 N 条消息实时推送的语义是给我这个会话新的消息。划重点新消息一旦落库它就成了历史消息。所以在拉取历史时理论上应该把当前已收到的实时消息的时间戳或 id 传过去让服务端返回更早的数据。但很多服务端接口不支持这种参数前端就得自己做防御。我给团队定的规范是这样的历史消息返回时和liveMessages做一次基于 id 的去重合并而不是直接替换 historyMessages。使用独立的incomingBuffer暂存实时消息等历史消息返回之后再统一刷入liveMessages。第二个做法尤其重要因为它把竞态窗口直接消灭了。具体实现是用一个标识位const isHistoryLoadedRef useRef(false); const pendingLiveRef useRef([]); useEffect(() { socket.on(message, (msg) { if (!isHistoryLoadedRef.current) { pendingLiveRef.current.push(msg); return; } appendLiveMessage(msg); }); fetchHistory().then((list) { setHistoryMessages((prev) mergeMessages(prev, list)); isHistoryLoadedRef.current true; // 统一刷入历史加载期间积压的实时消息 if (pendingLiveRef.current.length) { setLiveMessages((prev) mergeMessages(prev, pendingLiveRef.current) ); pendingLiveRef.current []; } }); }, []);ref在这里的用途是跨 render 记住历史是否已加载这个状态同时不触发额外的渲染。等历史拉取完成后再把积压的实时消息一次性合并进去配合mergeMessages的 id 去重重复问题从根上解决。3.2 拉取锁的实践方案WebSocket 连接建立本身也有时序问题。如果组件挂载时网络慢用户等了两秒才连上期间服务端已经推送了几条消息但客户端没收到历史接口又没把这几条包含进来那就不是重复而是缺失。我的处理方式是设置一个历史加载完成标志WebSocket 的连接消息全部晚于这个标志触发逻辑哪怕实际连接建立得早消息也先暂存在pendingLiveRef里。这里的关键是客户端逻辑必须以历史拉取成功作为实时消息开始处理的起跑线。这样虽然会稍微牺牲一点点消息到达的绝对实时性最多几百毫秒但换来了数据一致性和代码可维护性非常划算。如果再讲究一点还可以把历史接口返回的lastMessageId拿出来与暂存的实时消息做比较只保留比lastMessageId更新的消息进入liveMessages。不过这个依赖服务端返回可靠的边界标识不是每个后台都做得到所以我把mergeMessages的去重作为最后一道防线双重保险。4. 列表渲染与性能优化把拼接好的消息安全显示出来拼接只是第一步显示才是用户直接感知的环节。消息列表的渲染有三个问题几乎每个项目都会遇到key 怎么选、一条一条渲染还是一批一批渲染、列表项内部需不需要做 memo。4.1 key 到底选什么先说结论优先用消息 id并且这个 id 必须稳定且唯一。用数组索引当 key 是个大坑在消息列表这种头部插入、中间替换频繁的场景里索引 key 会直接导致 DOM 复用错乱表现症状是消息气泡的文字张冠李戴。// 不推荐 {allMessages.map((msg, index) ( MessageItem key{index} message{msg} / ))} // 推荐 {allMessages.map((msg) ( MessageItem key{msg.id} message{msg} / ))}这里有个容易被忽略的细节客户端临时生成的temp_id在服务端确认后会被替换成正式 id。如果直接拿临时 id 当 key替换的那一刻 React 会认为这是一条新消息组件整个卸载重建视觉效果就是气泡闪一下。所以我在前面提到的sendMessage实现里没有用删除临时消息 插入正式消息的方式而是原地替换对象就是为了保住 key 不变化。如果你的合并逻辑里不得不改变 id那就需要额外维护一个clientId作为 key这个字段自始至终不变。4.2 逐条渲染 vs 批量渲染WebSocket 推送一条拼一条是最自然的写法但高频推送时会有性能问题。试想证券行情或者群聊大爆发的场景一秒钟来 20 条消息setMessages被调用 20 次React 要连续做 20 次调度和协调。虽然 React 18 自动批处理能合并一部分但跨宏任务的更新依然会触发多次 render。我常用的优化手段是引入一个简单的节流队列const bufferRef useRef([]); const flushTimerRef useRef(null); const handleSocketMessage (msg) { bufferRef.current.push(msg); if (flushTimerRef.current) return; flushTimerRef.current setTimeout(() { setMessages((prev) mergeMessages(prev, bufferRef.current)); bufferRef.current []; flushTimerRef.current null; }, 50); };50ms 这个数字是经验值既保证了肉眼感知不到延迟又能把高频消息合并成一次渲染。实测下来在每分钟 600 条消息的场景下页面帧率明显比逐条渲染稳定。代价是消息不是严格逐条出现的而是每 50ms 批量刷新一次对聊天场景来说完全可接受。批量渲染还能配合一个技巧渲染时不要先清空再插入而是把新旧数组合并后整体交给 React。这是因为 React 的 diff 算法对数组尾部新增的识别非常高效远好过先splice再push的混合操作。4.3 消息项组件的 memo 边界我在第一章节提过React.memo的问题这里展开讲边界。消息项组件适合做 memo但 memo 的作用范围是有条件的const MessageItem memo(function MessageItem({ message, onRetry }) { return ( div classNamemessage-bubble span{message.content}/span {message.status failed ( button onClick{onRetry}重试/button )} /div ); });onRetry这个函数如果每次 render 都是新引用memo 就失效了。解决办法是用useCallback包裹或者干脆让 MessageItem 自己通过 store 读取回调函数不依赖 props。我自己在实际项目中更倾向后者因为它把哪些 props 变了会导致重渲染这个问题简化到了极致。另一个边界是如果消息内容里包含富文本、图片地址、表情包等memo 的浅比较可能判断错误——比如图片地址没变但图片的实际内容变了。这种情况要在 MessageItem 里显式声明比较字段const MessageItem memo( (props) div.../div, (prev, next) prev.message.seq next.message.seq );用服务端下发的自增序号seq作为比较基准比默认的浅比较更可靠。不过这个属于进阶玩法消息量不到几百条的普通应用其实用不上。5. 滚动定位与消息显示体验的收尾细节消息拼接和渲染正确之后最后一步是滚动体验。聊天界面的滚动问题处理不好会出现新消息被挤到屏幕外往上翻历史被拽回底部输入法弹起导致列表跳动等一连串体验问题。5.1 底部自动滚动的门槛判断最经典的方案是把滚动行为放在useEffect里监听allMessages变化const listRef useRef(null); const shouldStickToBottomRef useRef(true); useEffect(() { const el listRef.current; if (!el || !shouldStickToBottomRef.current) return; el.scrollTop el.scrollHeight; }, [allMessages]); const handleScroll () { const el listRef.current; const distance el.scrollHeight - el.scrollTop - el.clientHeight; shouldStickToBottomRef.current distance 80; };关键在于用户正在往上翻历史时新消息到达不能强行滚动。用shouldStickToBottomRef这个 ref 记录用户最近一次滚动的位置距离底部是否小于 80px小于才认为用户停留在底部新消息来了才触发滚动。用 ref 而不是 state是为了避免滚动事件触发额外的组件渲染——这个细节对性能敏感场景很重要。我把 80px 作为一个经验阈值它意味着用户看到的不完全是最后一条消息但只要离底部足够近新消息到来就自动吸底。这个距离感需要根据气泡大小和屏幕高度微调我在一些项目里会放宽到 120px因为消息气泡比较高大用户视觉上觉得自己已经在底部了但滚动位置离底还有一段距离。5.2 新消息的视觉提示避免强行动画很多项目给新消息加了滑动动画结果用户向上翻历史的时候突然来一条新消息动画直接把列表内容顶开非常打扰。我的建议是只有处于吸底状态时才播放新消息动画否则只显示一个有新消息的悬浮提示。const [showJumpButton, setShowJumpButton] useState(false); useEffect(() { if (!shouldStickToBottomRef.current) { setShowJumpButton(true); } }, [allMessages]);这个悬浮按钮点击后滚动到底部并重新开启吸底状态。它比任何自动滚动都要克制也更符合主流聊天产品的交互习惯。弹窗、震动、声音这些提示手段最好都加上用户是否在底部这个判断不然就是给正在翻历史的用户添乱。5.3 图片消息未加载完成的滚动高度问题最后聊一个特别隐蔽的坑消息里有图片时图片加载完成后会把容器的高度撑开导致scrollHeight变化你明明触发了滚到底部结果底部被图片撑高之后又重新露出了空白。处理方式有两种。一种是在图片外层给固定宽高比容器加载完成前后高度不变div style{{ aspectRatio: 1:1, maxWidth: 200 }} img src{url} onLoad{handleLoad} / /div另一种是在图片onLoad后再次触发一次滚底const handleImageLoad () { if (shouldStickToBottomRef.current) { requestAnimationFrame(() { const el listRef.current; el.scrollTop el.scrollHeight; }); } };我用requestAnimationFrame而不是直接赋值是因为图片加载完成到布局重算之间还有一个帧的延迟直接赋值偶尔会取到旧的scrollHeight。这个坑在普通文字聊天里遇不到但一旦上了图片消息、视频消息几乎必踩提前在架构上预留这个处理逻辑能省不少事。6. 依赖收集与 useEffect 顺序陷阱如果只是单个组件内部维护消息数组章节 1 到 5 已经足够。但真实项目里消息列表往往分布在多个组件中列表组件、输入框组件、会话列表组件、未读角标组件。它们各自需要消费同一个消息数组这时候就需要把消息状态上提到一个共享的 store 或者 Context 里。消息数组的拼接逻辑一旦上提又会产生新的问题依赖收集的顺序。6.1 多个组件消费同一消息源时的级联更新假设 A 组件负责接收新消息并拼接进 storeB 组件负责显示未读数C 组件负责列表渲染。理论上 A 更新消息数组后B 和 C 都会重新渲染看起来没问题。但如果你在 A 的useEffect里依赖了messages.length又在同一个组件里调用 setMessages就会形成循环依赖极端情况下出现消息数组无限增长的 bug。我遇到过的真实案例是某开发者写了一个useEffect依赖messages.length在里面做持久化存储每次新消息到达就会触发一次 storage 写入。这个 useEffect 意外修改了另一个状态那个状态又反哺消息数组最终在一次压测里把数组长度从 100 撑到 5000 多页面直接卡死。排查这类问题的思路是打开 React DevTools 的 Profiler看哪些组件的 render 是级联触发的再逐一检查 useEffect 的依赖数组。依赖数组里出现messages.length通常不是好主意因为它是一个随内容变化的值下游组件拿到的永远是最新的长度但如果你同时用它去做副作用循环很容易失控。6.2 用 reducer 归拢拼接动作把消息数组的拼接动作归拢到useReducer里可以大幅减少零散的 setState 逻辑也让竞态和去重逻辑集中到一处。我的经验是一旦消息流的事件类型超过两种追加、批量插入、更新状态、清空就该上 reducer。function messageReducer(state, action) { switch (action.type) { case appendLive: return { ...state, live: mergeOne(state.live, action.payload) }; case loadHistory: return { ...state, history: mergeMany(state.history, action.payload) }; case updateStatus: return updateMessageStatus(state, action.id, action.status); case reset: return initialState; default: return state; } }reducer 的好处是每个动作都是纯函数测试起来非常方便。我可以直接写单测验证appendLive会不会产生重复、loadHistory会不会覆盖已有消息而不用启动整个 React 应用。这种可测试性在消息系统这种高频变更的代码里价值会被放大很多。配合useContext做跨组件共享时记得把 dispatch 本身放进一个单独的 Context和消息数据 Context 分开。否则任何消息变化都会让所有消费该 Context 的组件重新渲染哪怕它们只需要 dispatch 而不关心数据本身。这个优化我用一个简单的例子说明const MessagesStateContext createContext(null); const MessagesDispatchContext createContext(null);消费方按需拿数据或者拿 dispatch互不干扰。这在组件树比较深、节点比较多的时候能避免很多无意义的性能损耗。7. 一套可以直接用的消息拼接与显示模板把前面所有经验汇总成一份可以直接用的模板这是我两个聊天项目下来沉淀的最小可行结构。它不是最高级的写法但它是逻辑最清晰、最容易维护的写法。// useMessageList.js export function useMessageList({ fetchHistory, socket }) { const [history, setHistory] useState([]); const [live, setLive] useState([]); const historyLoadedRef useRef(false); const pendingLiveRef useRef([]); const allMessages useMemo( () mergeMessages(history, live), [history, live] ); const loadEarlier useCallback(async () { const before history[0]?.timestamp; const list await fetchHistory({ before }); setHistory((prev) mergeMessages(list, prev)); }, [history, fetchHistory]); const appendLocalMessage useCallback((msg) { setLive((prev) mergeMessages(prev, [msg])); }, []); useEffect(() { setLive((prev) mergeMessages(prev, pendingLiveRef.current)); pendingLiveRef.current []; historyLoadedRef.current true; }, []); useEffect(() { if (!socket) return; const onMessage (msg) { if (!historyLoadedRef.current) { pendingLiveRef.current.push(msg); return; } setLive((prev) mergeMessages(prev, [msg])); }; socket.on(message, onMessage); return () socket.off(message, onMessage); }, [socket]); return { allMessages, loadEarlier, appendLocalMessage }; }这份 hook 把历史加载、实时拼接、积压处理全部封装起来业务组件只需要关心渲染和用户交互。我在团队里一直推行状态逻辑和渲染逻辑分离消息列表这种复杂状态尤其需要这样做。模板还有一些值得留意的边界loadEarlier里我传了history[0]?.timestamp作为分页游标如果你的服务端支持基于 id 的分页会更准时间戳在消息密集时可能存在同一秒多条消息的问题导致翻页漏数据。这个只能根据后端能力来取舍前端能做的防御是在mergeMessages里做好去重。另一个边界是消息数组异常膨胀。如果不做任何限制一个高频群聊跑上几天前端的内存占用会非常可观。虚拟列表是终极方案但如果暂时不想引入那么重的依赖也可以做简单的窗口截断只保留最近 500 条消息更早的通过分页加载进来。const MAX_VISIBLE_MESSAGES 500; const visibleMessages useMemo( () allMessages.slice(-MAX_VISIBLE_MESSAGES), [allMessages] );这个方案只适用于纯展示场景如果用户需要回看历史截断会让体验断裂。更稳妥的做法还是上虚拟列表组件在allMessages数量超过 1000 时自动切换到虚拟渲染模式。我把这两种方案都经历过早期项目用数组全量渲染消息过 2000 条之后明显卡顿后来引入虚拟列表万条消息也流畅。但虚拟列表也有自己的成本——动态高度的消息气泡需要提前估算高度图片加载又会撑高元素处理起来同样有不少细节。所以我的建议是先保证拼接和渲染逻辑正确再根据实际消息量决定是否上虚拟列表不要一上来就过度设计。8. 关于消息数组拼接的几点个人体会写到这里React 消息数组的核心链路基本都覆盖了。回到最开始那个让我踩坑的客服工作台项目后来我把消息状态管理重构成今天讲的这套结构问题是这么解决的第一所有拼接动作统一走mergeMessages去重合并不再裸写数组操作。第二历史消息和实时消息拆成两个 state 维护历史加载完成前实时消息全部暂存。第三列表项 key 用稳定 id消息对象一律不可变更新。第四滚动吸底判断用 ref 记录新消息只在用户位于底部时自动滚动。这套组合拳打下来线上没再出现过消息错乱和重复气泡的问题性能也稳定住了。如果你现在正准备写一个消息列表我的建议是不要急着引入复杂的状态管理库先从这个最小结构开始。等消息类型增多、状态流转变复杂之后再迁移到 reducer 或者 store 也不迟。消息数组拼接这个动作看起来只是一个concat但它背后连接着 React 的状态更新机制、列表渲染性能、竞态处理和交互体验每一个环节都值得认真对待。希望这篇分享能帮你少走一些弯路。