尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

长会话虚拟滚动与渲染优化 逻辑图依赖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: RefHTMLElement | null) { const messageList refChatItem[]([])// 完整消息数组 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 Mapnumber, 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及之后的所有startend偏移并重新汇总 } }) // 贴底逻辑分支 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 classchat-message :data-indexindex refelRef v-htmlrenderHtml/div /template script setup langts import { ref, watch } from vue import marked from marked const props defineProps{ msg: { content: string } index: number }() const elRef refHTMLElement | 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; } /style3. ChatVirtualList.vue 页面入口组件template div classchat-container refcontainerRef scrollhandleContainerScroll div classchat-list :style{ height: ${virtualizer.getTotalSize()}px, position: relative } div v-foritem in virtualizer.getVirtualItems() :keyitem.key :style{ position: absolute, top: ${item.start}px, left: 0, width: 100% } ChatMessage :msgmessageList[item.index] :indexitem.index :ref(dom) dom itemRefs.set(item.index, dom) / /div /div /div button clicksendMessage(测试提问)发送消息/button /template script setup langts import { ref } from vue import { useChatVirtualStream } from ./useChatVirtualStream import ChatMessage from ./ChatMessage.vue const containerRef refHTMLElement | null(null) const { messageList, virtualizer, itemRefs, sendMessage, handleContainerScroll } useChatVirtualStream(containerRef) /script style scoped .chat-container { height: 600px; overflow-y: auto; border: 1px solid #eee; } /style3.整体核心思路分片流式接收后端流式返回二进制 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 更新前执行回调渲染前微任务postDOM 更新挂载完成之后执行回调渲染后微任务sync同步执行数据一变立刻跑回调改成 pre / 不指定带来的 bug监听virtualizer.getVirtualItems()的 watch 如果是 flush:pre 此时虚拟列表新 DOM 还没有真实挂载到页面itemRefs拿不到真实 HTMLElementResizeObserver 无法绑定元素测量不到真实高度最终虚拟列表高度缓存错误消息卡片重叠、空白占位、布局错乱。 ✅ 结论这里必须使用 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组件内自己维护变量缓存上一次完整 contentlet 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}参数作用不加会出现什么 bugconst 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 后该计算会产生偏差需要额外修正。
返回列表