尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

React消息列表开发实战:数组拼接、渲染优化与滚动处理指南

React消息列表开发实战:数组拼接、渲染优化与滚动处理指南 做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或者本地生成的唯一idimport { 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因为用户滑动到底部时往往手指还没有完全停稳阈值太小会导致新消息来了却滚不下去体验很怪。自动滚动本身也有两种姿势。一是直接操作DOMel.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里的每一行代码都带着戒心但这恰恰是消息系统稳定运行的开始。
返回列表