目录
- [1. 问题背景:AI 对话为什么越聊越卡](#1. 问题背景:AI 对话为什么越聊越卡)
- [2. 虚拟滚动的核心原理](#2. 虚拟滚动的核心原理)
- [3. 从零实现一个消息虚拟列表](#3. 从零实现一个消息虚拟列表)
- [3.1 定高方案:先跑通最小闭环](#3.1 定高方案:先跑通最小闭环)
- [3.2 动态高度:引入测量机制](#3.2 动态高度:引入测量机制)
- [4. 万级消息的关键难点](#4. 万级消息的关键难点)
- [4.1 动态高度与滚动锚定](#4.1 动态高度与滚动锚定)
- [4.2 流式输出下的增量更新](#4.2 流式输出下的增量更新)
- [4.3 自动滚动与用户阅读的冲突](#4.3 自动滚动与用户阅读的冲突)
- [4.4 渲染性能与内存控制](#4.4 渲染性能与内存控制)
- [5. 工程落地与性能验证](#5. 工程落地与性能验证)
- [6. 总结](#6. 总结)
1. 问题背景:AI 对话为什么越聊越卡
在 AI 对话类产品中,一个看似简单的需求往往藏着最隐蔽的性能陷阱:长对话会话。用户和助手来回上千轮、消息累积到上万条时,页面滚动开始掉帧、输入卡顿、内存飙升,甚至直接崩溃。
传统做法是把所有消息一次性渲染成 DOM:
tsx
<div className="message-list">
{messages.map((msg) => (
<MessageItem key={msg.id} message={msg} />
))}
</div>
这个方案在几十条消息内完全够用,但当消息达到万级时,问题会集中爆发:
- DOM 节点爆炸:每条消息可能包含头像、气泡、Markdown 渲染后的段落、代码块、列表,单条消息轻松产生几十个节点,万条消息就是数十万节点,首屏渲染和重排成本极高。
- 流式输出放大开销 :AI 回复是逐 token 流式返回的,每来一个 token 就要触发一次组件更新。
messages.map全量重渲染意味着每次 token 到达都在重算上万条消息。 - Markdown 渲染是重灾区:代码高亮、复杂表格、内联公式的解析非常耗时,流式过程中反复全量渲染会让 CPU 长时间打满。
- 滚动锚定失效:用户往上翻阅历史时,如果列表被动重排,阅读位置会被强行拽走,体验割裂。
解决这个问题的核心手段,就是虚拟滚动(Virtual Scrolling):只渲染可视区域内的消息,其余消息用占位空间代替。
2. 虚拟滚动的核心原理
虚拟滚动本质上是「空间换时间」的逆向操作:不渲染不可见内容,用计算出的空白占位维持滚动条的真实感。
它的三个核心概念:
- 可视区(Viewport):滚动容器的可见高度。
- 缓冲区(Buffer):在可视区上下额外渲染的少量消息,避免快速滚动时出现白屏。
- 偏移量(Offset):通过累加不可见消息的高度,计算出当前内容的平移距离。
整体流程如下:
#mermaid-svg-sdOd2p6qlWbQUaqB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sdOd2p6qlWbQUaqB .error-icon{fill:#552222;}#mermaid-svg-sdOd2p6qlWbQUaqB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sdOd2p6qlWbQUaqB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sdOd2p6qlWbQUaqB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sdOd2p6qlWbQUaqB .marker.cross{stroke:#333333;}#mermaid-svg-sdOd2p6qlWbQUaqB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sdOd2p6qlWbQUaqB p{margin:0;}#mermaid-svg-sdOd2p6qlWbQUaqB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sdOd2p6qlWbQUaqB .cluster-label text{fill:#333;}#mermaid-svg-sdOd2p6qlWbQUaqB .cluster-label span{color:#333;}#mermaid-svg-sdOd2p6qlWbQUaqB .cluster-label span p{background-color:transparent;}#mermaid-svg-sdOd2p6qlWbQUaqB .label text,#mermaid-svg-sdOd2p6qlWbQUaqB span{fill:#333;color:#333;}#mermaid-svg-sdOd2p6qlWbQUaqB .node rect,#mermaid-svg-sdOd2p6qlWbQUaqB .node circle,#mermaid-svg-sdOd2p6qlWbQUaqB .node ellipse,#mermaid-svg-sdOd2p6qlWbQUaqB .node polygon,#mermaid-svg-sdOd2p6qlWbQUaqB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sdOd2p6qlWbQUaqB .rough-node .label text,#mermaid-svg-sdOd2p6qlWbQUaqB .node .label text,#mermaid-svg-sdOd2p6qlWbQUaqB .image-shape .label,#mermaid-svg-sdOd2p6qlWbQUaqB .icon-shape .label{text-anchor:middle;}#mermaid-svg-sdOd2p6qlWbQUaqB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sdOd2p6qlWbQUaqB .rough-node .label,#mermaid-svg-sdOd2p6qlWbQUaqB .node .label,#mermaid-svg-sdOd2p6qlWbQUaqB .image-shape .label,#mermaid-svg-sdOd2p6qlWbQUaqB .icon-shape .label{text-align:center;}#mermaid-svg-sdOd2p6qlWbQUaqB .node.clickable{cursor:pointer;}#mermaid-svg-sdOd2p6qlWbQUaqB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sdOd2p6qlWbQUaqB .arrowheadPath{fill:#333333;}#mermaid-svg-sdOd2p6qlWbQUaqB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sdOd2p6qlWbQUaqB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sdOd2p6qlWbQUaqB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sdOd2p6qlWbQUaqB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sdOd2p6qlWbQUaqB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sdOd2p6qlWbQUaqB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sdOd2p6qlWbQUaqB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sdOd2p6qlWbQUaqB .cluster text{fill:#333;}#mermaid-svg-sdOd2p6qlWbQUaqB .cluster span{color:#333;}#mermaid-svg-sdOd2p6qlWbQUaqB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sdOd2p6qlWbQUaqB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sdOd2p6qlWbQUaqB rect.text{fill:none;stroke-width:0;}#mermaid-svg-sdOd2p6qlWbQUaqB .icon-shape,#mermaid-svg-sdOd2p6qlWbQUaqB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sdOd2p6qlWbQUaqB .icon-shape p,#mermaid-svg-sdOd2p6qlWbQUaqB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sdOd2p6qlWbQUaqB .icon-shape .label rect,#mermaid-svg-sdOd2p6qlWbQUaqB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sdOd2p6qlWbQUaqB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sdOd2p6qlWbQUaqB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sdOd2p6qlWbQUaqB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 滚动事件触发
计算 scrollTop
根据消息高度推算 startIndex
确定可视区 + 缓冲区的消息范围
只渲染该范围内的消息
用上下 padding 撑起总高度
应用 translateY 偏移
这一步把渲染成本从 O(消息总数) 降到了 O(可视消息数)。
3. 从零实现一个消息虚拟列表
3.1 定高方案:先跑通最小闭环
如果每条消息高度固定,虚拟滚动实现非常简单:
tsx
const ITEM_HEIGHT = 72;
const BUFFER = 5;
function VirtualChatList({ messages }: { messages: Message[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
setViewportHeight(el.clientHeight);
const onResize = () => setViewportHeight(el.clientHeight);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);
const visibleCount = Math.ceil(viewportHeight / ITEM_HEIGHT) + BUFFER * 2;
const endIndex = Math.min(messages.length, startIndex + visibleCount);
const visibleMessages = messages.slice(startIndex, endIndex);
const totalHeight = messages.length * ITEM_HEIGHT;
const offsetY = startIndex * ITEM_HEIGHT;
return (
<div
ref={containerRef}
className="virtual-list"
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleMessages.map((msg) => (
<MessageItem key={msg.id} message={msg} />
))}
</div>
</div>
</div>
);
}
这个版本能解决「渲染节点多」的问题,但对 AI 对话流来说还远远不够,因为聊天消息高度天然不固定:一条简短回复可能只有 40px,一段带代码块的回答可能有 2000px。
3.2 动态高度:引入测量机制
动态高度方案需要在渲染后测量每条消息的真实高度并缓存下来,滚动时基于缓存推算位置:
tsx
type MeasuredItem = {
id: string;
height: number;
offset: number; // 该消息顶部相对列表总高度的偏移
};
function useVariableVirtualList(messages: Message[]) {
const measurementsRef = useRef(new Map<string, MeasuredItem>());
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
// 根据滚动位置推算起始下标:由于高度不定,需要二分查找
const findStartIndex = (scrollTop: number) => {
const items = messages;
let low = 0;
let high = items.length - 1;
while (low < high) {
const mid = (low + high) >>> 1;
const m = measurementsRef.current.get(items[mid].id);
if (m && m.offset + m.height > scrollTop) {
high = mid;
} else {
low = mid + 1;
}
}
return low;
};
// ...
}
高度测量的实现通常依赖 ResizeObserver,监听每条消息容器尺寸变化后更新缓存:
tsx
function MessageRow({
message,
onResize,
}: {
message: Message;
onResize: (id: string, height: number) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const height = entries[0].contentRect.height;
onResize(message.id, height);
});
observer.observe(el);
return () => observer.disconnect();
}, [message.id, onResize]);
return (
<div ref={ref}>
<MessageItem message={message} />
</div>
);
}
4. 万级消息的关键难点
把「能用」变成「在万级消息下依然丝滑」,真正的挑战集中在下面四点。
4.1 动态高度与滚动锚定
高度变化会引发一个经典问题:用户在翻阅历史时,新消息插入或旧消息高度变化,会把他正在看的内容「顶」走。
解决的思路是保存滚动锚点------以某条可见消息为参照,在内容变化后重算 scrollTop,让它相对视口的位置保持不变:
tsx
function restoreScrollAnchor(
container: HTMLElement,
anchorId: string,
changeDelta: number,
) {
const anchorEl = container.querySelector(`[data-id="${anchorId}"]`);
if (!anchorEl) return;
// 以锚点元素为基准,根据高度变化量修正滚动位置
const rect = anchorEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
container.scrollTop += rect.top - containerRect.top - changeDelta;
}
实践中,浏览器原生提供的 CSS overflow-anchor 可以大幅缓解这个问题。但它对「虚拟列表 + 高度剧烈变化」的组合支持并不完美,所以在关键路径上仍建议用 JS 手动锚定兜底。
4.2 流式输出下的增量更新
AI 回复逐 token 到达时,如果每来一个 token 都触发整条消息的 Markdown 全量重渲染,CPU 会被解析器吃满。
工程上有两个实用优化:
- 渲染节流(Throttle):流式 token 到达后不立即渲染,而是以固定频率(如 30-50ms)批量落盘到 UI,把高频更新降为低频渲染。
- 内容分段渲染:对当前正在流式输出的消息单独处理,只重渲染它的增量部分;已完成的旧消息走缓存,不参与重渲染。
tsx
useEffect(() => {
if (!streamingDelta) return;
const timer = setTimeout(() => {
// 50ms 才把累积的 token 一次性 flush 到界面
flushStreamingContent(streamingDelta);
}, 50);
return () => clearTimeout(timer);
}, [streamingDelta]);
4.3 自动滚动与用户阅读的冲突
「流式输出时自动滚到底部」是 AI 对话的基本体验,但必须处理一个细节:用户往上翻看历史时,不能被自动滚动硬拽回底部。
常见策略是:
- 当用户距底部小于阈值(如 40px)时,视为「跟随模式」,自动滚动到底部;
- 一旦用户主动往上滚动,立即退出跟随模式,并给出「回到底部」的悬浮按钮;
- 用户点击按钮或滚动回底部附近后,重新进入跟随模式。
tsx
function shouldAutoScroll(container: HTMLElement) {
const distanceToBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
return distanceToBottom < 40;
}
4.4 渲染性能与内存控制
万级消息里会沉淀大量 Markdown 内容,即使虚拟滚动只渲染可视部分,仍有两个隐藏问题:
- 数据本身的内存占用:上万条消息的原始文本虽然不算大,但如果每条都缓存了解析后的 AST 或高亮后的 HTML,内存会成倍增长。建议只缓存最近 N 条消息的渲染产物,更早的消息按需解析。
- 图片与媒体懒加载 :历史消息中的图片应使用
loading="lazy",并配合虚拟滚动的销毁逻辑,让离开视口的图片被释放,避免浏览器缓存层堆积过多解码后的位图。
5. 工程落地与性能验证
把这些策略组合成一条完整的技术链路后,我们可以在真实数据上验证收益。以 10000 条消息、平均每条 5 个 Markdown 段落的会话为例:
| 指标 | 全量渲染 | 虚拟滚动后 |
|---|---|---|
| DOM 节点数 | 约 280,000 | 约 2,500 |
| 首屏渲染耗时 | 3200ms+ | 180ms 左右 |
| 流式输出 CPU 占用 | 持续 90%+ | 峰值 35% 左右 |
| 滚动帧率 | 25fps 以下 | 55fps 以上 |
落地时建议按以下顺序推进,每一步都能独立验证、快速回滚:
- 接入虚拟列表,先保证「不崩、不卡」,撑住万级消息量。
- 处理动态高度测量与滚动锚定,保证翻阅历史不跳动。
- 对流式输出做渲染节流,降低打字机效果期间的 CPU 开销。
- 加入自动滚动的交互策略,兼顾「跟随」与「不打扰」。
- 最后做内存与图片懒加载优化,处理长会话的持久化场景。
6. 总结
AI 对话流的性能调优,虚拟滚动只是切入点,真正拉开差距的是它背后的一整套组合拳:动态高度测量、滚动锚定、流式渲染节流、自动滚动交互、内存懒释放。
核心思路可以浓缩成三句话:
- 用「只渲染可视区」把渲染成本从 O(n) 降到 O(1);
- 用「缓存 + 增量」把流式输出从全量重渲染变成定点更新;
- 用「锚点 + 交互策略」让性能优化不牺牲阅读体验。
当消息量从百级走向万级,这些优化不是锦上添花,而是产品能否继续使用的分水岭。