本文目录8 节

完整数据在本地,不代表可以一次渲染全部 DOM

聊天详情页的完整记录已经保存在本地,页面不需要每次向服务器重新请求全部历史消息。但数据读取成本和 DOM 渲染成本是两回事。

页面通过 visibleStartIndex 控制当前渲染的消息区间:初次只展示最近一部分,用户上滑到顶部附近时,再把起点向前移动,交给 Vue 渲染更早的消息。

读取本地完整 chatList
  -> 渲染最近一段
    -> 用户上滑到顶部附近
      -> visibleStartIndex 前移
        -> Vue 插入更早的消息 DOM

这个方案降低了初次进入页面的压力,但带来了新的体验问题:历史 DOM 插入到列表顶部后,原有消息整体向下移动,用户当前阅读位置会突然跳变。

用高度差恢复滚动锚点

修复的核心不是记住某条消息的绝对 scrollTop,而是记住新内容插入前后,列表总高度增加了多少。

插入前记录:

const previousHeight = scroller.scrollHeight
const previousTop = scroller.scrollTop

更新 visibleStartIndex 并等待 Vue 完成 DOM 渲染后,再计算:

const addedHeight = scroller.scrollHeight - previousHeight
scroller.scrollTop = previousTop + addedHeight

如果顶部新增内容高 1200px,就把 scrollTop 同步增加 1200px。用户看到的那条消息仍然停留在接近原来的视口位置。

这是一种基于几何差值的滚动锚点补偿。它不要求每条消息等高,适合包含不同长度文本和多媒体内容的聊天列表。

初始数量和补历史数量应该分开

如果初始加载和每次补历史共用一个 page size,调参时会互相影响:增加数量能减少触顶次数,却会让首次进入更重;减少数量能让首屏更快,却会让用户频繁看到 loading。

因此我将两个参数拆开:

const INITIAL_CHAT_PAGE_SIZE = 100
const HISTORY_LOAD_PAGE_SIZE = 150

它们不是通用最佳值,只是当前压测下便于独立观察的参数。真正重要的是把“首屏成本”和“滚动过程中的批次成本”分成两个可调维度。

同时补充了加载早期消息的反馈和多语言文案,避免用户触顶后只看到页面短暂停顿,不知道系统正在插入历史内容。

为什么 3000 条消息仍然会卡

在 Android WebView 中构造 3000 条本地聊天记录后,分批加载、锚点补偿和 loading 能改善体验,却不能消除大量 DOM 的渲染成本。

随着用户不断向上加载,已插入页面的消息节点仍然持续增加。文本布局、图片解码、视频组件和 Vue 响应式更新都会叠加。这里的根本问题不是接口分页,而是长生命周期页面中的 DOM 数量。

因此必须准确描述当前结果:滚动跳变得到修复,加载感受得到改善,但超长聊天的性能瓶颈只是缓解,没有彻底解决。

为什么没有立刻改成虚拟列表

虚拟列表能够回收视口外节点,看起来是自然的下一步。但聊天列表比固定高度商品列表复杂得多:

  • 消息高度不固定;
  • 图片和视频加载后会二次改变高度;
  • AI 回复可能流式增长;
  • 新消息到达时需要判断是否自动滚到底部;
  • 向上补历史后需要保持阅读锚点;
  • 消息内还有长按、预览、重试等交互。

虚拟化会把“DOM 太多”转化为“高度测量、锚点恢复和组件复用”的复杂度。如果没有足够回归时间,贸然引入可能让原本稳定的聊天交互出现更多边界问题。

当前更合理的结论是:阶段性优化已经降低明显跳变,虚拟列表作为后续专项评估,而不是在一次 Bug 修复中顺手重构。

iOS 与 Android 必须分别压测

我在 iOS 模拟器中用相同规模数据测试,没有稳定复现 Android 同款滚动跳变。这不代表 Android 修复无效,也不代表 iOS 永远没有性能问题。

WebView 内核、滚动实现、渲染时机和手势处理都可能让相同 Vue 代码表现不同。跨平台问题不能因为代码相似就默认同时存在,也不能因为一个平台正常就直接删除另一个平台的兼容处理。

更稳的做法是使用相同测试数据、相同操作路径分别记录结果,再决定是否共享修复。

图片 pending 需要单独判断

长聊天测试中还观察到部分图片请求长时间 pending。接口和文本消息基本正常,切换网络后图片资源仍然慢,因此它更接近资源服务或 CDN 响应问题,而不是聊天历史渲染逻辑本身。

前端可以优化可视区域优先级、缩略图、占位、并发控制和失败重试,但不能把资源服务器的响应时间包装成已被前端修复。

把 DOM 性能和资源加载拆开记录,能避免两个同时出现的慢问题互相干扰判断。

这次优化留下的边界意识

长列表优化不能只问“数据有多少”,还要问页面里同时保留了多少 DOM、每条内容何时改变高度、用户当前阅读锚点由什么维持。

一次可靠的阶段性修复,应当明确它解决的是哪一种体验问题,也明确没有解决什么。比起急着宣布“性能问题已完成”,我更愿意留下可复现的压测规模、现有收益和虚拟化改造的真实风险。

Recommended

继续阅读

  1. 一个两行修复,为什么从 Vue 一直追到了 Android 原生插件?前端与移动端 · 9 分钟
  2. 一次 Android 返回手势问题的完整排查过程前端与移动端 · 6 分钟