目录
面试(像素):定/估高 + 缓冲区
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 的
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-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)与顶部高度补偿
当用户向上滚动快触顶时:
触发条件:触发加载,
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$ 状态位,避免影响下一个会话的游标计算。