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 塞虚拟滚动,而是继续把业务时间线做好,同时关注组件本身是否提供正式的虚拟滚动能力。