聊天场景虚拟滚动实现说明
ps:这是以我目前的水平和眼光所能看到的东西,像大型的IM项目:QQ、微信等的具体实现应该是有更巧妙的方法,如果有大佬有所了解也可以私信或者评论一下嘞。很想学习!
一、为什么聊天列表比普通长列表更难做虚拟滚动
很多人第一次接触虚拟滚动,都是从表格、商品列表、瀑布流这类场景开始的。这些场景虽然也有性能压力,但它们的交互相对单纯:
- 列表通常只向下滚动
- 数据一般只在尾部追加
- 单项高度有时比较稳定
- 用户关注点更多是“能不能流畅滑动”
聊天列表不一样。IM 场景里的虚拟滚动,除了性能,还要同时处理阅读体验问题。它常见的复杂点包括:
- 单条消息高度不固定
- 列表既会向下追加,也会在顶部插入历史消息
- 用户可能在看最新消息,也可能在翻旧记录
- 切换会话后再切回来,滚动位置要尽量稳定
- 列表中不只有消息,还可能夹杂时间分隔线、系统提示、已读分割线等特殊项
所以聊天虚拟滚动不是简单的“少渲染几个 DOM”,而是一套围绕长列表渲染、滚动稳定性和阅读连续性展开的设计。
二、一个通用的聊天虚拟滚动方案,应该怎么拆
如果把方案做得尽量通用,而不是只绑定在某一个项目里,我更推荐拆成下面四层:
1. 原始数据层
这一层只负责维护消息数据本身,不负责滚动和渲染。
它可能按业务需要存成:
- 历史消息
- 实时消息
- 离线补齐消息
- 分线程消息 Map
这一层最重要的是保留业务语义,而不是为了虚拟滚动强行改造成某种特定结构。
2. 渲染适配层
这一层负责把原始数据整理成“虚拟列表可消费的一条线性渲染流”。
注意,这里说的是“渲染流”,不是“重写业务数据”。它的目标是:
- 把不同来源的消息整理成统一顺序
- 为每一项补充稳定标识
- 给特殊项统一建模
这里的特殊项通常包括:
- 时间分割线
- 历史分界线
- 系统通知
- 已读标记
- 加载提示
这是聊天虚拟滚动里非常关键的一层,因为虚拟滚动本身更擅长处理“一条线性的 item 流”,而不是多段各自渲染的结构。
3. 虚拟滚动算法层
这一层负责回答几个核心问题:
- 当前滚动位置下,应该渲染哪一段 item
- 顶部和底部各需要补多少占位高度
- 每项的高度从哪里来
- 历史加载前后如何保持视口稳定
这一层不关心业务,只关心:
- item 是谁
- item 大概多高
- 当前视口在哪里
4. 滚动交互层
这一层负责把真实滚动容器和虚拟列表算法连接起来。
它通常要处理:
- 滚动事件监听
- 视口尺寸同步
- 顶部触发历史加载
- 自动贴底策略
- 锚点捕获与恢复
这个拆分方式的优点是:
- 数据层不被渲染细节污染
- 算法层可独立演进
- 模板层保持相对轻薄
三、为什么聊天虚拟滚动最好用“统一渲染流”
很多项目在最开始实现聊天时,会按业务把消息拆成几段渲染:
- 历史消息一段
- 离线消息一段
- 在线消息一段
这种写法在业务表达上很直观,但不利于虚拟滚动。因为虚拟滚动的核心前提是:
列表里的每一个可见元素,都应该被放进同一条有序渲染流里。
如果仍然分段渲染,会带来几个问题:
- 虚拟窗口不好统一计算
- 上下占位块很难准确对应整段内容
- 特殊提示项的位置容易跑偏
- 新旧消息的边界很难保持稳定
所以更通用的做法是:
- 保留原始数据结构
- 在渲染适配层把它们整理成统一渲染流
- 虚拟滚动只消费这条渲染流
这样,虚拟滚动的输入就变成了一个纯粹的线性列表问题。
四、特殊项为什么一定要参与虚拟列表布局
这是聊天场景里一个经常被忽视的点。
很多实现会把这些内容单独写在列表外:
- “以上为历史消息”
- “以下为未读消息”
- “系统提示”
这样做在普通全量渲染里可能没问题,但一旦进入虚拟滚动,问题就会暴露出来:
- 这些提示项不参与位置计算
- 列表窗口变化时,它们的位置语义可能错乱
- 新消息和特殊提示之间的边界关系容易被破坏
更稳妥的通用思路是:
- 把特殊提示也当成列表项
- 给它稳定 id
- 给它类型标记
- 给它固定高度或可测量高度
这样一来,消息项和提示项都走统一布局体系,虚拟滚动算法也就不需要对它们做特别混乱的分支处理。
五、虚拟滚动的核心,不是隐藏元素,而是窗口化渲染
真正的虚拟滚动不是:
- 把不可见元素隐藏起来
而是:
- 只让当前窗口附近的少量 item 进入真实 DOM
- 其他部分不渲染,用占位高度模拟完整列表
一个典型的虚拟滚动实现,通常会维护这些概念:
- 当前滚动位置
scrollTop - 当前视口高度
viewportHeight - 当前窗口起点
startIndex - 当前窗口终点
endIndex - 顶部占位高度
topHeight - 底部占位高度
bottomHeight - 视口前后缓冲区
overscan
最终页面看到的是:
- 顶部占位块
- 当前窗口内真实项
- 底部占位块
这样用户会感觉自己在滚动完整列表,但实际上 DOM 始终只保留一小段。
六、为什么聊天场景几乎一定要支持动态高度
固定高度虚拟列表实现起来很简单,但聊天场景通常不能只靠固定高度。
原因很直接:
- 短文本和长文本高度差异很大
- 图片消息、代码消息、引用消息高度都不同
- 特殊提示项本身也不是消息气泡高度
一个更通用的聊天虚拟滚动方案,通常会采用:
- 先估算高度
- 渲染后测量真实高度
- 将真实高度写回缓存
- 再重新计算位置表
这是一种“先让列表跑起来,再逐步修正布局精度”的方式。
它的优点是:
- 首屏逻辑简单
- 能适应动态内容
- 可读性和可维护性都比较平衡
七、为什么需要位置表
虚拟滚动不是只知道“有多少项”就够了,它还必须知道每一项在完整列表中的相对位置。
所以通常会有一张位置表,每一项至少包含:
topheightbottom
它的意义是:
- 根据
scrollTop快速判断当前窗口落在哪些项上 - 计算上下占位高度
- 在历史加载后恢复视口锚点
这张位置表本质上是“当前渲染流在当前高度缓存下的完整布局索引”。
只要:
- 列表前面插入了新内容
- 某一项高度更新了
后续区间的位置都可能变化,因此这张表通常要重新计算。
八、为什么历史加载时不能只记住 scrollTop
这是聊天虚拟滚动里最容易误入歧途的点。
当用户向上加载历史时,很多人第一反应是:
- 记录加载前的
scrollTop - 加载完成后再把
scrollTop设回去
这个思路在前面没有内容变化时可以凑合,但在“顶部插入新项”场景里不够稳。因为:
- 插入历史消息后,整个列表的布局基准变了
- 原来的
scrollTop已经不再对应原来的阅读内容
更可靠的通用做法是:
- 加载前找到一个稳定的可见锚点项
- 记录它是谁
- 记录它相对视口顶部的偏移量
- 插入历史消息后,重新找到这条锚点项
- 恢复它原来的相对视口位置
这样记录的是“内容锚点”,而不是“绝对滚动值”。
九、为什么新消息贴底逻辑要和历史加载逻辑完全分开
这两种场景看起来都叫“列表变化”,但它们的滚动策略刚好相反。
历史加载
- 用户正在向上翻旧记录
- 目标是保持当前位置稳定
新消息到达
- 如果用户原本就在底部附近,可以自动贴底
- 如果用户正在看历史,就不能打断
所以一个成熟的聊天虚拟滚动实现,一定要把这两种变化拆开判断,而不能简单地监听“列表长度变化”。
否则很容易出现这种 bug:
- 用户向上加载历史
- 列表长度增加
- 系统误判成“新消息来了”
- 然后自动滚到底部
这是聊天虚拟滚动里非常典型的一类错误。
十、一个比较稳的自动贴底策略应该怎么想
通用思路通常是:
- 用户如果原本停留在底部附近,允许自动贴底
- 用户如果已经明显离开底部,停止自动贴底
判断方式常见的是:
- 用“距离底部的剩余像素值”做阈值判断
例如:
- 如果距离底部小于 100 或 120 像素,认为仍处于底部阅读状态
- 否则说明用户已经在主动翻历史
这类策略虽然不算复杂,但已经能明显提升聊天体验。
十一、如果做成一套通用方案,哪些点最值得抽象
如果想把聊天虚拟滚动做成一种可复用的实现,最值得抽象出来的是:
1. 统一 item 模型
不管是消息、分割线、系统提示还是已读标记,都统一成:
- 唯一 id
- item 类型
- 原始数据
- 可选固定高度
2. 渲染适配层
把“原始业务数据结构”和“虚拟列表需要的线性渲染流”拆开。这样换业务结构时,不必重写整个虚拟滚动。
3. 锚点恢复机制
这是聊天场景和普通列表差异最大的地方之一,非常值得做成独立能力。
4. 动态高度缓存
如果没有这个能力,聊天虚拟滚动通常很难真正稳定。
十二、这个项目里的特殊点
上面讲的是更大众、可复用的方案。下面再补充一下这个 IM 项目里比较有项目特色的处理。
1. 原始消息数据不是单段,而是三段
这个项目原本就把消息分成:
beforeMessagesofflineMessagesonlineMessages
这不是虚拟滚动常见的“天然扁平列表”,所以这里没有直接改 store,而是在渲染层做扁平化适配。
这是这个项目比较有代表性的一点:
虚拟滚动不是强行改掉原有业务结构,而是给现有结构加了一层渲染适配。
2. 历史分界线被做成了特殊虚拟项
这个项目里有一条很明确的历史提示:
———— 以上为历史聊天记录 ————
如果把它继续写成模板里的静态块,位置会非常不稳定。后来改成了统一渲染流里的特殊项,这一点非常贴合聊天产品的真实需求。
3. 历史加载和新消息贴底逻辑曾经互相干扰
这个项目在调试过程中,实际遇到过:
- 向上加载历史导致列表项数量增加
- 结果被误判成新消息到达
- 列表突然跳到底部
后来通过把“在线消息数量变化”和“总渲染项数量变化”拆开,才把这个问题收住。
这个过程本身很有复盘价值,因为它说明了聊天虚拟滚动里最难的部分往往不是“窗口化”,而是“滚动语义判断”。
4. 多会话切换会影响高度缓存稳定性
在这个项目里,还实际遇到过:
- 切换到别的会话
- 再切回来继续翻历史
- 列表突然跳动
最后排查出来,一个重要原因是切会话时过早清掉了高度缓存。这个细节很能说明聊天虚拟滚动的复杂性:它不是只管当前一帧渲染,还要考虑用户在不同会话之间来回切换时的连续体验。
十三、总结
如果把聊天虚拟滚动抽象成一种通用方案,我会用下面这句话概括:
它本质上是一套“线性渲染流 + 动态高度缓存 + 位置表 + 锚点恢复 + 贴底策略”的组合设计。
它的目标不是单纯减少几个 DOM 节点,而是:
- 在长列表场景下控制渲染成本
- 在历史加载时保持阅读位置稳定
- 在新消息到达时维持合理的滚动语义
而这个项目的特殊点,则主要体现在:
- 原始消息结构是分层的
- 历史分界线需要独立建模
- 多会话切换会影响高度缓存和锚点恢复