目录
[面试(像素):定/估高 + 缓冲区](#面试(像素):定/估高 + 缓冲区)
[动态高度:估高 + 高度缓存+二分查找](#动态高度:估高 + 高度缓存+二分查找)
[找起始索引 start:改用"二分查找"](#找起始索引 start:改用“二分查找”)
[关于 endIdx 的查找(用二分找还是用预估算?)](#关于 endIdx 的查找(用二分找还是用预估算?))
[纠正位置:updated 生命周期更新真实高度](#纠正位置:updated 生命周期更新真实高度)
[1. 进会话(初始模式)](#1. 进会话(初始模式))
[2. 日常收发消息与视口状态判断](#2. 日常收发消息与视口状态判断)
[3. 向上滑动:加载历史(Prepend)与顶部高度补偿](#3. 向上滑动:加载历史(Prepend)与顶部高度补偿)
[3.5 向下滑动:裁尾回收与窗口复位](#3.5 向下滑动:裁尾回收与窗口复位)
[4. 跳转定位(预加载目标前后10条):搜索 / 首条未读](#4. 跳转定位(预加载目标前后10条):搜索 / 首条未读)
[5. 切换会话 / 离开](#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 // 撑开滚动条的高度
javascript
// 可视区域高度固定,称之为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;
完整代码
javascript
<!-- 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为列表项的渲染区域
动态高度:估高 + 高度缓存+二分查找
滚动事件触发
│
▼
- 拿到 scrollTop
│
▼
- 通过【二分查找】在 positions 中找出 start 索引
│
▼
- 加上缓冲区,计算 end 索引,更新 visibleData 驱动视图渲染
│
▼
- startOffset = positionsstart.top (把渲染框拉到对应位置)
│
▼
- 【DOM 渲染完成 (updated)】
│
▼
- 测量 DOM 真实高度 -> 修正 positions 映射表 -> 累加更新后续所有节点的 top/bottom
-
测量(Measured) :当 DOM 实际渲染出来后,通过
getBoundingClientRect()拿到它的真实高度,更新缓存。 -
维护位置映射表(Positions) :在内存中维护一个数组,记录每一个 Item 的
top、bottom和height。
javascript
// 假设列表有 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 尺寸变化,随时触发高度更新逻辑。
- 如果 Item 内部的图片加载、展开折叠导致高度动态改变 ,需要用
找起始索引 start:改用"二分查找"
因为每项高度不同,positions 数组里的 top 和 bottom 是单调递增的。
当滚动条滚动到 scrollTop 时,我们需要在 positions 数组中找到第一个 bottom > scrollTop 的元素索引。
使用二分查找,时间复杂度只有 O(\\log N),即使上万条数据也是微秒级响应:
javascript
// 二分查找:找到第一个 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 data-index="105">),在updated钩子函数里通过Number(node.dataset.index)读出来的就是全局第 105 项,直接对应this.positions[105],无论怎么滚动,全局索引绝对不会乱!
-
DOM 渲染完成后,在 Vue 的 updated(或 React 的 useLayoutEffect)中测量真实 DOM 高度,并修正映射表:
javascript
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 属性 data-index,类型转换+"5" -> 5
let oldHeight = this.positions[index].height;
let dValue = oldHeight - realHeight; // 高度差值
// 2. 如果真实高度和预估高度不一致,修正当前项及后续所有项的 top/bottom
if (dValue !== 0) {
this.positions[index].height = realHeight;
this.positions[index].bottom = this.positions[index].bottom - dValue;
// 核心:当前项高度变了,后面所有项的 top 和 bottom 都要随之调整(顺延)
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom;
this.positions[k].bottom = this.positions[k].bottom - dValue;
}
}
});
});
}
完整代码
在虚拟列表测量列表项高度 的场景下,唯一推荐的首选是 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(计算可视视口区域,自动剔除容器自身的边框和滚动条)。
javascript
<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 状态位,避免影响下一个会话的游标计算。