逻辑图

依赖:@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.整体核心思路
- 分片流式接收:后端流式返回二进制 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:
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;然后重新计算:
- 该条目自身
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
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 分别代表什么
scrollHeight:容器内部完整内容总高度(包含看不见的滚动区域)clientHeight:容器可视区域高度,不含滚动条scrollTop:容器顶部已经向上滚动出去的距离
贴底判定公式:
scrollHeight - scrollTop - clientHeight < 20,差值就是内容底部距离可视区底部的距离。 坑:容器设置 padding 后,该计算会产生偏差,需要额外修正。