IM项目中聊天列表的虚拟滚动处理

10 阅读12分钟

聊天场景虚拟滚动实现说明

ps:这是以我目前的水平和眼光所能看到的东西,像大型的IM项目:QQ、微信等的具体实现应该是有更巧妙的方法,如果有大佬有所了解也可以私信或者评论一下嘞。很想学习!

一、为什么聊天列表比普通长列表更难做虚拟滚动

很多人第一次接触虚拟滚动,都是从表格、商品列表、瀑布流这类场景开始的。这些场景虽然也有性能压力,但它们的交互相对单纯:

  • 列表通常只向下滚动
  • 数据一般只在尾部追加
  • 单项高度有时比较稳定
  • 用户关注点更多是“能不能流畅滑动”

聊天列表不一样。IM 场景里的虚拟滚动,除了性能,还要同时处理阅读体验问题。它常见的复杂点包括:

  • 单条消息高度不固定
  • 列表既会向下追加,也会在顶部插入历史消息
  • 用户可能在看最新消息,也可能在翻旧记录
  • 切换会话后再切回来,滚动位置要尽量稳定
  • 列表中不只有消息,还可能夹杂时间分隔线、系统提示、已读分割线等特殊项

所以聊天虚拟滚动不是简单的“少渲染几个 DOM”,而是一套围绕长列表渲染、滚动稳定性和阅读连续性展开的设计。

二、一个通用的聊天虚拟滚动方案,应该怎么拆

如果把方案做得尽量通用,而不是只绑定在某一个项目里,我更推荐拆成下面四层:

1. 原始数据层

这一层只负责维护消息数据本身,不负责滚动和渲染。

它可能按业务需要存成:

  • 历史消息
  • 实时消息
  • 离线补齐消息
  • 分线程消息 Map

这一层最重要的是保留业务语义,而不是为了虚拟滚动强行改造成某种特定结构。

2. 渲染适配层

这一层负责把原始数据整理成“虚拟列表可消费的一条线性渲染流”。

注意,这里说的是“渲染流”,不是“重写业务数据”。它的目标是:

  • 把不同来源的消息整理成统一顺序
  • 为每一项补充稳定标识
  • 给特殊项统一建模

这里的特殊项通常包括:

  • 时间分割线
  • 历史分界线
  • 系统通知
  • 已读标记
  • 加载提示

这是聊天虚拟滚动里非常关键的一层,因为虚拟滚动本身更擅长处理“一条线性的 item 流”,而不是多段各自渲染的结构。

3. 虚拟滚动算法层

这一层负责回答几个核心问题:

  • 当前滚动位置下,应该渲染哪一段 item
  • 顶部和底部各需要补多少占位高度
  • 每项的高度从哪里来
  • 历史加载前后如何保持视口稳定

这一层不关心业务,只关心:

  • item 是谁
  • item 大概多高
  • 当前视口在哪里

4. 滚动交互层

这一层负责把真实滚动容器和虚拟列表算法连接起来。

它通常要处理:

  • 滚动事件监听
  • 视口尺寸同步
  • 顶部触发历史加载
  • 自动贴底策略
  • 锚点捕获与恢复

这个拆分方式的优点是:

  • 数据层不被渲染细节污染
  • 算法层可独立演进
  • 模板层保持相对轻薄

三、为什么聊天虚拟滚动最好用“统一渲染流”

很多项目在最开始实现聊天时,会按业务把消息拆成几段渲染:

  • 历史消息一段
  • 离线消息一段
  • 在线消息一段

这种写法在业务表达上很直观,但不利于虚拟滚动。因为虚拟滚动的核心前提是:

列表里的每一个可见元素,都应该被放进同一条有序渲染流里。

如果仍然分段渲染,会带来几个问题:

  • 虚拟窗口不好统一计算
  • 上下占位块很难准确对应整段内容
  • 特殊提示项的位置容易跑偏
  • 新旧消息的边界很难保持稳定

所以更通用的做法是:

  1. 保留原始数据结构
  2. 在渲染适配层把它们整理成统一渲染流
  3. 虚拟滚动只消费这条渲染流

这样,虚拟滚动的输入就变成了一个纯粹的线性列表问题。

四、特殊项为什么一定要参与虚拟列表布局

这是聊天场景里一个经常被忽视的点。

很多实现会把这些内容单独写在列表外:

  • “以上为历史消息”
  • “以下为未读消息”
  • “系统提示”

这样做在普通全量渲染里可能没问题,但一旦进入虚拟滚动,问题就会暴露出来:

  • 这些提示项不参与位置计算
  • 列表窗口变化时,它们的位置语义可能错乱
  • 新消息和特殊提示之间的边界关系容易被破坏

更稳妥的通用思路是:

  • 把特殊提示也当成列表项
  • 给它稳定 id
  • 给它类型标记
  • 给它固定高度或可测量高度

这样一来,消息项和提示项都走统一布局体系,虚拟滚动算法也就不需要对它们做特别混乱的分支处理。

五、虚拟滚动的核心,不是隐藏元素,而是窗口化渲染

真正的虚拟滚动不是:

  • 把不可见元素隐藏起来

而是:

  • 只让当前窗口附近的少量 item 进入真实 DOM
  • 其他部分不渲染,用占位高度模拟完整列表

一个典型的虚拟滚动实现,通常会维护这些概念:

  • 当前滚动位置 scrollTop
  • 当前视口高度 viewportHeight
  • 当前窗口起点 startIndex
  • 当前窗口终点 endIndex
  • 顶部占位高度 topHeight
  • 底部占位高度 bottomHeight
  • 视口前后缓冲区 overscan

最终页面看到的是:

  • 顶部占位块
  • 当前窗口内真实项
  • 底部占位块

这样用户会感觉自己在滚动完整列表,但实际上 DOM 始终只保留一小段。

六、为什么聊天场景几乎一定要支持动态高度

固定高度虚拟列表实现起来很简单,但聊天场景通常不能只靠固定高度。

原因很直接:

  • 短文本和长文本高度差异很大
  • 图片消息、代码消息、引用消息高度都不同
  • 特殊提示项本身也不是消息气泡高度

一个更通用的聊天虚拟滚动方案,通常会采用:

  • 先估算高度
  • 渲染后测量真实高度
  • 将真实高度写回缓存
  • 再重新计算位置表

这是一种“先让列表跑起来,再逐步修正布局精度”的方式。

它的优点是:

  • 首屏逻辑简单
  • 能适应动态内容
  • 可读性和可维护性都比较平衡

七、为什么需要位置表

虚拟滚动不是只知道“有多少项”就够了,它还必须知道每一项在完整列表中的相对位置。

所以通常会有一张位置表,每一项至少包含:

  • top
  • height
  • bottom

它的意义是:

  • 根据 scrollTop 快速判断当前窗口落在哪些项上
  • 计算上下占位高度
  • 在历史加载后恢复视口锚点

这张位置表本质上是“当前渲染流在当前高度缓存下的完整布局索引”。

只要:

  • 列表前面插入了新内容
  • 某一项高度更新了

后续区间的位置都可能变化,因此这张表通常要重新计算。

八、为什么历史加载时不能只记住 scrollTop

这是聊天虚拟滚动里最容易误入歧途的点。

当用户向上加载历史时,很多人第一反应是:

  • 记录加载前的 scrollTop
  • 加载完成后再把 scrollTop 设回去

这个思路在前面没有内容变化时可以凑合,但在“顶部插入新项”场景里不够稳。因为:

  • 插入历史消息后,整个列表的布局基准变了
  • 原来的 scrollTop 已经不再对应原来的阅读内容

更可靠的通用做法是:

  1. 加载前找到一个稳定的可见锚点项
  2. 记录它是谁
  3. 记录它相对视口顶部的偏移量
  4. 插入历史消息后,重新找到这条锚点项
  5. 恢复它原来的相对视口位置

这样记录的是“内容锚点”,而不是“绝对滚动值”。

九、为什么新消息贴底逻辑要和历史加载逻辑完全分开

这两种场景看起来都叫“列表变化”,但它们的滚动策略刚好相反。

历史加载

  • 用户正在向上翻旧记录
  • 目标是保持当前位置稳定

新消息到达

  • 如果用户原本就在底部附近,可以自动贴底
  • 如果用户正在看历史,就不能打断

所以一个成熟的聊天虚拟滚动实现,一定要把这两种变化拆开判断,而不能简单地监听“列表长度变化”。

否则很容易出现这种 bug:

  • 用户向上加载历史
  • 列表长度增加
  • 系统误判成“新消息来了”
  • 然后自动滚到底部

这是聊天虚拟滚动里非常典型的一类错误。

十、一个比较稳的自动贴底策略应该怎么想

通用思路通常是:

  • 用户如果原本停留在底部附近,允许自动贴底
  • 用户如果已经明显离开底部,停止自动贴底

判断方式常见的是:

  • 用“距离底部的剩余像素值”做阈值判断

例如:

  • 如果距离底部小于 100 或 120 像素,认为仍处于底部阅读状态
  • 否则说明用户已经在主动翻历史

这类策略虽然不算复杂,但已经能明显提升聊天体验。

十一、如果做成一套通用方案,哪些点最值得抽象

如果想把聊天虚拟滚动做成一种可复用的实现,最值得抽象出来的是:

1. 统一 item 模型

不管是消息、分割线、系统提示还是已读标记,都统一成:

  • 唯一 id
  • item 类型
  • 原始数据
  • 可选固定高度

2. 渲染适配层

把“原始业务数据结构”和“虚拟列表需要的线性渲染流”拆开。这样换业务结构时,不必重写整个虚拟滚动。

3. 锚点恢复机制

这是聊天场景和普通列表差异最大的地方之一,非常值得做成独立能力。

4. 动态高度缓存

如果没有这个能力,聊天虚拟滚动通常很难真正稳定。

十二、这个项目里的特殊点

上面讲的是更大众、可复用的方案。下面再补充一下这个 IM 项目里比较有项目特色的处理。

1. 原始消息数据不是单段,而是三段

这个项目原本就把消息分成:

  • beforeMessages
  • offlineMessages
  • onlineMessages

这不是虚拟滚动常见的“天然扁平列表”,所以这里没有直接改 store,而是在渲染层做扁平化适配。

这是这个项目比较有代表性的一点:

虚拟滚动不是强行改掉原有业务结构,而是给现有结构加了一层渲染适配。

2. 历史分界线被做成了特殊虚拟项

这个项目里有一条很明确的历史提示:

  • ———— 以上为历史聊天记录 ————

如果把它继续写成模板里的静态块,位置会非常不稳定。后来改成了统一渲染流里的特殊项,这一点非常贴合聊天产品的真实需求。

3. 历史加载和新消息贴底逻辑曾经互相干扰

这个项目在调试过程中,实际遇到过:

  • 向上加载历史导致列表项数量增加
  • 结果被误判成新消息到达
  • 列表突然跳到底部

后来通过把“在线消息数量变化”和“总渲染项数量变化”拆开,才把这个问题收住。

这个过程本身很有复盘价值,因为它说明了聊天虚拟滚动里最难的部分往往不是“窗口化”,而是“滚动语义判断”。

4. 多会话切换会影响高度缓存稳定性

在这个项目里,还实际遇到过:

  • 切换到别的会话
  • 再切回来继续翻历史
  • 列表突然跳动

最后排查出来,一个重要原因是切会话时过早清掉了高度缓存。这个细节很能说明聊天虚拟滚动的复杂性:它不是只管当前一帧渲染,还要考虑用户在不同会话之间来回切换时的连续体验。

十三、总结

如果把聊天虚拟滚动抽象成一种通用方案,我会用下面这句话概括:

它本质上是一套“线性渲染流 + 动态高度缓存 + 位置表 + 锚点恢复 + 贴底策略”的组合设计。

它的目标不是单纯减少几个 DOM 节点,而是:

  • 在长列表场景下控制渲染成本
  • 在历史加载时保持阅读位置稳定
  • 在新消息到达时维持合理的滚动语义

而这个项目的特殊点,则主要体现在:

  • 原始消息结构是分层的
  • 历史分界线需要独立建模
  • 多会话切换会影响高度缓存和锚点恢复