AI 消息列表虚拟滚动:这是业务问题,还是组件能力边界?

9 阅读10分钟

AI 消息列表虚拟滚动:这是业务问题,还是组件能力边界?

前言

在做 AI 对话工作台时,消息列表迟早会遇到一个问题:

历史消息越来越多,页面要不要上虚拟滚动?

这个问题看起来很自然。

因为从性能角度讲,不可能无限制把所有消息都渲染在 DOM 里。

但真正落到 AI 对话场景里,它又没有普通列表那么简单。

当前项目已经实现了基础的历史分页:

首次加载最近 50 条历史消息
用户上滑到顶部附近
使用 lastCreateTime 游标加载更早消息
把更早消息 prepend 到 messages 前面
通过 scrollHeight 差值修正 scrollTop,避免页面跳动

这个阶段已经解决了“不要一次性拉取所有历史消息”的问题。

但它还没有解决“前端 DOM 节点无限增长”的问题。

所以很自然地,我开始思考下一步:

要不要马上加虚拟滚动?

最后我的结论是:

如果继续使用 BubbleList,真正的虚拟滚动更应该是组件层支持的能力,而不是业务侧硬改出来的功能。

这篇文章就记录一下这个判断过程:为什么当前业务层已经完成了该做的分页加载,为什么虚拟滚动不适合继续在业务代码里强行补,以及后续更合理的演进方式是什么。

当前已经做到了什么

现在消息列表不是一次性加载所有历史。

进入某个会话时,先加载最近一页:

listAppChatHistory(appId, {
  pageSize: WORKBENCH_HISTORY_PAGE_SIZE,
});

然后取当前这一页里最早的 createTime 作为游标:

target.historyCursor = getEarliestHistoryCursor(result.records);

用户继续上滑到顶部附近时,再请求更早历史:

listAppChatHistory(target.appId, {
  pageSize: WORKBENCH_HISTORY_PAGE_SIZE,
  lastCreateTime: target.historyCursor,
});

拿到更早消息后,不是追加到后面,而是插到前面:

target.messages = mergeOlderMessages(olderMessages, target.messages);

为了避免页面跳动,加载前记录滚动高度:

const previousScrollHeight = scrollElement.scrollHeight;
const previousScrollTop = scrollElement.scrollTop;

消息 prepend 完成后,等待 DOM 更新:

await nextTick();

再修正滚动位置:

const nextScrollHeight = scrollElement.scrollHeight;
scrollElement.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);

这套逻辑解决的是聊天时间线分页最关键的问题:

加载更早历史时,用户当前阅读位置不能跳。

所以当前版本不是单纯 demo。

它已经有真实项目的基础骨架:

cursor 分页
上滑加载更早消息
prepend 合并
消息去重
滚动锚定
加载锁
会话切换保护

但它还不是虚拟滚动

分页和虚拟滚动解决的是两个不同问题。

分页解决的是:

不要一次性从后端加载所有历史消息。

虚拟滚动解决的是:

不要一次性在前端渲染所有已经加载的消息。

当前实现属于前者。

如果用户持续上滑:

加载 50 条
加载到 100 条
加载到 150 条
加载到 300 条
加载到 1000 条

这些消息最终都会留在 conversation.messages,也会继续传给 BubbleList 渲染。

也就是说:

接口压力被分页降低了
但 DOM 渲染压力还没有完全解决

这就是虚拟滚动存在的意义。

为什么没有马上做虚拟滚动

如果这是一个普通列表,我可能会很快接入虚拟滚动。

比如商品列表:

每一项高度接近
结构相对固定
不会持续流式变化
不会有 Markdown 动态渲染
不会有代码块和表格撑高

这种场景很适合直接使用虚拟列表。

但 AI 消息列表不是这样。

一条 AI 消息可能是:

一句普通回复
一段很长的需求分析
一个 Markdown 表格
一大段代码块
一个正在流式输出的回答
一个带打字机效果的动态内容

这些消息高度完全不稳定。

更麻烦的是,它不是渲染完就结束。

AI 回复会随着 SSE chunk 持续增长:

content 从空字符串开始
每次 chunk 到达后追加
MarkdownRenderer 重新渲染
Bubble typing 继续展示
消息高度持续变化

这会给虚拟滚动带来很多额外复杂度。

难点一:消息高度不是固定的

虚拟滚动最简单的情况是固定高度。

比如:

itemHeight = 80;

这样列表可以很容易计算:

第 100 条消息应该出现在 8000px 的位置

但 AI 消息不可能这么算。

同一个消息列表里,可能出现:

用户短消息:40px
AI 普通回复:160px
AI 长文方案:1200px
代码块:800px
Markdown 表格:600px

这就需要动态高度虚拟列表。

动态高度意味着:

每条消息渲染后要测量真实高度
高度要缓存
内容变化后要重新测量
滚动位置要根据高度变化修正

这比普通虚拟列表复杂很多。

难点二:流式输出会持续改变高度

当前 AI 回复是流式的。

前端会先插入一条 assistant 空消息:

{
  role: 'assistant',
  content: '',
  typing: { step: 2, interval: 24, suffix: '|' },
}

然后每个 chunk 到达后追加内容:

content: `${item.content}${chunk}`

这意味着最后一条 assistant 消息的高度会不断变化。

如果做虚拟滚动,就必须处理:

正在流式输出的消息高度变化
虚拟列表重新测量
如果用户在底部,要继续跟随
如果用户在看历史,不能强行滚到底部

这不是一个简单的 virtual: true 能解决的问题。

难点三:Markdown 渲染会让高度再次变化

项目里 AI 消息已经接入了 Markdown 渲染:

messageRender: (content: unknown) => h(MarkdownRenderer, {
  content: String(content ?? ''),
})

MarkdownRenderer 内部会做:

markdown-it 解析 Markdown
highlight.js 处理代码高亮
DOMPurify 做安全过滤
v-html 渲染 HTML

Markdown 的高度不是线性可控的。

比如模型正在输出代码块:

```ts
const a = 1

代码块还没闭合时是一种渲染结果。

后续闭合后又是一种渲染结果。

如果再加上代码高亮、表格、图片预览,消息高度会持续调整。

虚拟列表必须能感知这些高度变化,否则会出现:

滚动条位置不准
消息重叠
底部留白
滚动锚定失效

难点四:BubbleList 本身不是虚拟列表

当前消息区使用的是:

<BubbleList
  :items="conversation.messages"
  :roles="roles"
  :auto-scroll="true"
/>

BubbleList 的职责是:

接收完整 items
渲染所有 Bubble
处理 roles
处理 autoScroll
处理 typing
暴露 onScroll

它不是虚拟滚动组件。

虚拟滚动需要控制:

当前只渲染哪些消息
顶部占位高度
底部占位高度
每条消息真实高度
滚动容器
动态测量

这些和 BubbleList 的完整列表渲染职责有冲突。

如果真要做严格虚拟滚动,更合理的结构可能是:

VirtualMessageList
  负责虚拟滚动、可视区域、动态高度、滚动锚定

Bubble
  只负责单条消息气泡展示

也就是从:

<BubbleList :items="messages" />

演进到:

<VirtualList :items="messages">
  <template #default="{ item }">
    <Bubble
      :content="item.content"
      :typing="item.typing"
      :message-render="renderMarkdown"
    />
  </template>
</VirtualList>

这个改动不是小修小补,而是消息列表架构升级。

难点五:autoScroll 和历史分页已经很敏感

现在消息列表已经有两类滚动策略。

第一类是新消息输出:

用户在底部时,AI 输出要自动跟随到底部。

第二类是上滑加载历史:

用户在顶部附近时,加载更早消息,并保持当前阅读位置不跳。

这两类滚动方向是相反的。

如果再引入虚拟滚动,还要同时处理:

虚拟列表滚动范围变化
上方 prepend 历史消息
底部 append 新消息
当前用户是否在底部
当前用户是否在历史浏览状态
正在 typing 的消息是否还可见

这已经不是一个单点优化,而是滚动状态机。

所以我不认为现在应该为了“听起来更高级”就直接改虚拟滚动。

这更像组件能力边界

看到社区里也有人讨论 BubbleList 是否应该支持虚拟滚动,我反而更确认了一点:

这不是某一个业务项目单独遇到的问题,而是 AI 消息列表组件天然会遇到的能力诉求。

如果一个组件定位是 AI 消息列表,它不仅要能渲染气泡,还迟早要面对:

长会话
大量历史消息
动态高度消息
流式输出
自动滚动
上滑加载更早历史

这些能力放在一起,虚拟滚动就不只是一个性能小优化,而是列表组件架构的一部分。

如果业务侧为了继续使用 BubbleList,强行在外面包一层虚拟滚动,很容易出现职责冲突:

外层虚拟列表想控制渲染范围
BubbleList 内部又要渲染完整 items
外层想控制滚动容器
BubbleList 内部也有自己的滚动容器
外层要做动态高度测量
BubbleList 内部还要处理 autoScroll 和 typing

这不是一个优雅的扩展方式。

所以我更倾向把它定义为:

BubbleList 当前的能力边界

业务侧可以完成历史分页、游标、prepend、滚动锚定。

但真正的虚拟滚动,如果还想沿用 BubbleList 的 roles、typing、messageRender、autoScroll,就更应该由组件本身提供。

比如组件层如果支持:

<BubbleList
  virtual
  :items="messages"
  :roles="roles"
  :estimate-size="120"
/>

或者提供更底层的组合能力:

BubbleList 负责虚拟时间线
Bubble 负责单条气泡
内部统一处理 autoScroll / typing / dynamic height

这会比业务侧自己绕开组件实现可靠得多。

业务侧现在应该做到哪里

既然虚拟滚动更像组件能力,那业务侧是不是就什么都不用做?

也不是。

业务侧应该把“数据时间线”做好。

也就是:

历史分页
游标维护
消息合并
消息去重
加载状态
错误处理
滚动锚定

这些是业务系统必须负责的。

当前项目已经完成了核心部分:

首次加载最近历史
上滑加载更早历史
lastCreateTime 游标
olderMessages prepend
按消息 key 去重
scrollHeight 差值保持视口稳定

这部分不依赖虚拟滚动。

即使未来组件支持虚拟滚动,业务侧仍然需要这些数据逻辑。

换句话说:

业务侧负责“有哪些消息”
组件侧负责“怎么高性能渲染这些消息”

这条边界要分清楚。

小结

这次本来想继续把消息列表升级成无限滚动甚至虚拟滚动。

但深入看下来,AI 消息列表的复杂度比普通列表高很多。

主要难点在于:

消息高度动态
SSE 流式输出持续改变内容
Markdown 渲染会改变 DOM 高度
BubbleList 本身不是虚拟列表
autoScroll 和上滑加载是两套相反策略
滚动状态需要更系统地管理

更重要的是,如果继续使用 BubbleList,真正虚拟滚动更像组件层能力,而不是业务层应该硬补的能力。

所以当前更合理的判断是:

业务侧做好消息时间线分页和滚动锚定
虚拟滚动等待组件层支持
如果组件长期不支持且确实出现性能瓶颈,再做 VirtualMessageList + Bubble 的架构替换

这不是技术妥协,而是工程取舍。

好的前端架构不是一开始就把所有高级能力塞进去。

而是在每个阶段知道:

当前最该解决什么
哪些能力属于业务侧
哪些能力属于组件侧

对这个 AI 工作台来说,现在已经完成了消息时间线分页的核心能力。

下一步不应该在业务里强行给 BubbleList 塞虚拟滚动,而是继续把业务时间线做好,同时关注组件本身是否提供正式的虚拟滚动能力。