news 2026/8/23 7:57:37

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
长会话虚拟滚动与渲染优化

逻辑图

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

1. 安装依赖

npm install @tanstack/vue-virtual # or pnpm pnpm add @tanstack/vue-virtual

2. 完整可运行 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.整体核心思路

  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

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

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 后,该计算会产生偏差,需要额外修正。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/23 7:57:05

数学建模创新思维:从解题到造题的实战路径

1. 项目概述&#xff1a;从“解题”到“造题”的思维跃迁 干了这么多年数学建模&#xff0c;带过不少学生&#xff0c;也评过不少竞赛论文&#xff0c;我发现一个普遍现象&#xff1a;很多队伍在技术实现上已经相当熟练&#xff0c;模型、算法、代码信手拈来&#xff0c;但最终…

作者头像 李华
网站建设 2026/8/23 7:56:48

潜在多智能体通信中隐蔽协同的检测:从行为序列分析到工程实践

在分布式系统、多智能体协作和复杂网络交互的工程实践中&#xff0c;一个长期存在的挑战是&#xff1a;如何识别那些不通过显式、预定义协议进行通信&#xff0c;而是通过隐式、潜在信号进行协调的智能体行为。这类“潜在多智能体协同”现象广泛存在于自动化交易系统、游戏AI、…

作者头像 李华
网站建设 2026/8/23 7:55:39

Revit建筑设计思维与实战:从BIM核心到施工图全流程

在BIM项目实践中&#xff0c;Revit作为核心建模工具&#xff0c;其高效应用远不止于软件操作本身&#xff0c;更在于一套系统化的设计思维与工作流程。很多初学者在掌握了基础命令后&#xff0c;依然在项目协同、参数化设计及出图效率上遇到瓶颈。本文将围绕一套实战导向的Revi…

作者头像 李华
网站建设 2026/8/23 7:54:09

XGBoost优化机制与并行化实现深度解析:从理论到工程实践

1. 项目概述&#xff1a;从“黑箱”到“白盒”&#xff0c;拆解XGBoost的工程艺术 如果你在数据科学或机器学习领域摸爬滚打过一阵子&#xff0c;那么“XGBoost”这个名字对你来说&#xff0c;可能熟悉得像一个老朋友。在各种数据竞赛的冠军方案里&#xff0c;在工业界风控、推…

作者头像 李华
网站建设 2026/8/23 7:53:29

具身智能技术栈核心:大脑、小脑与桥接层的实时调度实践

如果你最近关注科技展会&#xff0c;可能会有一个强烈的感受&#xff1a;今年几乎所有大型展会&#xff0c;从CES到世界人工智能大会&#xff0c;再到各种行业峰会&#xff0c;“具身智能”都成了最热门的展区。展台上人形机器人、机械臂、四足机器人琳琅满目&#xff0c;动作流…

作者头像 李华
网站建设 2026/8/23 7:52:33

2025求职必备:AI简历优化与面试辅助工具全解析

1. 项目背景与需求分析2025届毕业生即将面临一个全新的就业环境——AI技术正在重塑各行各业的工作方式。根据最新行业调研数据显示&#xff0c;超过67%的企业HR部门已经开始使用AI工具进行简历筛选&#xff0c;而近40%的岗位JD中都出现了"AI协作能力"的要求。这种趋势…

作者头像 李华