逻辑图
依赖:@tanstack/vue-virtual,版本建议最新,它支持动态可变高度,不需要提前知道每条消息高度。
1. 安装依赖
npm install @tanstack/vue-virtual # or pnpm pnpm add @tanstack/vue-virtual2. 完整可运行 Vue3 示例
1. useChatVirtualStream.ts 主逻辑组合(核心流程)
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 分支)
<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 页面入口组件
<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.整体核心思路
- 分片流式接收:后端流式返回二进制 chunk,前端缓冲区合并,达到阈值 / 换行后更新消息列表,避免每一个 token 触发 UI 重渲染。
- 子组件增量防抖 MD 渲染:ChatMessage 只计算本次新增文本,遇到换行 / 标题强制渲染,否则 100ms 防抖执行完整 Markdown 解析,减少高频 DOM 更新。
- 虚拟列表 DOM 复用 + 尺寸监听:useVirtualizer 管理可视区 DOM,渲染完成后通过 ResizeObserver 监听消息卡片撑开;高度变化超过 1px 才调用
measureItem更新缓存,规避浮点误差,防止卡片重叠错乱。 - 自动贴底分层策略
stickToBottom:监听容器滚动,距离底部 < 20px 标记为贴底;用户上翻后永久停止自动滚动,不修改 scrollTop。- 流式打字中:使用 RAF 节流执行滚底,合并多次滚动操作,兼顾持续贴底、减少闪屏。
- 流结束:兜底滚底,浅拷贝消息列表触发子组件全量重渲染,修复流式过程中残缺 Markdown 语法。
- 性能兜底:RAF 节流统一管理滚动、尺寸监听、DOM 绑定;组件销毁时清除观察者,防止内存泄漏。
业务约束前提:历史中间消息高度固定,仅最后一条 AI 消息动态变长
4.高频提问
1. watch 中flush:'post'如果改成flush:'pre'/ 不指定,会出现什么现象?三种 flush 区别是什么
三种 flush 执行时机
pre(默认):DOM 更新前执行回调(渲染前,微任务)post:DOM 更新挂载完成之后执行回调(渲染后微任务)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:
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;然后重新计算:
- 该条目自身
size / start / end - 这条 index 之后所有条目的 start、end 偏移(因为前面高度变了,后面整体往下挪)
- 重新汇总计算
totalSize(整个列表虚拟总高度,用来撑起外层占位 div、控制滚动条长度)稀土掘金
内部维护一个
measurements数组缓存:measurements[index] = { size, start, end }初始化时全部填充estimateSize的预估高度;测量后替换成真实高度稀土掘金
2.virtualizer.measureElement(el)(对外常用 ref 回调)
绑定到 DOM ref,库内部自动读取元素高度,再调用 resizeItem 更新缓存,是封装好的自动化方案。
3.virtualizer.measure()
清空全部测量缓存,全部条目重新走一遍测量,开销很大,一般不用在 ResizeObserver 回调里。
补充
为什么是比较是否小于1,为什么不能直接判断
oldHeight !== realHeight? 浏览器布局计算会产生浮点数精度误差,例如120和120.00000002,两者视觉完全一样,但全等判断不通过,依旧会触发循环,所以需要阈值判断,而不是严格全等。
4. ResizeObserver 对比 scroll + setTimeout 手动测量元素高度,优势是什么
- ResizeObserver 原生专门监听元素内容盒尺寸变化,不是监听滚动,元素宽高改变才触发,回调次数更少,性能更好
- 异步回调,不阻塞主线程,不会造成卡顿
- 可以监听非滚动带来的尺寸变化:文字撑开、图片加载完成、Markdown 渲染撑开
- scroll 事件只能感知容器滚动,无法精准捕获单个子元素尺寸变更
5. TextDecoder 解码时{stream:true}参数作用,不加会出现什么 bug
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 分别代表什么
scrollHeight:容器内部完整内容总高度(包含看不见的滚动区域)clientHeight:容器可视区域高度,不含滚动条scrollTop:容器顶部已经向上滚动出去的距离
贴底判定公式:
scrollHeight - scrollTop - clientHeight < 20,差值就是内容底部距离可视区底部的距离。 坑:容器设置 padding 后,该计算会产生偏差,需要额外修正。