上篇博客我讲到了定高虚拟列表的实现,这篇文章我将继续讲解不定高虚拟列表实现

1. 为什么"不定高"比"定高"难?
定高虚拟列表里每项高度固定为 itemHeight,滚动偏移 scrollTop 和索引可以直接换算:
startIndex = floor(scrollTop / itemHeight)- 总高度 =
N * itemHeight
但不定高列表中,每个 item 的真实高度可能不一样(比如文本长短不同、图片高度不同),于是:
- 你没法用
scrollTop / itemHeight直接得到准确索引 - 你也没法一开始就知道总高度是多少
解决思路 :
先用一个"估算高度"把全列表撑起来并完成首次渲染;渲染出真实 DOM 后,再测量真实高度并修正"位置表(positions)",从而逐步逼近真实布局。
2. 整体结构:viewport / phantom / content 三层模型
这份实现也采用经典的三层结构:
-
viewport(滚动容器) :
#list-view真正发生滚动的元素,通过它读取
scrollTop与clientHeight -
phantom(幽灵占位层) :
#list-phantom不渲染真实内容,只用
height撑开整个滚动区域(让滚动条长度正确) -
content(真实渲染层) :
#list-content只渲染可视区 + 缓冲区的少量 DOM,通过
transform: translate3d放到正确位置
3. 核心类 VirtualList:关键字段都是什么?
构造函数里初始化了这些关键属性:
3.1 配置项
-
estimatedHeight = 80
预估高度:用来初始化位置表并估算首屏渲染范围 -
bufferSize = 5
缓冲区:在可视区上下多渲染几项,避免滚动边缘白屏/闪烁
3.2 状态
-
visibleData当前真正要渲染的那一段数据切片(slice 出来的)
-
positions(重中之重)
元数据表:为每一项维护其在整个列表中的布局信息
每个 positions[i] 形如:
js
{
index,
height, // 当前认为的高度(初始化用 estimatedHeight,后续会修正为真实高度)
top, // 当前认为的顶部位置
bottom, // 当前认为的底部位置
dValue // 真实高度与旧高度的差(用于修正)
}
4. initPositions:用"预估高度"初始化位置表,并撑起 phantom
初始化阶段:
- 假设每项高度都是 estimatedHeight
- 计算每项的 top/bottom
- 用最后一项的 bottom 作为 phantom 总高度
核心逻辑等价于:
- top[i] = i * estimatedHeight
- bottom[i] = (i + 1) * estimatedHeight
- phantomHeight = bottom[last]
这样即使真实高度未知,也可以先让滚动条"看起来合理",并具备可滚动性。
5. 滚动监听:requestAnimationFrame 对齐渲染节奏
滚动事件频率非常高,代码采用:
cpp
this.container.addEventListener(
"scroll",
() => {
requestAnimationFrame(() => {
this.render();
});
},
{ passive: true }
);
含义:
- passive: true:告诉浏览器监听器不会 preventDefault(),更利于滚动性能
- requestAnimationFrame:让渲染与浏览器下一帧绘制对齐(避免同一帧反复 render)
6. 使用二分查找 startIndex
定高列表可以用 scrollTop / itemHeight 得索引;不定高列表做不到。
这份代码通过 positions 做二分查找:
找到第一个 bottom > scrollTop 的项,它就是当前滚动位置对应的"首个可见项"。
实现函数:
cpp
getStartIndex(scrollTop) {
// 二分查找 positions[mid].bottom 与 scrollTop 的关系
}
这个函数的关键判断:
midVal = positions[midIndex].bottom- 如果
midVal < scrollTop:说明 mid 还在可视区上方 → 往右找 - 如果
midVal > scrollTop:mid 可能是答案 → 记录 tempIndex 并继续往左逼近
最终返回的 tempIndex 就是最小的满足 bottom > scrollTop 的索引。
为什么重要?
因为 positions 反映的是"当前已知的布局",即使它一开始是估计的,随着不断修正会越来越准确,从而 startIndex 也越来越准。
7. render:一次滚动帧内的完整渲染流程
render() 的步骤可以按 6 步理解:
7.1 读取滚动偏移
cpp
const scrollTop = this.container.scrollTop;
7.2 估算可视数量 visibleCount(仍基于 estimatedHeight)
cpp
const visibleCount = Math.ceil(
this.container.clientHeight / this.estimatedHeight
);
注意:这里用的是预估高度,不是实时平均高度,所以只是"粗略可视数量"。但配合 bufferSize 足够可用。
7.3 用二分查找得到 startIndex
cpp
let startIndex = this.getStartIndex(scrollTop);
7.4 加 buffer 得到渲染区间 [start, end)
cpp
const start = Math.max(0, startIndex - this.bufferSize);
const end = Math.min(
this.listData.length,
startIndex + visibleCount + this.bufferSize
);
7.5 截取数据并渲染 DOM
cpp
this.visibleData = this.listData.slice(start, end);
然后拼出 HTML,写入 listContent.innerHTML。
7.6 用 transform 把真实渲染层移动到正确位置
关键点:不使用 top: xxxpx,而使用 GPU 友好的位移:
cpp
const startOffset = this.positions[start].top;
this.listContent.style.transform = `translate3d(0, ${startOffset}px, 0)`;
这一步让"只渲染一小段 DOM"在视觉上处于全列表正确的位置。
8. updatePositions:渲染后测量真实高度,并修正 positions
这是"不定高虚拟列表"最核心的一步:真实高度必须靠 DOM 渲染后才能测出来。
8.1 遍历当前渲染出来的节点,读取真实高度
cpp
const rect = node.getBoundingClientRect();
const realHeight = rect.height;
对比旧高度:
cpp
const oldHeight = this.positions[index].height;
const dValue = realHeight - oldHeight;
如果 dValue != 0,说明估算错了,需要修正:
- 更新当前项的
height - 更新当前项的
bottom - 记录
dValue - 累计 diff
8.2 为什么要更新"后续所有项"的 top/bottom?
因为某一项高度变了,会导致它后面的所有项位置都发生"连锁反应"。
代码采取从"本次渲染区第一个节点"开始,重新链式计算直到末尾:
cpp
let accumulatedTop = this.positions[startUpdateIndex].top;
for (let i = startUpdateIndex; i < this.positions.length; i++) {
const item = this.positions[i];
item.top = accumulatedTop;
item.bottom = item.top + item.height;
accumulatedTop = item.bottom;
}
最后更新 phantom 总高度:
cpp
this.phantom.style.height = `${
this.positions[this.positions.length - 1].bottom
}px`;
8.3 复杂度与工程化提示
代码里也写了提示:
- 这种"从某处到末尾的 O(n) 更新"在几万条数据上会变慢
- 工程里可用更高阶的数据结构(如线段树 / Fenwick 树 / 分块)优化,或做懒更新
这份 Demo 选择 O(n) 的原因很合理:用最直观的方式把原理讲清楚。
附上js代码
cpp
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>高性能不定高虚拟列表</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
background: #f0f2f5;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
/* 容器样式 */
#app {
width: 375px;
height: 667px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden; /* 内部滚动,外部隐藏 */
}
/* 头部 */
header {
height: 60px;
background: #000;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
/* 列表视口 (Viewport) */
#list-view {
position: absolute;
top: 60px;
bottom: 0;
left: 0;
right: 0;
overflow-y: auto; /* 开启原生滚动 */
-webkit-overflow-scrolling: touch;
}
/* 幽灵占位区域 (Phantom) - 用于撑开滚动条 */
#list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
/* 真实列表容器 */
#list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
/* 列表项样式 */
.list-item {
padding: 16px;
border-bottom: 1px solid #eee;
background: #fff;
display: flex;
flex-direction: column;
}
.item-head {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
color: #999;
font-size: 12px;
}
.item-text {
line-height: 1.6;
font-size: 14px;
color: #333;
word-break: break-all;
}
.item-img {
background: #eee;
margin-top: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 12px;
}
/* 调试面板 */
#debug-panel {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: #0f0;
padding: 10px;
font-family: monospace;
font-size: 12px;
pointer-events: none;
border-radius: 4px;
}
</style>
</head>
<body>
<div id="app">
<header>不定高虚拟列表 Demo</header>
<div id="list-view">
<div id="list-phantom"></div>
<div id="list-content"></div>
</div>
</div>
<div id="debug-panel">
FPS: <span id="fps">0</span><br />
Rendered: <span id="render-count">0</span> items<br />
Start Index: <span id="start-index">0</span>
</div>
<script>
// ==========================================
// 1. 模拟数据生成器 (Mock Data)
// ==========================================
const TOTAL_COUNT = 10000; // 模拟 1万条数据
const data = [];
// 生成随机文本
function getRandomText() {
const str =
"性能优化是一个系统工程涉及渲染原理算法设计多线程计算等核心知识点";
let res = "";
const len = Math.floor(Math.random() * 100) + 10; // 随机 10-110 个字
for (let i = 0; i < len; i++)
res += str[Math.floor(Math.random() * str.length)];
return res;
}
// 初始化数据
for (let i = 0; i < TOTAL_COUNT; i++) {
data.push({
id: i,
text: getRandomText(),
hasImage: Math.random() > 0.7, // 30% 概率有图
imgHeight: Math.floor(Math.random() * 100) + 50, // 随机图片高度
});
}
// ==========================================
// 2. 核心类: VirtualList
// ==========================================
class VirtualList {
constructor(containerId, listId, phantomId, listData) {
this.container = document.getElementById(containerId);
this.listContent = document.getElementById(listId);
this.phantom = document.getElementById(phantomId);
this.listData = listData;
// 配置项
this.estimatedHeight = 80; // 预估高度 (Estimate Height)
this.bufferSize = 5; // 缓冲区大小 (Buffer Zone)
// 状态
this.visibleData = [];
this.positions = []; // 元数据表:存储每一项的 top, bottom, height
this.initPositions();
this.initEvents();
this.render(); // 首次渲染
}
// --- 初始化元数据 ---
initPositions() {
this.positions = this.listData.map((_, index) => ({
index,
height: this.estimatedHeight,
top: index * this.estimatedHeight,
bottom: (index + 1) * this.estimatedHeight,
dValue: 0, // 真实高度与预估高度的差值
}));
this.phantom.style.height = `${
this.positions[this.positions.length - 1].bottom
}px`;
}
// --- 初始化事件监听 ---
initEvents() {
// 监听滚动事件(被动监听,提升性能)
this.container.addEventListener(
"scroll",
() => {
requestAnimationFrame(() => {
this.render();
});
},
{ passive: true }
);
}
// --- 核心:二分查找 (Binary Search) ---
// 找到第一个 bottom > scrollTop 的项
getStartIndex(scrollTop) {
let start = 0;
let end = this.positions.length - 1;
let tempIndex = null;
while (start <= end) {
let midIndex = parseInt((start + end) / 2);
let midVal = this.positions[midIndex].bottom;
if (midVal === scrollTop) {
return midIndex + 1;
} else if (midVal < scrollTop) {
start = midIndex + 1;
} else if (midVal > scrollTop) {
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
end = midIndex - 1;
}
}
console.log('🚀 ~ VirtualList ~ getStartIndex ~ tempIndex:', tempIndex)
return tempIndex;
}
// --- 核心渲染逻辑 ---
render() {
const scrollTop = this.container.scrollTop;
// 1. 计算可视范围
const visibleCount = Math.ceil(
this.container.clientHeight / this.estimatedHeight
);
// 2. 二分查找起始索引
let startIndex = this.getStartIndex(scrollTop);
// 3. 加上缓冲区 (Buffer Zone)
const start = Math.max(0, startIndex - this.bufferSize);
const end = Math.min(
this.listData.length,
startIndex + visibleCount + this.bufferSize
);
// 4. 截取数据
this.visibleData = this.listData.slice(start, end);
// 5. 渲染 DOM
// 注意:这里使用 transform 偏移,而不是 absolute top
// 偏移量 = 起始项的 top 值
const startOffset = this.positions[start].top;
this.listContent.style.transform = `translate3d(0, ${startOffset}px, 0)`;
// 生成 HTML
this.listContent.innerHTML = this.visibleData
.map((item) => {
const imgHtml = item.hasImage
? `<div class="item-img" style="height:${item.imgHeight}px">图片占位 (高度: ${item.imgHeight}px)</div>`
: "";
return `
<div class="list-item" id="item-${item.id}" data-index="${
item.id
}">
<div class="item-head">
<span>Index: ${item.id}</span>
<span>Pos: ${this.positions[item.id].top}px</span>
</div>
<div class="item-text">${item.text}</div>
${imgHtml}
</div>
`;
})
.join("");
// 更新调试面板
document.getElementById("start-index").innerText = start;
document.getElementById("render-count").innerText =
this.visibleData.length;
// 6. 关键步骤:渲染后修正高度
this.updatePositions(start);
}
// --- 动态修正高度 (Dynamic Height Correction) ---
updatePositions(renderStartIndex) {
const nodes = this.listContent.children;
if (!nodes || nodes.length === 0) return;
let diff = 0; // 总高度差值
for (let node of nodes) {
const index = parseInt(node.dataset.index);
const rect = node.getBoundingClientRect();
const realHeight = rect.height;
const oldHeight = this.positions[index].height;
const dValue = realHeight - oldHeight;
if (dValue) {
// 更新当前项
this.positions[index].height = realHeight;
this.positions[index].bottom += dValue;
this.positions[index].dValue = dValue;
diff += dValue; // 累加差值
}
}
// 如果有高度变化,需要更新后续所有项的 top/bottom
// ⚠️ 性能优化点:这里全量更新在几万条数据时会慢。
// 实际工程中可用线段树优化,或者只在用到时懒计算。
// 本 Demo 为了演示原理,采用全量更新,但只更新渲染索引之后的
if (diff !== 0) {
// 找到第一个发生变化的索引
const startUpdateIndex = parseInt(nodes[0].dataset.index);
// 累加修正
// 注意:这里是一个简化的 O(n) 更新。
// 实际上我们只需要保证 positions[index].top 是对的即可
// 下面的循环是"链式反应"
let accumulatedTop = this.positions[startUpdateIndex].top;
for (let i = startUpdateIndex; i < this.positions.length; i++) {
const item = this.positions[i];
item.top = accumulatedTop;
item.bottom = item.top + item.height;
accumulatedTop = item.bottom;
}
// 更新滚动条总高度 (Phantom Height)
this.phantom.style.height = `${
this.positions[this.positions.length - 1].bottom
}px`;
}
}
}
// ==========================================
// 3. 启动应用
// ==========================================
const vList = new VirtualList(
"list-view",
"list-content",
"list-phantom",
data
);
// 常规 DOM 渲染 100000 个元素列表,极度卡顿
// const listContentDom = document.querySelector("#list-content");
// const containerDom = document.querySelector("#list-view");
// const count = 100000;
// for (let index = 0; index < count; index++) {
// const liDom = document.createElement("li");
// liDom.innerHTML = index;
// listContentDom.appendChild(liDom);
// }
// 简单的 FPS 监控
let frame = 0;
let lastTime = performance.now();
const fpsElem = document.getElementById("fps");
function loop() {
const now = performance.now();
frame++;
if (now > lastTime + 1000) {
const fps = Math.round((frame * 1000) / (now - lastTime));
fpsElem.innerText = fps;
fpsElem.style.color = fps < 30 ? "red" : "#0f0";
frame = 0;
lastTime = now;
}
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>