恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
像素vs条数级虚拟列表
首页
资讯中心
/
像素vs条数级虚拟列表
像素vs条数级虚拟列表
发布时间:2026/8/7 11:43:26
目录面试像素定/估高 缓冲区DOM数学公式缓冲区完整代码动态高度估高 高度缓存二分查找避坑指南找起始索引 start改用“二分查找”关于 endIdx 的查找用二分找还是用预估算纠正位置updated 生命周期更新真实高度完整代码开发条数动态高度双向时间轴强实时更新相关滚动锚定数据缓存流程1. 进会话初始模式2. 日常收发消息与视口状态判断3. 向上滑动加载历史Prepend与顶部高度补偿3.5 向下滑动裁尾回收与窗口复位4. 跳转定位预加载目标前后10条搜索 / 首条未读5. 切换会话 / 离开目标都一样少画 DOM。差别在「少」怎么算。像素级虚拟列表条数级虚拟列表总高度用估算/缓存高度撑出来就是真实 DOM 总高滚动条可对应「全部消息」的假高度只对应「当前窗口」真实高度不定高必须测高/估高直接渲染省事代价实现难、和气泡耦合重DOM 仍可能几十~一两百不是极致常说的/面试像素级虚拟列表 按 scrollTop viewport 算 index 区间 spacer/transform。实际开发条数级虚拟列表 按条数slice不维护总虚拟高度。面试像素定/估高 缓冲区「前端进阶」高性能渲染十万条数据(虚拟列表)DOM外层容器Container固定高度如 400pxoverflow-y: auto。被滚动的幽灵背景Phantom无内容纯粹靠height totalHeight把滚动条撑开z-index: -1。渲染区域Content绝对定位或transform偏移里面只渲染visibleData。数学公式缓冲区边说边写出来totalHeight: listData.length * itemSize // 撑开滚动条的高度// 可视区域高度固定称之为screenHeight const visibleCount Math.ceil(screenHeight / itemSize); const rawStart Math.floor(scrollTop / itemHeight); const start Math.max(0, rawStart - buffer); const end Math.min(listData.length , start visibleCount buffer * 2); const visibleData listData.slice(start,end); const offset start * itemHeight;完整代码!-- 1. 外层容器固定高度负责滚动 -- div classcontainer refcontainer scrollonScroll styleheight: 400px; overflow-y: auto; !-- 2. 幽灵背景纯粹靠高度撑开真实滚动条 -- div classphantom :style{ height: totalHeight px }/div !-- 3. 渲染区域绝对定位/Transform 偏移拉回视口,通过引入 Z 轴参数触发合成层Compositing Layer在部分旧机型或复杂场景下能减少重排Reflow提升滚动流畅度 。 -- div classcontent :style{ transform: translate3d(0, ${startOffset}px, 0) } div v-foritem in visibleData :keyitem.id classitem {{ item.value }} /div /div /div export default { data() { return { listData: [], // 10万条原始数据 itemSize: 50, // 每项固定高 50px screenHeight: 400, // 视口高度 bufferCount: 5, // 上下缓冲区条数 start: 0, end: 0, startOffset: 0 }; }, computed: { // 1. 总高度撑开滚动条 totalHeight() { return this.listData.length * this.itemSize; }, // 2. 视口能容纳的条数 visibleCount() { return Math.ceil(this.screenHeight / this.itemSize); }, // 3. 真实渲染的数据切片 visibleData() { return this.listData.slice(this.start, this.end); } }, methods: { // 4. 滚动核心逻辑 onScroll() { const scrollTop this.$refs.container.scrollTop; // 算视口内的绝对起点 const rawStart Math.floor(scrollTop / this.itemSize); // 加缓冲区算实际 start 和 end this.start Math.max(0, rawStart - this.bufferCount); this.end Math.min(this.listData.length, rawStart this.visibleCount this.bufferCount * 2); // 关键把渲染框拉回当前可视位置跟着 start 走 this.startOffset this.start * this.itemSize; } } }; //以下可忽略 .infinite-list-phantom { position: absolute; /* 绝对定位脱离文档流 */ left: 0; top: 0; right: 0; z-index: -1; /* 1. 关键把幽灵背景送到最底层 */ } .infinite-list { position: absolute; left: 0; top: 0; right: 0; /* 2. 内容层默认 z-index 比 -1 高且里面没有背景色或者背景透明绝对不会被遮挡 */ }infinite-list-container为可视区域的容器infinite-list-phantom为容器内的占位高度为总列表高度用于形成滚动条infinite-list为列表项的渲染区域动态高度估高 高度缓存二分查找[ 滚动事件触发 ]│▼1. 拿到 scrollTop│▼2. 通过【二分查找】在 positions 中找出 start 索引│▼3. 加上缓冲区计算 end 索引更新 visibleData 驱动视图渲染│▼4. startOffset positions[start].top (把渲染框拉到对应位置)│▼5. 【DOM 渲染完成 (updated)】│▼6. 测量 DOM 真实高度 - 修正 positions 映射表 - 累加更新后续所有节点的 top/bottom测量Measured当 DOM 实际渲染出来后通过getBoundingClientRect()拿到它的真实高度更新缓存。维护位置映射表Positions在内存中维护一个数组记录每一个 Item 的top、bottom和height。// 假设列表有 3 条数据预估高度 100px this.positions [ { index: 0, height: 100, top: 0, bottom: 100 }, { index: 1, height: 100, top: 100, bottom: 200 }, { index: 2, height: 100, top: 200, bottom: 300 }, ]列表总高度listHeight就是映射表中最后一个元素的bottom值this.positions[last].bottom。偏移量startOffset直接等于start这一项的top值即this.positions[start] ? this.positions[start].top : 0。避坑指南锚点丢失抖动问题现象当向上滚动时上面原本预估高度为 100px 的 Item 被渲染出来后真实高度变成了 300px。这会导致当前视口内的内容瞬间被“顶”下去造成极其严重的画面抖动。解决如果向上滚动时修正了上面的元素高度需要同步调整scrollTop把差值补回到滚动条上即scrollTop 真实高度 - 预估高度保持视口内容不动。updated里的性能隐患每次更新都用for循环修补后续所有positions项如果数据有 10 万条耗时会增加。优化使用树状数组Binary Indexed Tree或线段树或者只做“懒修正”用到哪一项才计算那项的top可以把修正时间复杂度从 $O(N)$ 降到 $O(\log N)$。终极懒人方案ResizeObserver如果 Item 内部的图片加载、展开折叠导致高度动态改变需要用ResizeObserver监听 DOM 尺寸变化随时触发高度更新逻辑。找起始索引start改用“二分查找”因为每项高度不同positions数组里的top和bottom是单调递增的。当滚动条滚动到scrollTop时我们需要在positions数组中找到第一个bottom scrollTop的元素索引。使用二分查找时间复杂度只有 $O(\log N)$即使上万条数据也是微秒级响应// 二分查找找到第一个 bottom 大于 scrollTop 的项 binarySearch(scrollTop) { let start 0; let end this.positions.length - 1; let tempIndex null; while (start end) { let midIndex Math.floor((start end) / 2); let midValue this.positions[midIndex].bottom; if (midValue scrollTop) { return midIndex 1; } else if (midValue scrollTop) { tempIndex midIndex; end midIndex - 1; // 往左边继续找更靠前的 } else { start midIndex 1; // 往右边找 } } return tempIndex; }关于endIdx的查找用二分找还是用预估算你的疑问“endIdx不应该是找到第一个 bottom 大于scrollTop screenHeight的吗”解答理论上是的最完美的逻辑确实是用二分查找去精准定位scrollTop screenHeight所在的endIdx。但是在尚未滚到那些节点时那些节点的真实 DOM根本还没渲染positions里存的仅仅是“预估高度”。用预估高度去二分查scrollTop screenHeight和你直接用startIdx Math.ceil(screenHeight / itemHeight)算出来的结果一模一样所以大部分实现为了省去一次二分查找的开销直接用startIdx 预估可视数量。纠正位置updated生命周期更新真实高度在v-for(item, index) in visibleList中这里的局部index是切片后数组的索引比如 0, 1, 2...。但我们在positions数组里记录的是全局源数据列表的索引所以模板必须写:data-indexstartIdx index即当前切片起点startIdx 局部偏移index。 这样渲染出来的 DOM 上带的就是全局唯一索引比如div>updated() { this.$nextTick(() { if (!this.$refs.items || !this.$refs.items.length) return; // 1. 获取已渲染 DOM 节点的真实高度 this.$refs.items.forEach((node) { let rect node.getBoundingClientRect(); let realHeight rect.height; let index node.dataset.index; // HTML 属性 style="background-color:transparent">完整代码在虚拟列表测量列表项高度的场景下唯一推荐的首选是item.getBoundingClientRect().height。绝对不要用clientHeight测列表项而offsetHeight只能作为取整备选。属性精度小数包含 Border虚拟列表测项高选用原因getBoundingClientRect().height✅ 支持 (如 50.45px)✅ 包含⭐⭐⭐⭐⭐ 首选亚像素级精准累加top/bottom不会产生累计偏差或滚动微抖offsetHeight❌ 强制四舍五入取整✅ 包含⭐⭐⭐ 备选仅支持整数若 CSS 渲染出小数如 0.5px 边框滚动多了会有 1-2px 错位clientHeight❌ 强制四舍五入取整❌ 不包含❌ 禁用会直接导致布局卡顿崩塌一旦项有border少算高度会导致startOffset越来越偏测【列表项 (Item)】无脑选item.getBoundingClientRect().height带边框、带小数、最准确。测【外层容器 (Container)】用container.clientHeight计算可视视口区域自动剔除容器自身的边框和滚动条。template div classcontainer refcontainerRef scrollonScrollTop :style{ height: screenHeight px, overflowY: auto } div classc_phantom :style{ height: totalHeight px }/div div classc_visible-list :style{ transform: translate3d(0, ${startOffset}px, 0) } div v-for(item, idx) in visibleList :keyitem.id refitems :data-indexstart idx {{ item.val }} /div /div /div /template script export default { props: { list: { type: Array, default: () [] }, itemHeight: { type: Number, default: 50 }, screenHeight: { type: Number, default: 500 }, bufferCnt: { type: Number, default: 5 } }, data() { return { positions: [], scrollTop: 0 }; }, computed: { binaryStart() { let i 0, j this.positions.length - 1; while (i j) { let mid Math.floor((i j) / 2); if (this.positions[mid].bottom this.scrollTop) { i mid 1; } else { j mid - 1; } } return i; }, start() { return Math.max(0, this.binaryStart - this.bufferCnt); }, end() { return Math.min(this.list.length, this.start Math.ceil(this.screenHeight / this.itemHeight) 2 * this.bufferCnt); }, startOffset() { return this.positions[this.start] ? this.positions[this.start].top : 0; }, totalHeight() { return this.positions.length ? this.positions[this.positions.length - 1].bottom : 0; }, visibleList() { return this.list.slice(this.start, this.end); } }, methods: { onScrollTop() { this.scrollTop this.$refs.containerRef.scrollTop; }, initPositions() { this.positions this.list.map((item, idx) ({ height: this.itemHeight, top: idx * this.itemHeight, bottom: (idx 1) * this.itemHeight })); } }, created() { this.initPositions(); }, updated() { const items this.$refs.items || []; items.forEach((item) { let idx Number(item.dataset.index); const realHeight item.getBoundingClientRect().height。 const del realHeight - this.positions[idx].height; if (del ! 0) { this.positions[idx].height realHeight; this.positions[idx].bottom del; while (idx this.positions.length) { this.positions[idx].top del; this.positions[idx].bottom del; k; } } }); } }; /script开发条数动态高度双向时间轴强实时更新- 首屏只拉最近 N 条initialListNum - 上翻距离快到顶再拉 N 条达到DOM 数量上限裁远端- 下翻超过一定距离首尾同时减- 跳转 ele.scrollIntoView- key msg_id发送中用 temp_msg_id成功后替换msg_idVue 在列表增删、窗口滑动时正确复用/更新对应 DOM替换「不闪」多半是同位置替换 内容已渲染 只去掉 loading 的体感展示区间: slice( length - step , length - cutstep )- step顶部裁掉的- cutstep底部裁掉的- initialListNum 20每次加减的步长- cutbase 4窗口大约超过 20 * 4 条量级才开始裁尾相关滚动锚定浏览器原生overflow-anchor声明式 CSS 属性交给浏览器 Layout 引擎自动纠偏默认值 auto 自动纠偏。DOM 精准定位scrollIntoView基于指定 DOM 坐标的 “相机对焦” 机制APIelement.scrollIntoView ()。高度补偿 元素插入前总高 H 旧插入后总高 H 新算出高度变化量△scrollTop△校准滚动位置。开发中overflow-anchornone顶部有数据增删且 JS 手动改scrollTop时如 当前IM虚拟列表。element.scrollIntoView跳转高度补偿虚拟列表顶部插入变高要加上 △顶部/尾部裁剪变矮要减去△核心都是维持“人眼看到的 DOM 相对视口位置不变”。数据缓存messageListMap(Vuex Store)主缓存。按conversationId隔离。写入首屏/进会话 (SET)、上翻 (UNSHIFT)、收发新消息 (PUSH)。清理非当前会话超 20 条即裁剪非活跃会话整条删除。history_message_list(组件 Component Data)临时历史模式缓存。写入搜索跳转/跳未读定位时拉取同步写localStorage(set_history_message_list) 供预览。清理换会话或退出历史模式时立即清空为[]。飞书/微信/QQ 客户端多是SQLite 等原生库消息先落本地再同步打开会话常能 先出本地再补网流程1. 进会话初始模式数据准备从 Vuex 的messageListMap[id]拉取最近消息若无则请求 API触发SET_MESSAGE_LIST。双游标初始化$step \approx \min(20, \text{list.length})$$cutstep 0$只渲染最近的约 20 条 DOMkey msg_id发送中的暂用temp_msg_id成功后替换。位置对齐直接置底或对准最新消息。2. 日常收发消息与视口状态判断当底部收到新消息PUSH_MESSAGE_LIST时视口逻辑分支如下底部来了新消息 │ ┌─────────────┴─────────────┐ ▼ ▼ [ 处于贴底状态 (isBackBottom) ] [ 处于向上看历史状态 ] │ │ ├─ 直接滚到底部 / 保持贴底 ├─ 不动 scrollTop防打扰 └─ 不需要高度差补偿 └─ 弹出「haveNewMsg 有新消息」气泡3. 向上滑动加载历史Prepend与顶部高度补偿当用户向上滚动快触顶时触发条件触发加载loading true。扩窗与拉取若 Vuex 内存有更早消息$step 20$。若内存不足发请求拉取更早历史写入 Store再扩充 $step$。高度差补偿关键新 DOM 在顶部长出导致 $scrollHeight$ 变大。补偿公式$scrollTop \Delta scrollHeight 970$注970 是为了防止用户连续快速手势直接撞顶触发连刷的“预留缓冲距离”并非消息真实高度。触发下边缘裁剪若过长当 $step - cutstep \ge 80$即渲染 DOM 超过 80 条时触发 $cutstep 20$。效果卸载靠近最新侧底部的 20 条 DOM人在上面看历史时底下那截先不画。3.5 向下滑动裁尾回收与窗口复位当用户看完历史向下滑回最新时触发条件向下滑动触底附近且 $cutstep 0$。缩小窗口$step$ 与 $cutstep$ 同步减少约 20窗口整体向最新消息方向滑移把底部 DOM 重新放回。高度差补偿尾部 DOM 放回导致整体高度变矮或顶部旧 DOM 卸载导致变矮。补偿公式$scrollTop scrollTop_{old} - (scrollHeight_{old} - scrollHeight_{new})$归位处理当 $cutstep$ 减为 0 且滑动回最底部时清理未读 Point、跟底并重置haveNewMsg状态。4. 跳转定位预加载目标前后10条搜索 / 首条未读搜索跳转悬停/选中某条、或点击后先出 loading 且并行拉你们跳转时已经在拉。跳转场景不走日常的“双游标滑动”逻辑而是切换为历史模式 (history true)用户点击跳转 / 看未读 │ ▼ 1. 拉取该消息上下文 (get_message_history) │ ▼ 2. 存入 history_message_list全量渲染该上下文片段不走 step/cutstep 切片 │ ▼ 3. Vue 完成真实 DOM 挂载 │ ▼ 4. 通过 getElementById找到对应 msg_id 的 DOM │ ▼ 5. 执行 dom.scrollIntoView({ block: center }) 精准对准5. 切换会话 / 离开Vuex 清理非活跃会话清理多余messageListMap仅保留活跃会话约 30~40 条内存数据。组件清理清空history_message_list []重置 $step, cutstep, history$ 状态位避免影响下一个会话的游标计算。