流式输出布局优化技术总结
1. Markdown 流式渲染时高度突变引发的布局偏移
什么是高度突变?
流式输出时,AI 一个字一个字地返回内容,每次新内容到达都需要重新渲染 Markdown。高度突变发生在内容类型发生变化的时刻。
具体场景举例
假设 AI 正在流式输出这段 Markdown:
让我给你展示一个图表:
``chart
{
"type": "bar",
"data": [1, 2, 3]流式传输过程中,内容可能是这样的:
| 时刻 | 内容 | 渲染结果 | 高度 |
|---|---|---|---|
| 1 | 让我给你 | 普通文本 | 低 |
| 2 | 让我给你展示 | 普通文本 | 低 |
| 3 | 让我给你展示一个 | 普通文本 | 低 |
| 4 | 让我给你展示一个图表: | 普通文本 | 低 |
| 5 | 让我给你展示一个图表:\n | 普通文本(换行) | 稍高 |
| 6 | 让我给你展示一个图表:\n\ | 普通文本 | 突变:代码块开始 |
| 7 | 让我给你展示一个图表:\n\` | 普通文本 | 继续渲染代码块语言名 |
| 8 | 让我给你展示一个图表:\n\``chart` | 普通文本 | 继续 |
| 9 | ... | 媒体块(图表) | 再次突变:图表组件渲染 |
高度突变的根本原因
Markdown 解析依赖完整语法
react-markdown需要完整内容才能正确解析。比如**粗体**只收到**粗体时不知道是否要渲染粗体。不同内容类型高度差异大
- 普通文本:几十像素
- 代码块:几百像素
- 图表/图片:几百像素
- 表格:几百像素
内容类型切换时高度跳变
从"普通文本" → "代码块" → "图表渲染",每次切换高度都可能翻倍增长。
2. 项目中的解决方案
2.1 未闭合 Markdown 块自动补全
文件: features/chat/components/MessageContent/index.tsx
函数: preprocessStreamingContent
function preprocessStreamingContent(content: string, isStreaming: boolean): string {
if (!isStreaming || !content) return content
// 统计代码块的开始和结束
const codeBlockPattern = /``/g
const matches = content.match(codeBlockPattern)
const count = matches?.length || 0
// 如果没有代码块或代码块数量是偶数(都闭合了),直接返回
if (count === 0 || count % 2 === 0) {
return content
}
// 有未闭合的代码块
const lastOpenBlock = content.lastIndexOf('``')
const afterBlock = content.slice(lastOpenBlock + 3)
const langMatch = afterBlock.match(/^(\w+)/)
const lang = langMatch?.[1]
// 如果是媒体块,需要特殊处理
if (lang && ['image', 'chart', 'weather'].includes(lang)) {
const blockContent = afterBlock.slice(lang.length).trim()
// 检查是否有完整的 JSON
const jsonStart = blockContent.indexOf('{')
const jsonEnd = blockContent.lastIndexOf('}')
// JSON 不完整,隐藏整个代码块
if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) {
return content.slice(0, lastOpenBlock)
}
// 尝试解析 JSON
const jsonStr = blockContent.slice(jsonStart, jsonEnd + 1)
try {
JSON.parse(jsonStr)
// JSON 解析成功,补上闭合标记让 ReactMarkdown 正常渲染
return content + '\n``'
} catch {
// JSON 解析失败,隐藏整个代码块
return content.slice(0, lastOpenBlock)
}
}
// 普通代码块,补上闭合标记
return content + '\n``'
}策略说明:
| 类型 | 不完整时的处理 | 原因 |
|---|---|---|
| 普通代码块 | 补全闭合标记,继续渲染 | 不会崩溃,只是显示不完美 |
| 媒体块 (chart/weather/image) | JSON 不完整时隐藏整个代码块 | JSON 不完整会导致组件渲染报错/崩溃 |
2.2 骨架高度(虚拟滚动预估)
文件: features/chat/components/MessageList/index.tsx
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => {
const msg = messages[index]
if (!msg) return 100
if (msg.thinking) return 250
if (msg.content.includes('``')) return 300
if (msg.role === 'user') return 80
return 150
},
overscan: 3,
})2.3 滚动锚定
文件: features/chat/components/MessageList/index.tsx
<div
className="flex-1 overflow-y-auto custom-scrollbar-auto"
style={{ overflowAnchor: 'auto' }}
>overflowAnchor: 'auto' 是 CSS 原生属性,启用滚动锚定。当内容动态增加时,浏览器自动调整滚动位置,让用户正在看的区域保持不动。
3. 浏览器渲染时序问题 - AI 回答与滚动底部的竞态
问题场景
流式输出时:
第一次 scrollToBottom()
↓
container.scrollTop = 1000(当前高度)
↓
浏览器尝试滚动...
↓
AI 输出新内容,内容变高了(比如 1500px)
↓
浏览器重新计算 layout,scrollHeight 变了!
↓
但滚动还没完成,用户看到的位置可能不对解决方案
const scrollToBottom = useCallback(() => {
const container = scrollContainerRef.current
if (!container) return
container.scrollTop = container.scrollHeight
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight
})
}, [])时序分析
scrollTop = xxx→ 浏览器把滚动加入"待渲染队列"- 第一次渲染完成 → 内容可能已经变化(比如流式输出了新内容)
requestAnimationFrame→ 等待下一帧(浏览器下次重绘前)- 此时再设置一次
scrollTop→ 确保用最新的 scrollHeight 滚动到底部
本质原因
流式输出时,内容和滚动同时发生:
- 第一次 scroll 时,内容高度还是旧的
- 内容增高后,scrollHeight 变大
- 必须等内容渲染完,再滚动一次才能真正到底部
类似场景
很多 UI 库都有类似逻辑:
- 聊天列表滚动到底部
- 无限滚动加载更多后回到原位置
- 动态内容高度变化后的定位
4. 滚动状态管理
检测用户主动滚动
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setUserScrolledUp(distanceFromBottom > 100)
}当用户向上滚动超过 100px 时,认为用户主动上滑,此时不再自动滚动到底部。
// 流式更新时滚动
useEffect(() => {
if (!streamingMessageId || userScrolledUp) return
scrollToBottom()
}, [streamingContentLength, streamingMessageId, userScrolledUp, scrollToBottom])5. 相关文件索引
| 功能 | 文件路径 |
|---|---|
| 未闭合 Markdown 补全 | features/chat/components/MessageContent/index.tsx |
| Markdown 组件映射 | features/chat/components/MessageContent/MarkdownComponents.tsx |
| 媒体块注册表 | features/chat/components/MessageContent/blocks/registry.ts |
| 虚拟滚动列表 | features/chat/components/MessageList/index.tsx |
| 消息实体 | features/chat/types/chat.ts |
| 游标分页 API | app/api/conversations/[id]/messages/route.ts |
| 消息仓库 | server/repositories/message.repository.ts |