☰
React消息列表开发实战:数组拼接、渲染优化与滚动处理指南
2026/10/7 4:06:32 网站建设 项目流程

做IM、聊天列表、通知中心这类功能时,我几乎每次都会在“消息数组”上栽几个跟头。React里的消息数组拼接与显示,表面看不过是一个setState加一个map,但真正落地时你会发现:拼接顺序错了消息会倒置,渲染方式选错了上百条消息直接卡顿,滚动位置没处理好用户就被强行“拽”回顶部。这篇分享我打算把这些年做消息列表的实战经验完整梳理一遍,从数组本身的拼接逻辑、不可变更新、key设计,到渲染层的增量优化、虚拟列表、自动滚动判断,再到大批量消息和滚动位置保活这些高频故障,全部给出可抄作业的写法。

这套东西适合正在做聊天软件、直播弹幕、实时日志看板或站内信列表的前端开发,也适合React处于进阶阶段、想搞懂列表渲染底层逻辑的朋友。内容里有大量我实际踩坑后的修正方案,不需要你有特别深的基础,只要会用useState和map就能跟得上后面所有的思路。

1. 先想清楚:消息数组在界面上到底怎么流转

1.1 消息的本质就是一份有序数组

不管消息来自WebSocket推送、HTTP轮询还是本地数据库读取,在前端内存里它最终都会被归结为一个数组。数组的有序性天然对应了消息列表的时间线性,第0项是最早的或最新的,由你的业务决定。这里第一个要决策的问题就是排序方向。

绝大多数IM都把最新消息放底部,数组尾部是新的;公告、Feed流则相反,最新消息放顶部,数组头部是新的。方向不同,拼接策略完全不一样,而且这个选择会辐射到后续所有代码。我习惯在项目一开始就用类型把方向定死,比如MessageListOrder = 'asc' | 'desc',不做隐式假设,因为排序方向一旦搞错,整个消息列表的渲染逻辑都会跟着乱。

1.2 方案选型:直接拼接还是按需分片

知道了数据形态之后,紧接着要选的是数据处理方式。消息量小的场景,比如站内信、系统通知,直接全量拼接即可,一个concat就完事。但消息量大的场景,比如群聊或日志流,一次性把数千条消息塞进数组再渲染,页面极大概率白屏。

分片的核心思路是:只保留当前需要的窗口,超出渲染窗口的数据要么丢弃、要么用加载更多的方式逐步追加。我见过很多团队在这块过度设计,动不动上虚拟列表,结果数据量根本没到那个级别,反而徒增复杂度。按需分片并不等于必须用虚拟列表,你可以先给列表加一个“加载更早的消息”按钮,每次往数组头部拼20条,这个方案在小中型项目里足够好用。

2. 消息数组拼接的操作细节与不可变更新

2.1 从本地推消息:push逻辑和性能差异

新消息到达时最直接的想法是messages.push(msg),然后在原数组上setMessages(messages)。这代码能跑,但隐患很大。React的useState判断状态是否变化依赖的是Object.is,你原地push之后再塞同一个引用进去,新旧state是同一个对象引用,渲染可能根本不触发,或者因为其他地方对数组做了引用比较而出现奇怪问题。

正确做法永远是生成一个新数组,不可变更新。展开运算符是目前最顺手的方式:

const appendMessage = useCallback((msg) => { setMessages(prev => [...prev, msg]); }, []);

prev这里拿到的是上一次的state快照,不是闭包里可能过期的值,这能避开“连续快速收到多条消息时只显示最后一条”的经典bug。展开运算符比concat更符合React社区习惯,性能上两者没有本质差别。但有一点要注意:如果单条消息本身是个体量很大的对象,比如包含base64图片,展开数组本身很便宜,真正贵的是后续渲染,所以推送前最好先做消息体瘦身。

2.2 拉历史消息:头部插入与滚动位置

加载历史消息时,时序是反着来的。用户往下翻到了顶部,点一个“加载更早”,接口返回的更老消息要拼在现有数组的前面。如果继续用[...prev, ...olderMessages],历史消息反而排到了列表最底部,这种错误我见过不下三次。

正确的头部插入逻辑是:

const prependMessages = useCallback((olderMessages) => { setMessages(prev => { const withoutLoadingPlaceholder = prev.filter(m => m.id !== 'loading-more'); return [...olderMessages, ...withoutLoadingPlaceholder]; }); }, []);

这里我还顺手做了一个filter:加载历史时通常会往数组头部插一个loading-more占位消息,等真实数据返回后再把它挤掉。这个占位消息如果不删,列表头部会残留一个永远转圈的元素,而且会让真正的历史消息被往后挤一位,视觉上非常突兀。

头部插入有个附带问题:新数据插进去之后,浏览器默认会把滚动高度往下推,用户视觉上会感觉列表“跳了一下”。解决思路是在插入前记录当前第一条可见消息的id或scrollTop值,数据更新后在useLayoutEffect里把滚动位置修正回去。这个操作必须在layout阶段完成,用useEffect都来不及,会闪屏。

2.3 key 的选择:为什么不能用 index

列表渲染的key问题我在项目评审里强调过无数次。用数组index当key是最省事的写法,但消息场景中几乎必然引发bug:头部插入历史消息时index整体位移,React会误以为你删掉了第一条,于是把后续所有项都卸载重挂,用户输入框里的草稿状态、图片加载状态、已读高亮全部丢失。更糟的是消息组件内部如果有useEffect做订阅或定时器,卸载重挂会带来重复执行和内存泄漏。

可靠方案是使用服务端下发的稳定消息id,或者本地生成的唯一id:

import { nanoid } from 'nanoid'; const buildMessage = (payload) => ({ id: payload.id ?? nanoid(), clientMsgId: nanoid(), ...payload, });

clientMsgId用来处理发送中消息的临时渲染,id用来做最终key。对于单条消息内的复杂状态,比如图片上传进度、重试按钮、语音播放状态,我会再拆一层MessageItemInner组件,用memo带上自定义比较函数,只在自己关心的字段变化时重渲染,从根上减少整列表的渲染压力。

3. 消息显示的渲染核心:列表渲染与增量优化

3.1 列表渲染选型:map、分组、虚拟列表

在React中渲染一个数组,大多数人第一反应是messages.map。这个写法对50条以内的消息完全没问题,但对几千条消息就力不从心了。我实际测过,一次性渲染2000条带头像、排版相对复杂的消息,首屏时间大概要3到5秒,滚动时还伴随持续掉帧。

选择渲染方案时,先给个项目量级判断:100条以内直接map最稳;100到500条可以用“分批渲染+增量化”的手法,让列表看起来像是一个个冒出来的;500条以上再考虑虚拟列表。虚拟列表方案我推荐react-window,它比react-virtualized轻量太多,API也简单:

import { FixedSizeList as List } from 'react-window'; const MessageList = ({ messages }) => ( <List height={600} itemCount={messages.length} itemSize={72} itemData={messages} > {({ index, style, data }) => ( <MessageItem style={style} message={data[index]} /> )} </List> );

react-window的问题在于它默认只渲染可视区附近的内容,而聊天场景的消息高度经常不固定——有的只有一行字,有的带长图片。处理不固定高度,可以用VariableSizeList,并配合setEstimatedItemSize预估高度;或者更实用一点,给每条消息设一个最小高度,把图片和长文本的测量交给内部组件,但外层高度给个合理预估值。我的做法是:没有特殊样式需求的消息用固定高度,含图片、卡片、引用的消息用动态测量组件,两者混合时用VariableSizeList。这里面坑最多的是图片加载后高度变化,需要在图片onLoad后重新调用resetAfterIndex,否则滚动会出现空白跳位。

3.2 自动滚动与“贴底才滚”的判断

聊天列表有个高频需求:新消息到达时自动滚到底部。无脑每次scrollTop = scrollHeight也行,但用户正在上翻看历史消息时,突然被弹回底部会非常恼火。所以要做判断——只有用户当前本身就在底部附近时,才执行自动滚动。

判断逻辑很简单但很实用:

const isNearBottom = () => { const el = listRef.current; if (!el) return false; return el.scrollHeight - el.scrollTop - el.clientHeight < 100; };

这个100px的阈值是经验值,阈值越大触发自动滚动的意愿越强,反之越克制。实际项目中我取120,因为用户滑动到底部时往往手指还没有完全停稳,阈值太小会导致新消息来了却滚不下去,体验很怪。

自动滚动本身也有两种姿势。一是直接操作DOM:

el.scrollTop = el.scrollHeight;

二是用scrollIntoView配合底部锚点元素。聊天列表我推荐后者,因为它对浏览器滚动容器、嵌套滚动、甚至移动端都有更好的兼容性:

useEffect(() => { endAnchorRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages.length]);

但是behavior: 'smooth'在大量消息到达时会显得很鬼畜,一条消息平滑一次,多条消息连续到达时滚动动画会互相打架。我的经验是新消息量少、间隔长时用smooth,批量消息一次到达时直接用auto或手动设置scrollTop,保证瞬时定位到底部。

3.3 批量消息下的渲染优化

有时候服务端一次推过来几百条历史消息或聊天记录,直接一次性setMessages会引发React一次海量渲染。这里可以用“分批渲染”的技巧,把一个大数组拆成若干小批次渐进式渲染,让浏览器有空隙处理其他任务,不至于页面白屏卡死。

一个相对通用的分批渲染组件长这样:

function useBatchedRender(items, batchSize = 30) { const [visibleCount, setVisibleCount] = useState(batchSize); useEffect(() => { setVisibleCount(batchSize); }, [items]); useEffect(() => { if (visibleCount >= items.length) return; const timer = requestAnimationFrame(() => { setVisibleCount(prev => Math.min(prev + batchSize, items.length)); }); return () => cancelAnimationFrame(timer); }, [visibleCount, items, batchSize]); return items.slice(0, visibleCount); }

这段代码的核心是把一次大渲染拆成多次小渲染,每一次requestAnimationFrame只多渲染30条。效果上,视觉效果是消息像瀑布一样快速涌入,用户几乎察觉不到分批过程,但浏览器主线程不会被一次性打满。更进一步的优化是配合useDeferredValue,把消息数组作为延迟值传入渲染逻辑,让React在有空闲时再处理这部分更新,高优先级的输入交互不会被阻塞。

React.memo在这类列表里几乎是必上的。把MessageItem用memo包一层,并且确保传给它的message对象引用不变就不重渲染。但这里有个反向陷阱:如果父组件每次渲染都重新生成一个新的message对象副本,memo就完全失效了。所以要保证数组里的消息对象引用稳定,更新单条消息时只替换那一条:

setMessages(prev => prev.map(m => m.id === updated.id ? updated : m ));

这个写法会保留其他消息对象的引用,配合memo可以做到“只重渲染那一条”。

4. 消息去重与历史拼接中的数据一致性

4.1 重复消息到底是怎么来的

消息重复显示是消息系统里最容易被甩锅的bug。常见场景包括:WebSocket断线重连后服务端重发未确认消息、拉取历史记录与实时推送在时间窗口重叠、本地重试发送导致同一条消息发出两次。如果你在拼接数组时不加任何保护,重复消息就会原样进入数组,界面上出现两条一模一样的内容,用户第一反应就是系统出bug了。

最外围的防线是在setMessages的更新函数里做去重。合并前先按唯一id索引一次,已有内容跳过,没有的再拼进去:

const mergeMessages = (prev, incoming) => { const seen = new Set(prev.map(m => m.id)); const fresh = incoming.filter(m => !seen.has(m.id)); return [...prev, ...fresh]; };

这个写法能挡住大多数重复推送。不过Set本身也是O(n)空间,消息量巨大时可以考虑把索引做成useRef维护的Map,避免每次合并都重新遍历整个数组。真正可靠的做法还是要服务端下发电台内单调递增的消息序号,前端按序号去重,这样即使WebSocket重连期间出现了乱序消息,也能靠序号排序归位。

4.2 实时消息和历史消息的顺序冲突

头部插入历史消息时,很容易出现新旧消息的顺序错乱。典型的场景是:用户打开聊天页,先用接口拉了一页最近20条消息,同时WebSocket又推来一条新消息。如果先拼接新消息再拼历史,数组就会变成[新消息,旧消息,更旧消息],渲染出来完全乱套。

正确的拼接策略是保证“数组顺序=消息时间顺序”。实现上我偏好统一入口函数:

const upsertMessages = useCallback((incoming) => { setMessages(prev => { const map = new Map(prev.map(m => [m.id, m])); incoming.forEach(m => map.set(m.id, m)); return Array.from(map.values()).sort((a, b) => a.ts - b.ts); }); }, []);

先把新旧消息都丢进Map按id去重,再统一按时间戳排序,最后转回数组。这个方案在消息量几千条内性能完全OK,而且思路无脑清晰:所有消息进来都先合并再去重再排序,不用为“新消息该加到头部还是尾部”而纠结。

4.3 跨会话切换时的数组重置

还有一个容易忽略的点:切换会话时消息数组没有清空。React的state不会因为组件props变化自动重置,如果你在同一个MessageList组件里复用不同会话的消息,上一会话的消息会残留在数组里,新会话的消息接在后面,用户看到的前后两个聊天内容混在一起。

解决办法是在会话id变化时主动重置数组:

const [sessionId, setSessionId] = useState(null); useEffect(() => { if (sessionId !== currentSessionId) { setSessionId(currentSessionId); setMessages([]); } }, [currentSessionId, sessionId]);

更稳妥的做法是给整个列表组件加一个key,用会话id作为key值:

<MessageList key={currentSessionId} sessionId={currentSessionId} />

这样React会在会话切换时卸载整个列表再重新挂载,不仅数组状态从头开始,内部的滚动位置、图片加载状态、输入框内容全部归零,省掉大量手动清理的代码。这个技巧我强烈推荐,复杂度几乎为零,却能避免一整类状态残留问题。

5. 常见问题排查与踩坑实录

5.1 新消息不渲染,列表一直停在旧状态

这类问题排第一的元凶就是变异了原数组。检查一下代码里是不是写了messages.push(msg)之后又setMessages(messages),或者messages[0] = newMsg之类。React的不可变原则在这种场景下不是口号,而是实打实的渲染保证。排查时可以先给setMessages加一个每次都生成新数组的版本测试,如果能正常显示,基本可以确定是引用没变的问题。

第二个元凶是闭包拿到了旧state。比如在useEffect里依赖了一个空数组,事件监听器注册时捕获的是最初的messages,后续消息到达后监听器里的messages还是老样子。解决办法是把更新逻辑写成函数式更新,或者把依赖项补齐。

5.2 大量消息同时到达导致白屏卡顿

白屏卡顿通常发生在两个阶段:一是消息写入state后首轮渲染太卡,二是滚动过程中的每一帧绘制都超过16ms。首轮渲染优化用分批渲染和虚拟列表;滚动卡顿则要看消息项内部是不是有太重的计算或太多DOM节点。我曾经排查过一个群聊页面,每条消息里嵌套了5层div做气泡背景,滚动起来每帧都在重排,最终把气泡改成单层div加border-radius和伪元素才解决。渲染性能和DOM结构复杂度强相关,不只是React的问题。

遇到白屏时先不要急着上虚拟列表,打开React DevTools看Profile火焰图,找到占用时间最高的组件,往往一个memo或一个useMemo就能解决,比换虚拟列表方案成本低得多。

5.3 滚动位置跳变,用户被强制拉回顶部

这个问题在加载历史消息时最明显。根因是新数组插入头部后,浏览器重新计算了滚动高度,原来那个滚动位置对应到新高度下的不同逻辑位置。修正方法我前面提到过:记录滚动容器在数据更新前的scrollTop,在useLayoutEffect里恢复。

不过还有一个更隐蔽的坑:图片消息。历史消息里的图片还没加载时高度可能为0,等图片加载完高度撑开,滚动高度又变了,用户就会看到列表在持续跳。这种场景要在图片加载后重新计算底部锚点位置,或者给图片容器预留一个与图片比例一致的固定高度,从源头避免高度突变。

5.4 问题排查速查表

问题现象常见根因处理建议
新消息不显示原数组原地push,引用没变改用展开运算符或concat生成新数组
历史消息插到列表底部头部插入用成了尾部拼接改为[...older, ...prev]
所有列表项全量重渲染key用了index,或memo失效用稳定消息id做key,保持消息对象引用不变
加载后列表跳一下插入头部导致scrollTop失效用useLayoutEffect记录并恢复滚动位置
同类消息重复出现推送和拉取时间窗口重叠合并前按id去重
切换会话后消息残留state未随会话id清空给列表组件设key=会话id
图片加载后高度抖动图片高度未预留容器按图片比例固定高度

5.5 调试消息数组的好帮手

消息数组的问题大多发生在“状态更新之后、渲染之前”这个阶段,单靠页面肉眼观察很难定位。我在本地开发时会手动在关键位置打印一份精简快照,确认数组的拼接顺序、去重结果和长度变化:

function useMessageLogger(label, messages) { useEffect(() => { if (import.meta.env.DEV) { console.log(label, { length: messages.length, first: messages[0]?.id, last: messages[messages.length - 1]?.id, }); } }, [label, messages]); }

头部和尾部的id一旦出现不符合时间顺序的情况,马上就能发现是拼接逻辑的问题。另外我偶尔会用structuredClone或JSON.parse(JSON.stringify())把整个数组导出到控制台,对比服务端原始返回的数据,确认是不是在某个中间环节丢了消息或产生了重复。

6. 我实际用下来的经验总结

做消息列表这么多年,给我留下最深印象的其实是“简单需求不简单”。一个const [messages, setMessages] = useState([])写起来很容易,但它背后牵涉到不可变更新、引用稳定性、渲染性能、滚动交互、数据去重这么多环节。再加上现在多人协作的开发模式下,服务端接口、WebSocket推送、本地缓存都可能同时在往这个数组里塞数据,任何一个环节不设防,问题最终都会以“消息错乱”的形式呈现在用户面前。

我个人的实操体会是,先把基础的数据拼接和去重做好,再考虑花哨的渲染优化。消息数组只有保持稳定、唯一、有序,渲染层才有资格去谈虚拟列表、渐进渲染这些进阶方案。顺序千万不要搞反,否则你会发现优化手段越多,隐性问题反而越难排查。

如果你正在做React的消息列表功能,建议先把本文里的拼接、去重、key、贴底滚动这四条主链路代码跑通,再根据你的数据量级逐层叠加优化。踩过几次坑之后你也会跟我一样,对setMessages里的每一行代码都带着戒心,但这恰恰是消息系统稳定运行的开始。

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

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

立即咨询