☰
React消息数组拼接:从状态更新到渲染优化的完整实践
2026/10/13 3:18:14 网站建设 项目流程

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 做去重,比filter+some的写法高效很多,尤其是消息量上千时,复杂度从 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 传过去,让服务端返回更早的数据。但很多服务端接口不支持这种参数,前端就得自己做防御。

我给团队定的规范是这样的:

  1. 历史消息返回时,和liveMessages做一次基于 id 的去重合并,而不是直接替换 historyMessages。
  2. 使用独立的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 className="message-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 组件负责接收新消息并拼接进 store,B 组件负责显示未读数,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 的状态更新机制、列表渲染性能、竞态处理和交互体验,每一个环节都值得认真对待。希望这篇分享能帮你少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询