当前位置: 首页 > news >正文

像素vs条数级虚拟列表

目录

面试(像素):定/估高 + 缓冲区

DOM

数学公式(缓冲区)

完整代码

动态高度:估高 + 高度缓存+二分查找

避坑指南

找起始索引 start:改用“二分查找”

关于 endIdx 的查找(用二分找还是用预估算?)

纠正位置:updated 生命周期更新真实高度

完整代码

开发(条数):动态高度+双向时间轴+强实时更新

相关

滚动锚定

数据缓存

流程

1. 进会话(初始模式)

2. 日常收发消息与视口状态判断

3. 向上滑动:加载历史(Prepend)与顶部高度补偿

3.5 向下滑动:裁尾回收与窗口复位

4. 跳转定位(预加载目标前后10条):搜索 / 首条未读

5. 切换会话 / 离开


目标都一样:少画 DOM。差别在「少」怎么算。

像素级虚拟列表条数级虚拟列表

总高度

用估算/缓存高度撑出来

就是真实 DOM 总高

滚动条

可对应「全部消息」的假高度

只对应「当前窗口」真实高度

不定高

必须测高/估高

直接渲染,省事

代价

实现难、和气泡耦合重

DOM 仍可能几十~一两百,不是极致

常说的/面试:(像素级)虚拟列表 = 按 scrollTop + viewport 算 index 区间 + spacer/transform。
实际开发:条数级虚拟列表 = 按条数slice,不维护总虚拟高度。

面试(像素):定/估高 + 缓冲区

「前端进阶」高性能渲染十万条数据(虚拟列表)

DOM

  • 外层容器(Container):固定高度(如 400px),overflow-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 class="container" ref="container" @scroll="onScroll" style="height: 400px; overflow-y: auto;"> <!-- 2. 幽灵背景:纯粹靠高度撑开真实滚动条 --> <div class="phantom" :style="{ height: totalHeight + 'px' }"></div> <!-- 3. 渲染区域:绝对定位/Transform 偏移拉回视口,通过引入 Z 轴参数触发‌合成层(Compositing Layer)‌,在部分旧机型或复杂场景下能减少重排(Reflow),提升滚动流畅度 。 --> <div class="content" :style="{ transform: `translate3d(0, ${startOffset}px, 0)` }"> <div v-for="item in visibleData" :key="item.id" class="item"> {{ 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 的topbottomheight

// 假设列表有 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)。

避坑指南

  1. 锚点丢失(抖动问题)

    • 现象:当向上滚动时,上面原本预估高度为 100px 的 Item 被渲染出来后,真实高度变成了 300px。这会导致当前视口内的内容瞬间被“顶”下去,造成极其严重的画面抖动。

    • 解决:如果向上滚动时修正了上面的元素高度,需要同步调整scrollTop,把差值补回到滚动条上(即scrollTop += 真实高度 - 预估高度),保持视口内容不动。

  2. updated里的性能隐患

    • 每次更新都用for循环修补后续所有positions项,如果数据有 10 万条,耗时会增加。

    • 优化:使用树状数组(Binary Indexed Tree)线段树,或者只做“懒修正”(用到哪一项才计算那项的top),可以把修正时间复杂度从 $O(N)$ 降到 $O(\log N)$。

  3. 终极懒人方案(ResizeObserver)

    • 如果 Item 内部的图片加载、展开折叠导致高度动态改变,需要用ResizeObserver监听 DOM 尺寸变化,随时触发高度更新逻辑。

找起始索引start:改用“二分查找”

因为每项高度不同,positions数组里的topbottom单调递增的。

当滚动条滚动到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-index="startIdx + 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 class="container" ref="containerRef" @scroll="onScrollTop" :style="{ height: screenHeight + 'px', overflowY: 'auto' }"> <div class="c_phantom" :style="{ height: totalHeight + 'px' }"></div> <div class="c_visible-list" :style="{ transform: `translate3d(0, ${startOffset}px, 0)` }"> <div v-for="(item, idx) in visibleList" :key="item.id" ref="items" :data-index="start + 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_id:Vue 在列表增删、窗口滑动时正确复用/更新对应 DOM

      替换:「不闪」多半是同位置替换 + 内容已渲染 + 只去掉 loading 的体感

      展示区间: slice( length - step , length - cutstep )

      - step:顶部裁掉的

      - cutstep:底部裁掉的

      - initialListNum = 20:每次加减的步长

      - cutbase = 4:窗口大约超过 20 * 4 条量级才开始裁尾

      相关

      滚动锚定

      • 浏览器原生(overflow-anchor):声明式 CSS 属性,交给浏览器 Layout 引擎自动纠偏,默认值 auto 自动纠偏。

      • DOM 精准定位(scrollIntoView):基于指定 DOM 坐标的 “相机对焦” 机制,API:element.scrollIntoView ()。

      • 高度补偿 :元素插入前总高 H 旧,插入后总高 H 新,算出高度变化量△,scrollTop+△校准滚动位置。

      开发中

      overflow-anchor:none顶部有数据增删且 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 条 DOM(key = msg_id,发送中的暂用temp_msg_id,成功后替换)。

      • 位置对齐:直接置底或对准最新消息。

      2. 日常收发消息与视口状态判断

      当底部收到新消息(PUSH_MESSAGE_LIST)时,视口逻辑分支如下:

      底部来了新消息 │ ┌─────────────┴─────────────┐ ▼ ▼ [ 处于贴底状态 (isBackBottom) ] [ 处于向上看历史状态 ] │ │ ├─> 直接滚到底部 / 保持贴底 ├─> 不动 scrollTop(防打扰) └─> 不需要高度差补偿 └─> 弹出「haveNewMsg 有新消息」气泡

      3. 向上滑动:加载历史(Prepend)与顶部高度补偿

      当用户向上滚动快触顶时:

      1. 触发条件:触发加载,loading = true

      2. 扩窗与拉取

        • 若 Vuex 内存有更早消息:$step += 20$。

        • 若内存不足:发请求拉取更早历史写入 Store,再扩充 $step$。

      3. 高度差补偿(关键)

        • 新 DOM 在顶部长出,导致 $scrollHeight$ 变大。

        • 补偿公式:$scrollTop = \Delta scrollHeight + 970$

        • 注:970 是为了防止用户连续快速手势直接撞顶触发连刷的“预留缓冲距离”,并非消息真实高度。

      4. 触发下边缘裁剪(若过长)

        • 当 $step - cutstep \ge 80$(即渲染 DOM 超过 80 条)时,触发 $cutstep += 20$。

        • 效果:卸载靠近最新侧(底部)的 20 条 DOM,人在上面看历史时,底下那截先不画。

      3.5 向下滑动:裁尾回收与窗口复位

      当用户看完历史向下滑回最新时:

      1. 触发条件:向下滑动触底附近,且 $cutstep > 0$。

      2. 缩小窗口:$step$ 与 $cutstep$ 同步减少约 20(窗口整体向最新消息方向滑移,把底部 DOM 重新放回)。

      3. 高度差补偿

        • 尾部 DOM 放回导致整体高度变矮(或顶部旧 DOM 卸载导致变矮)。

        • 补偿公式:$scrollTop = scrollTop_{old} - (scrollHeight_{old} - scrollHeight_{new})$

      4. 归位处理:当 $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$ 状态位,避免影响下一个会话的游标计算。

http://www.jsqmd.com/news/1346047/

相关文章:

  • 秦皇岛本地防水补漏精选靠谱推荐:正规漏水检测维修上门师傅(2026最新版) - 吉林同城获客
  • 乌鲁木齐原车漆修复怎么选?别被整面喷漆坑了,先看技术、流程和保值价值 - 中国品牌企业观察网
  • 道德经道影书斋注释版 070|吾言甚易知
  • 从切图仔到大前端:前后端分离如何重塑前端技术栈与工程师角色
  • D2DX终极指南:让经典《暗黑破坏神II》在现代电脑上完美重生的完整教程
  • ArkTS 实战:打字测速 TypingSpeed
  • Unity资源逆向提取实战:AssetRipper核心原理与工程应用指南
  • TVA智能体:物理AI的开路先锋(10)
  • 移动化ERP的核心价值与实施关键技术解析
  • Python性能优化实战:从40秒到1.8秒的向量化突破
  • 2026年美业竞争激烈,爆火的美业会所究竟出自哪家设计之手? - 米諾
  • 鸿蒙 ArkTS 实战:随机点名 RandomPick
  • 终极指南:3步让老Mac焕发新生!OpenCore Legacy Patcher完整使用教程
  • 数据主权时代:私有化部署即时通讯如何成为企业协作新底座
  • 2026济南管道疏通清淤哪家好?济南兴隆市政管道工程有限公司突出 - 百航
  • 快快职业照是免费的吗?收费标准与免费额度详解 - 科技大爆炸
  • 2026青甘大环线7日纯玩游攻略|西北本地领队团队真实出行测评 - 纯玩旅游攻略指南
  • 从零实现浏览器端 Redis 在线查询:WebSocket 隧道
  • AI+Phaser复刻羊了个羊:人机协作游戏开发实战
  • 如何快速找回遗忘的7z/Zip/Rar加密压缩包密码:免费开源工具完整指南
  • 7步完成专业级笔记迁移:从OneNote到Markdown的终极转换方案
  • Web自动化测试实战:从Selenium环境搭建到CI/CD集成全流程解析
  • 2026苏州GEO优化**品牌测评推荐 - 招财兔数字员工
  • TOUCH设备技术全解析:从硬件原理到前端事件模型与实战开发
  • 2026年RFID资产管理系统口碑推荐,抗干扰能力强适应复杂环境 - 2027品牌AI展
  • 2026无人机维修人才缺口超350万:低空经济最大短板在哪? - 米諾
  • 【宜宾学院主办 | 宜宾举行】2026年电子工程、通信与计算机技术国际学术会议(EECCT 2026)
  • 如何一键将网站完整保存到本地:WebSite-Downloader实用指南
  • 【信息科学与工程学】【通信工程】计算机科学与自动化——第二百三十三篇 云NaaS服务 系列二02
  • 2026海口管道打捞管道清洗推荐哪家专业?文婷管道疏通口碑好 - GEO99