长会话虚拟滚动与渲染优化

逻辑图

依赖:@tanstack/vue-virtual,版本建议最新,它支持动态可变高度,不需要提前知道每条消息高度。

1. 安装依赖

复制代码
npm install @tanstack/vue-virtual
# or pnpm
pnpm add @tanstack/vue-virtual

2. 完整可运行 Vue3 示例

1. useChatVirtualStream.ts 主逻辑组合(核心流程)

TypeScript 复制代码
import { ref, watch, computed, nextTick, onUnmounted, watchEffect } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import marked from 'marked'

type ChatItem = {
  id: string
  role: 'user' | 'assistant'
  content: string
}

// RAF节流工具:绑定浏览器渲染帧,一帧最多执行一次函数,合并高频调用
function rafThrottle(fn: () => void) {
  let rafId: number | null = null
  return () => {
    if (rafId) return
    rafId = requestAnimationFrame(() => {
      fn()
      rafId = null
    })
  }
}

export function useChatVirtualStream(containerRef: Ref<HTMLElement | null>) {
  const messageList = ref<ChatItem[]>([])// 完整消息数组
  const isStreaming = ref(false)// 是否处于AI流式输出中
  const stickToBottom = ref(true)// 用户是否停留在底部,控制自动贴底

  //初始化虚拟列表virtualizer 实例
  const virtualizer = useVirtualizer({
    count: computed(() => messageList.value.length),
    getScrollElement: () => containerRef.value,
    estimateSize: () => 80, // 预估高度
  })

  // 缓存DOM ref,用于ResizeObserver监听
  const itemRefs = new Map<number, HTMLElement>()
  let resizeObserver: ResizeObserver | null = null
  let lastScrollTop = 0

  // =========== 步骤:监听容器滚动,更新 stickToBottom ===========
  // 对应用户手动滚动容器的分支,更新是否开启自动贴底;RAF 节流避免滚动事件疯狂触发。
  const handleContainerScroll = rafThrottle(() => {
    const el = containerRef.value
    if (!el) return
    const { scrollTop, scrollHeight, clientHeight } = el
    // 距离底部小于20px判定为贴底;用户上翻超过阈值,关闭自动贴底
    stickToBottom.value = scrollHeight - scrollTop - clientHeight < 20
    lastScrollTop = scrollTop
  })

  // =========== ResizeObserver 测量条目高度 handleResize ===========
  const handleResize = rafThrottle((entries: ResizeObserverEntry[]) => {
    // 遍历尺寸发生变化的DOM条目
    entries.forEach(entry => {
      const dom = entry.target as HTMLElement
      const index = Number(dom.dataset.index ?? -1)
      if (index < 0) return
      const realHeight = entry.contentRect.height
      const oldHeight = virtualizer.getItemSize(index)
      const delta = Math.abs(realHeight - oldHeight)
      // 高度差大于1px才更新,忽略浮点误差
      if (delta > 1) {
        virtualizer.resizeItem(index, realHeight)//更新index及之后的所有start,end偏移并重新汇总
      }
    })

    // 贴底逻辑分支
    if (!stickToBottom.value) {
      // 用户上翻历史:不做任何滚动操作
      return
    }

    // stickToBottom = true,执行滚底逻辑
    const scrollBottom = rafThrottle(() => {
      virtualizer.scrollToIndex(messageList.value.length - 1, { align: 'end' })
    })

    if (isStreaming.value) {
      // 流式打字中:RAF节流滚底,持续跟随底部,降低闪屏概率
      scrollBottom()
    } else {
      // 流结束:兜底滚动 + 完整MD重渲染修复残缺语法
      virtualizer.scrollToIndex(messageList.value.length - 1, { align: 'end' })
      // 浅拷贝触发子组件完整重渲染
      messageList.value = [...messageList.value]
    }
  })

  // 重新绑定监听 observeItems
  const observeItems = rafThrottle(async () => {
    await nextTick()// 等待DOM渲染完成
    if (resizeObserver) resizeObserver.disconnect()// 清空旧监听,避免重复绑定
    resizeObserver = new ResizeObserver(handleResize)
    itemRefs.clear()
    // 对当前可视区内所有消息DOM绑定尺寸监听
    const virtualItems = virtualizer.getVirtualItems()
    virtualItems.forEach(item => {
      const dom = itemRefs.get(item.index)
      if (dom) resizeObserver!.observe(dom)
    })
  })

  // 监听virtualItems变化,等待DOM渲染后监听尺寸
  watch(
    () => virtualizer.getVirtualItems(),
    () => observeItems(),
    { flush: 'post' }//保证 DOM 挂载完成再执行监听
  )

  // =========== 流式请求发送消息 ===========
  async function sendMessage(query: string) {
    messageList.value.push({
      id: Date.now().toString(),
      role: 'user',
      content: query
    })
    isStreaming.value = true
    // 新增空assistant消息,后续持续填充content
    messageList.value.push({
      id: (Date.now() + 1).toString(),
      role: 'assistant',
      content: ''
    })
    const lastMsgIndex = messageList.value.length - 1
    const lastMsg = messageList.value[lastMsgIndex]

    try {
      const res = await fetch('/api/stream', {
        method: 'POST',
        body: JSON.stringify({ query })
      })
      if (!res.body) throw new Error('stream not support')
      const reader = res.body.getReader()
      const decoder = new TextDecoder()
      let buffer = ''

      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        const chunk = decoder.decode(value, { stream: true })//开启流式解码
        buffer += chunk
        // 缓冲区阈值/换行符触发写入消息列表,减少响应式更新次数
        if (buffer.length > 30 || /\n/.test(buffer)) {
          lastMsg.content += buffer
          buffer = ''
        }
      }
      // 读取完毕,写入剩余缓存
      lastMsg.content += buffer
    } catch (err) {
      console.error('stream error', err)
    } finally {
      isStreaming.value = false// 标记流式结束
    }
  }

  onUnmounted(() => {
    resizeObserver?.disconnect()// 销毁观察者,防止内存泄漏
  })

  return {
    messageList,
    virtualizer,
    containerRef,
    itemRefs,
    sendMessage,
    handleContainerScroll
  }
}

2. ChatMessage.vue 子组件(增量 MD 防抖渲染,对齐流程图 I 分支)

TypeScript 复制代码
<template>
  <div class="chat-message" :data-index="index" ref="elRef" v-html="renderHtml"></div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import marked from 'marked'

const props = defineProps<{
  msg: { content: string }
  index: number
}>()
const elRef = ref<HTMLElement | null>(null)
const renderHtml = ref('')

let answerBuffer = ''
let renderTimer: number | null = null
let prevContent = ''
watch(
  () => props.msg.content,
  (newVal, oldVal) => {
    // 提取增量diff文本
    //const diffText = newVal.slice(oldVal.length)不用这种方式,会多触发一次无意义的全量解析
    const diffText = newVal.slice(prevContent.length)
    prevContent = newVal
    answerBuffer += diffText

    // 判断是否命中强制渲染标记:换行、标题、段落终止符
    const forceRender = /\n|#{1,6}\s/.test(diffText)
    function doRender() {
      if (renderTimer) clearTimeout(renderTimer)
      renderHtml.value = marked.parse(props.msg.content)
      answerBuffer = ''
    }

    if (forceRender) {
      doRender()
    } else {
      if (renderTimer) clearTimeout(renderTimer)
      renderTimer = window.setTimeout(doRender, 100)
    }
  },
  { flush: 'post' }
)
</script>

<style scoped>
.chat-message {
  padding: 8px 12px;
  white-space: pre-wrap;
}
</style>

3. ChatVirtualList.vue 页面入口组件

javascript 复制代码
<template>
  <div class="chat-container" ref="containerRef" @scroll="handleContainerScroll">
    <div
      class="chat-list"
      :style="{
        height: `${virtualizer.getTotalSize()}px`,
        position: 'relative'
      }"
    >
      <div
        v-for="item in virtualizer.getVirtualItems()"
        :key="item.key"
        :style="{
          position: 'absolute',
          top: `${item.start}px`,
          left: 0,
          width: '100%'
        }"
      >
        <ChatMessage
          :msg="messageList[item.index]"
          :index="item.index"
          :ref="(dom) => dom && itemRefs.set(item.index, dom)"
        />
      </div>
    </div>
  </div>
  <button @click="sendMessage('测试提问')">发送消息</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useChatVirtualStream } from './useChatVirtualStream'
import ChatMessage from './ChatMessage.vue'

const containerRef = ref<HTMLElement | null>(null)
const { messageList, virtualizer, itemRefs, sendMessage, handleContainerScroll } = useChatVirtualStream(containerRef)
</script>

<style scoped>
.chat-container {
  height: 600px;
  overflow-y: auto;
  border: 1px solid #eee;
}
</style>

3.整体核心思路

  1. 分片流式接收:后端流式返回二进制 chunk,前端缓冲区合并,达到阈值 / 换行后更新消息列表,避免每一个 token 触发 UI 重渲染。
  2. 子组件增量防抖 MD 渲染:ChatMessage 只计算本次新增文本,遇到换行 / 标题强制渲染,否则 100ms 防抖执行完整 Markdown 解析,减少高频 DOM 更新。
  3. 虚拟列表 DOM 复用 + 尺寸监听 :useVirtualizer 管理可视区 DOM,渲染完成后通过 ResizeObserver 监听消息卡片撑开;高度变化超过 1px 才调用measureItem更新缓存,规避浮点误差,防止卡片重叠错乱。
  4. 自动贴底分层策略
    • stickToBottom:监听容器滚动,距离底部 < 20px 标记为贴底;用户上翻后永久停止自动滚动,不修改 scrollTop。
    • 流式打字中:使用 RAF 节流执行滚底,合并多次滚动操作,兼顾持续贴底、减少闪屏。
    • 流结束:兜底滚底,浅拷贝消息列表触发子组件全量重渲染,修复流式过程中残缺 Markdown 语法。
  5. 性能兜底:RAF 节流统一管理滚动、尺寸监听、DOM 绑定;组件销毁时清除观察者,防止内存泄漏。

业务约束前提:历史中间消息高度固定,仅最后一条 AI 消息动态变长

4.高频提问

1. watch 中 flush:'post' 如果改成 flush:'pre' / 不指定,会出现什么现象?三种 flush 区别是什么

三种 flush 执行时机
  1. pre(默认):DOM 更新前执行回调(渲染前,微任务)
  2. postDOM 更新挂载完成之后执行回调(渲染后微任务)
  3. sync:同步执行,数据一变立刻跑回调
改成 pre / 不指定带来的 bug

监听 virtualizer.getVirtualItems() 的 watch 如果是 flush:pre: 此时虚拟列表新 DOM 还没有真实挂载到页面,itemRefs 拿不到真实 HTMLElement,ResizeObserver 无法绑定元素,测量不到真实高度;最终虚拟列表高度缓存错误,消息卡片重叠、空白占位、布局错乱。 ✅ 结论:这里必须使用 flush:'post',配合 nextTick 确保真实 DOM 存在后再监听尺寸。

2. ChatMessage 里 newVal.slice(oldVal.length) 依赖 watch 自带 oldVal 有什么坑?如何解决

坑点

watch 自带的 oldVal 保存的是上一次监听触发时的引用快照 。 流结束我们执行 messageList.value = [...messageList.value] 浅拷贝数组 → 消息对象引用整体更新,watch 拿到的 oldVal 不再是上次增量结束的对象,oldVal.length 是旧对象全长,diffText = 完整全文,会重复执行全量 Markdown 解析,浪费性能。就是最后整体再解析一次后会再次触发这个watch,造成一次多余的全量解析。

解决方案

不要依赖 watch 内置 oldVal,组件内自己维护变量缓存上一次完整 content

TypeScript 复制代码
let prevContent = ''
watch(() => props.msg.content, (newVal) => {
  const diffText = newVal.slice(prevContent.length)
  prevContent = newVal
})

3.ResizeObserver 为什么容易报 loop limit exceeded 死循环警告?你的代码怎么阻断循环

原因

ResizeObserver 回调执行时修改元素布局高度(resizeItem更新虚拟条目尺寸)→ 再次触发元素尺寸变化 → 再次进入 ResizeObserver 回调,形成循环,浏览器抛出超限警告。

代码阻断方案

增加判断:新旧高度差值 delta > 1px 才执行 resizeItem 更新缓存,忽略浏览器浮点微小尺寸波动,打断循环链路。

补充:ResizeObserver 回调是异步微任务,区别于 scroll 同步高频事件。

API讲解

resizeItem(index, measuredSize)(核心底层 API)

作用 :手动指定某一条 index 索引 的列表项真实尺寸,覆盖原有缓存里的 size;然后重新计算:

  1. 该条目自身 size / start / end
  2. 这条 index 之后所有条目 的 start、end 偏移(因为前面高度变了,后面整体往下挪)
  3. 重新汇总计算 **totalSize(整个列表虚拟总高度,用来撑起外层占位 div、控制滚动条长度)**稀土掘金

内部维护一个 measurements 数组缓存: measurements[index] = { size, start, end } 初始化时全部填充 estimateSize 的预估高度;测量后替换成真实高度稀土掘金

2. virtualizer.measureElement(el)(对外常用 ref 回调)

绑定到 DOM ref,库内部自动读取元素高度,再调用 resizeItem 更新缓存,是封装好的自动化方案。

3. virtualizer.measure()

清空全部测量缓存,全部条目重新走一遍测量,开销很大,一般不用在 ResizeObserver 回调里。

补充

为什么是比较是否小于1,为什么不能直接判断 oldHeight !== realHeight? 浏览器布局计算会产生浮点数精度误差,例如 120120.00000002,两者视觉完全一样,但全等判断不通过,依旧会触发循环,所以需要阈值判断,而不是严格全等。

4. ResizeObserver 对比 scroll + setTimeout 手动测量元素高度,优势是什么

  1. ResizeObserver 原生专门监听元素内容盒尺寸变化,不是监听滚动,元素宽高改变才触发,回调次数更少,性能更好
  2. 异步回调,不阻塞主线程,不会造成卡顿
  3. 可以监听非滚动带来的尺寸变化:文字撑开、图片加载完成、Markdown 渲染撑开
  4. scroll 事件只能感知容器滚动,无法精准捕获单个子元素尺寸变更

5. TextDecoder 解码时 {stream:true} 参数作用,不加会出现什么 bug

TypeScript 复制代码
      const decoder = new TextDecoder()
      let buffer = ''

      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        const chunk = decoder.decode(value, { stream: true })//开启流式解码
        buffer += chunk
        // 缓冲区阈值/换行符触发写入消息列表,减少响应式更新次数
        if (buffer.length > 30 || /\n/.test(buffer)) {
          lastMsg.content += buffer
          buffer = ''
        }
      }

stream: true:开启流式解码,保留多字节字符的残留字节,等待下一个 chunk 拼接完成再解析。 ❌ 不加 stream:true:每一段 chunk 都会独立完整解码,如果中文、emoji、特殊 unicode 字符被切割在两个二进制块中,会出现文字乱码、表情截断

6. scrollTop /scrollHeight/clientHeight 分别代表什么

  1. scrollHeight:容器内部完整内容总高度(包含看不见的滚动区域)
  2. clientHeight:容器可视区域高度,不含滚动条
  3. scrollTop:容器顶部已经向上滚动出去的距离

贴底判定公式:scrollHeight - scrollTop - clientHeight < 20,差值就是内容底部距离可视区底部的距离。 坑:容器设置 padding 后,该计算会产生偏差,需要额外修正。

相关推荐
谙忆10241 小时前
图片裁剪总是跑偏?用 object-fit、object-position 和真实像素把坐标对齐
javascript·css·图像识别
花归去2 小时前
ant的a-table更改表格的高度
前端·javascript·html
sibylyue2 小时前
基于 Vben Admin 框架的 Vue 3 前端项目配置文件及常用命令
前端·javascript·vue.js
知几蜗牛2 小时前
0 后端 · 0 数据库 · 0 备案:用 AI 两天搓出的股票管理系统,开源了
前端·后端·llm
雪芽蓝域zzs2 小时前
Element‑Plus icon 图标名称查询 & 和菜单 meta.icon 字段映射
前端
01_ice3 小时前
前端学习css
前端·css·学习
愚公搬代码3 小时前
【愚公系列】《Web应用安全》010-Repeater模块的使用
前端·安全
Cache技术分享4 小时前
503. Java 反射 - 编写 ServiceFactory 类
前端·后端
赵大仁4 小时前
AI 限流 UX 设计:排队、降级、告知与用户预期管理
前端·ai·限流·用户体验·产品设计