流式输出布局优化技术总结

25 天前(已编辑)
3

流式输出布局优化技术总结

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...媒体块(图表)再次突变:图表组件渲染

高度突变的根本原因

  1. Markdown 解析依赖完整语法

    react-markdown 需要完整内容才能正确解析。比如 **粗体** 只收到 **粗体 时不知道是否要渲染粗体。

  2. 不同内容类型高度差异大

    • 普通文本:几十像素
    • 代码块:几百像素
    • 图表/图片:几百像素
    • 表格:几百像素
  3. 内容类型切换时高度跳变

    从"普通文本" → "代码块" → "图表渲染",每次切换高度都可能翻倍增长。

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
  })
}, [])

时序分析

  1. scrollTop = xxx → 浏览器把滚动加入"待渲染队列"
  2. 第一次渲染完成 → 内容可能已经变化(比如流式输出了新内容)
  3. requestAnimationFrame → 等待下一帧(浏览器下次重绘前)
  4. 此时再设置一次 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
游标分页 APIapp/api/conversations/[id]/messages/route.ts
消息仓库server/repositories/message.repository.ts

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...