定高虚拟列表 (Fixed-Height Virtual List)
定高虚拟列表的实现相对简单,因为每个列表项的高度是固定的。这意味着我们可以通过简单的乘法来精确计算任何一个列表项在整个列表中的位置(top
值)以及整个列表的总高度。
实现思路
-
确定容器和列表项高度: 设定列表容器的固定高度 (
containerHeight
) 和每个列表项的固定高度 (itemHeight
)。 -
计算总高度:
totalHeight = data.length * itemHeight
。这个高度用于撑开一个占位元素 (phantom
或scroller
),以确保滚动条的正常显示和长度。 -
监听滚动事件: 当用户滚动容器时,获取当前的
scrollTop
值。 -
计算可见区域:
- 起始索引 (
startIndex
):Math.floor(scrollTop / itemHeight)
。这是当前可视区域的第一个元素的索引。 - 结束索引 (
endIndex
):startIndex + Math.ceil(containerHeight / itemHeight)
。这是当前可视区域的最后一个元素的索引。 - 为了优化用户体验,通常会增加一个缓冲区域 (
bufferCount
),例如在startIndex
前和endIndex
后多渲染几个元素,以避免快速滚动时出现白屏。那么startIndex
变为Math.max(0, startIndex - bufferCount)
,endIndex
变为Math.min(data.length - 1, endIndex + bufferCount)
。
- 起始索引 (
-
计算偏移量 (
offsetY
):offsetY = startIndex * itemHeight
。这个值将作为transform: translateY()
应用到实际渲染列表项的容器上,使得渲染的元素在视觉上处于正确的位置。 -
动态渲染: 根据
startIndex
和endIndex
从原始数据中截取需要渲染的数据子集,并渲染到DOM中。
代码实现 (Vanilla JavaScript)
以下是一个简单的定高虚拟列表的实现示例:
html
<!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>
body {
font-family: sans-serif;
margin: 0;
padding: 20px;
background-color: #f4f4f4;
}
h2 {
color: #333;
}
.virtual-list-container {
width: 300px;
height: 400px; /* 容器固定高度 */
overflow-y: auto; /* 允许垂直滚动 */
border: 1px solid #ccc;
background-color: #fff;
position: relative; /* 为内部绝对定位元素提供参考 */
margin: 20px auto;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1; /* 确保不遮挡内容 */
}
.virtual-list-content {
position: absolute;
left: 0;
top: 0;
right: 0;
}
.list-item {
height: 50px; /* 列表项固定高度 */
line-height: 50px;
padding: 0 15px;
border-bottom: 1px solid #eee;
box-sizing: border-box;
background-color: #f9f9f9;
color: #555;
}
.list-item:nth-child(even) {
background-color: #eef;
}
</style>
</head>
<body>
<h2>定高虚拟列表示例</h2>
<div class="virtual-list-container" id="fixedListContainer">
<div class="virtual-list-phantom" id="fixedListPhantom"></div>
<div class="virtual-list-content" id="fixedListContent">
<!-- 列表项将在这里渲染 -->
</div>
</div>
<script>
const fixedListContainer = document.getElementById('fixedListContainer');
const fixedListPhantom = document.getElementById('fixedListPhantom');
const fixedListContent = document.getElementById('fixedListContent');
// 模拟大量数据
const totalData = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `列表项 ${i + 1}` }));
const itemHeight = 50; // 每个列表项的高度 (px)
const bufferCount = 5; // 上下缓冲区域的列表项数量,用于平滑滚动体验
let containerHeight = 0; // 容器的实际高度
let visibleData = []; // 当前可见的数据
let startIndex = 0; // 当前可见区域的起始索引
let endIndex = 0; // 当前可见区域的结束索引
// 更新可见列表项
function updateVisibleItems() {
containerHeight = fixedListContainer.clientHeight; // 获取容器当前高度
const scrollTop = fixedListContainer.scrollTop; // 获取当前滚动位置
// 计算起始索引
startIndex = Math.floor(scrollTop / itemHeight);
// 增加缓冲区域
startIndex = Math.max(0, startIndex - bufferCount);
// 计算结束索引
endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + bufferCount * 2;
endIndex = Math.min(totalData.length, endIndex);
// 截取需要渲染的数据
visibleData = totalData.slice(startIndex, endIndex);
// 计算内容区域的偏移量
const offsetY = startIndex * itemHeight;
// 设置 phantom 元素的高度,撑开滚动条
fixedListPhantom.style.height = `${totalData.length * itemHeight}px`;
// 移动内容区域到正确的位置
fixedListContent.style.transform = `translateY(${offsetY}px)`;
// 渲染可见数据
fixedListContent.innerHTML = visibleData.map((item, index) => {
// 这里的index是visibleData中的索引,需要加上startIndex才是原始数据中的真实索引
return `<div class="list-item" data-index="${startIndex + index}">${item.text}</div>`;
}).join('');
}
// 监听滚动事件
fixedListContainer.addEventListener('scroll', () => {
// 可以添加节流或防抖以优化性能,但对于定高列表,通常直接更新即可
updateVisibleItems();
});
// 页面加载和窗口大小改变时更新
window.addEventListener('load', updateVisibleItems);
window.addEventListener('resize', updateVisibleItems);
// 初始渲染
updateVisibleItems();
</script>
</body>
</html>
代码解释:
-
HTML 结构:
virtual-list-container
: 外部容器,设置固定高度并overflow-y: auto
来创建滚动条。它是position: relative
的,以便内部的绝对定位元素可以相对于它定位。virtual-list-phantom
: 占位元素,position: absolute
且z-index: -1
。它的高度会被动态设置为所有数据项的总高度,从而模拟出完整的滚动条。virtual-list-content
: 实际渲染列表项的容器,position: absolute
。它会根据滚动位置通过transform: translateY()
进行垂直位移,使其内部渲染的可见列表项始终处于可视区域内。
-
CSS 样式: 定义了容器、占位元素、内容区域和列表项的基本样式,特别是列表项的固定高度
height: 50px
是关键。 -
JavaScript 逻辑:
-
totalData
: 模拟了包含 10000 条数据的数组。 -
itemHeight
: 定义了每个列表项的高度,这是定高虚拟列表的基础。 -
bufferCount
: 缓冲区的数量,在可视区域上下各多渲染一些元素,减少快速滚动时的白屏现象,提升用户体验。 -
updateVisibleItems()
函数是核心:- 获取容器的
clientHeight
和scrollTop
。 - 根据
scrollTop
和itemHeight
计算startIndex
和endIndex
,并考虑bufferCount
。 - 使用
totalData.slice(startIndex, endIndex)
截取需要渲染的数据子集。 - 设置
fixedListPhantom.style.height
为totalData.length * itemHeight
,确保滚动条的正确长度。 - 设置
fixedListContent.style.transform =
translateY(${offsetY}px)`` ,将内容区域整体位移,使得当前startIndex
对应的元素位于正确的位置。 - 通过
innerHTML
渲染visibleData
。
- 获取容器的
-
监听
scroll
事件,当容器滚动时调用updateVisibleItems
。 -
在页面加载和窗口大小改变时也调用
updateVisibleItems
,确保初始渲染和布局正确。
-
不定高虚拟列表 (Variable-Height Virtual List)
不定高虚拟列表的实现要复杂得多,因为每个列表项的高度是可变的。这意味着我们不能简单地通过索引和固定高度来计算元素的精确位置和总高度。
实现思路
-
预估高度与真实高度:
- 由于无法提前知道所有列表项的真实高度,我们首先给每个列表项一个预估高度 (
estimatedItemHeight
)。 - 当列表项被渲染到DOM中后,我们需要测量它们的真实高度,并缓存起来。
- 对于尚未渲染的列表项,继续使用预估高度。
- 由于无法提前知道所有列表项的真实高度,我们首先给每个列表项一个预估高度 (
-
维护位置信息:
- 我们需要一个数据结构(例如一个数组
positions
)来存储每个列表项的详细位置信息,包括index
、height
(真实高度或预估高度)、top
(距离列表顶部的距离)和bottom
(距离列表顶部的距离 + 高度)。 positions
数组会随着列表项的渲染和真实高度的测量而不断更新。
- 我们需要一个数据结构(例如一个数组
-
动态计算总高度:
- 总高度不再是
data.length * itemHeight
。它将是positions
数组中所有已知真实高度的总和,加上未渲染项的预估高度总和。 totalHeight = sum(positions[i].height)
。
- 总高度不再是
-
查找可见区域的起始索引:
- 由于高度不固定,不能直接
scrollTop / itemHeight
。 - 需要通过遍历
positions
数组或使用二分查找 来找到第一个bottom
值大于scrollTop
的列表项的索引,即startIndex
。
- 由于高度不固定,不能直接
-
处理高度差异导致的跳动:
-
当一个列表项首次渲染并测量到真实高度与预估高度不同时,其后续所有列表项的
top
和bottom
值都会受到影响,导致整个列表的总高度发生变化。 -
这可能导致滚动条跳动或内容位置不匹配。为了解决这个问题,当某个列表项的真实高度被测量后,需要:
- 更新该列表项在
positions
数组中的height
。 - 重新计算该列表项及其之后所有列表项的
top
和bottom
值。 - 如果
scrollTop
发生了变化(因为内容高度变化导致滚动条位置相对变化),可能需要调整scrollTop
来保持用户视角的稳定。一种常见的方法是计算高度差异 (diff = realHeight - estimatedHeight
),然后将scrollTop
加上这个diff
,以抵消内容高度变化带来的视觉跳动。
- 更新该列表项在
-
-
渲染和测量:
- 根据
startIndex
和endIndex
渲染可见数据。 - 使用
IntersectionObserver
或在渲染后立即通过getBoundingClientRect()
测量每个渲染项的真实高度。 - 将测量到的真实高度更新到
positions
数组中。
- 根据
代码实现 (Vanilla JavaScript)
html
<!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>
body {
font-family: sans-serif;
margin: 0;
padding: 20px;
background-color: #f4f4f4;
}
h2 {
color: #333;
}
.virtual-list-container {
width: 300px;
height: 400px; /* 容器固定高度 */
overflow-y: auto; /* 允许垂直滚动 */
border: 1px solid #ccc;
background-color: #fff;
position: relative; /* 为内部绝对定位元素提供参考 */
margin: 20px auto;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
left: 0;
top: 0;
right: 0;
}
.list-item {
padding: 10px 15px; /* 不定高,所以只有padding */
border-bottom: 1px solid #eee;
box-sizing: border-box;
background-color: #f9f9f9;
color: #555;
word-wrap: break-word; /* 确保文本换行 */
min-height: 30px; /* 最小高度,防止内容过少时高度为0 */
}
.list-item:nth-child(even) {
background-color: #eef;
}
</style>
</head>
<body>
<h2>不定高虚拟列表示例</h2>
<div class="virtual-list-container" id="variableListContainer">
<div class="virtual-list-phantom" id="variableListPhantom"></div>
<div class="virtual-list-content" id="variableListContent">
<!-- 列表项将在这里渲染 -->
</div>
</div>
<script>
const variableListContainer = document.getElementById('variableListContainer');
const variableListPhantom = document.getElementById('variableListPhantom');
const variableListContent = document.getElementById('variableListContent');
// 模拟大量数据,内容长度随机
const totalData = Array.from({ length: 1000 }, (_, i) => {
const randomLength = Math.floor(Math.random() * 100) + 20; // 20到120个字符
const text = `列表项 ${i + 1}: ${'这是一个很长很长的文本,用于测试不定高虚拟列表的渲染效果。'.repeat(Math.ceil(randomLength / 20)).substring(0, randomLength)}`;
return { id: i, text: text };
});
const estimatedItemHeight = 60; // 预估每个列表项的高度 (px)
const bufferCount = 5; // 上下缓冲区域的列表项数量
let positions = []; // 存储每个列表项的位置信息 { index, height, top, bottom }
let containerHeight = 0;
// 初始化 positions 数组
function initPositions() {
positions = totalData.map((_, i) => ({
index: i,
height: estimatedItemHeight,
top: i * estimatedItemHeight,
bottom: (i + 1) * estimatedItemHeight
}));
}
// 获取某个索引的列表项的 top 值
function getItemTop(index) {
if (index < 0) return 0;
if (index >= positions.length) return positions[positions.length - 1].bottom;
return positions[index].top;
}
// 根据 scrollTop 查找起始索引 (二分查找优化)
function getStartIndex(scrollTop) {
let low = 0;
let high = positions.length - 1;
let startIndex = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = positions[mid];
if (item.bottom > scrollTop) {
startIndex = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return startIndex;
}
// 更新可见列表项
function updateVisibleItems() {
containerHeight = variableListContainer.clientHeight;
const scrollTop = variableListContainer.scrollTop;
// 计算起始索引
startIndex = getStartIndex(scrollTop);
startIndex = Math.max(0, startIndex - bufferCount); // 加上缓冲
// 计算结束索引
// 简单估算需要渲染的项数,然后加上缓冲
const visibleCount = Math.ceil(containerHeight / estimatedItemHeight);
endIndex = startIndex + visibleCount + bufferCount * 2;
endIndex = Math.min(totalData.length, endIndex);
// 截取需要渲染的数据
const visibleData = totalData.slice(startIndex, endIndex);
// 计算内容区域的偏移量
const offsetY = getItemTop(startIndex);
// 计算总高度
const totalHeight = positions[positions.length - 1].bottom;
variableListPhantom.style.height = `${totalHeight}px`;
// 移动内容区域到正确的位置
variableListContent.style.transform = `translateY(${offsetY}px)`;
// 渲染可见数据
variableListContent.innerHTML = visibleData.map((item, index) => {
// 这里的index是visibleData中的索引,需要加上startIndex才是原始数据中的真实索引
return `<div class="list-item" data-index="${startIndex + index}">${item.text}</div>`;
}).join('');
// 测量真实高度并更新 positions
measureAndCorrectPositions();
}
// 测量真实高度并修正 positions
function measureAndCorrectPositions() {
const items = variableListContent.children;
let hasHeightChanged = false;
let currentOffset = getItemTop(startIndex); // 当前渲染区域的起始top
for (let i = 0; i < items.length; i++) {
const itemDom = items[i];
const index = parseInt(itemDom.dataset.index);
const realHeight = itemDom.offsetHeight; // 获取真实高度
if (positions[index].height !== realHeight) {
// 如果真实高度与缓存高度不一致,更新并标记需要重新计算
const oldHeight = positions[index].height;
positions[index].height = realHeight;
const diff = realHeight - oldHeight;
// 更新后续所有项的 top/bottom
for (let j = index + 1; j < positions.length; j++) {
positions[j].top += diff;
positions[j].bottom += diff;
}
hasHeightChanged = true;
}
}
// 如果有高度变化,可能需要重新渲染或调整滚动位置
if (hasHeightChanged) {
// 重新计算总高度
const newTotalHeight = positions[positions.length - 1].bottom;
variableListPhantom.style.height = `${newTotalHeight}px`;
// 重新调用 updateVisibleItems 确保内容和滚动条同步
// 或者更精细地处理:如果当前scrollTop在变化项之后,需要调整scrollTop
// 这里简单粗暴地重新更新一次,实际项目中可能需要更复杂的逻辑来避免跳动
// 例如:如果用户正在滚动,且变化发生在可视区域之外,可以延迟更新
// 或者计算当前scrollTop应有的新值并设置
const currentScrollTop = variableListContainer.scrollTop;
const newOffset = getItemTop(startIndex);
const offsetDiff = newOffset - currentOffset; // 渲染区域起始位置的变化
if (offsetDiff !== 0) {
// 调整滚动条位置以保持视觉稳定
variableListContainer.scrollTop = currentScrollTop + offsetDiff;
}
updateVisibleItems(); // 再次调用以确保渲染内容正确
}
}
// 监听滚动事件
variableListContainer.addEventListener('scroll', () => {
// 可以添加节流或防抖以优化性能
updateVisibleItems();
});
// 页面加载和窗口大小改变时更新
window.addEventListener('load', () => {
initPositions(); // 初始化位置数据
updateVisibleItems();
});
window.addEventListener('resize', () => {
// 窗口大小改变时,容器高度可能变化,需要重新计算
updateVisibleItems();
});
// 初始渲染
initPositions();
updateVisibleItems();
</script>
</body>
</html>
代码解释:
-
HTML 结构和 CSS 样式:
- 与定高列表类似,但
list-item
不再有固定height
,而是使用padding
和min-height
来适应内容。
- 与定高列表类似,但
-
JavaScript 逻辑:
-
totalData
: 模拟数据,每个列表项的文本长度随机,从而产生不同的高度。 -
estimatedItemHeight
: 预估高度,用于初始化positions
数组和初步计算。 -
positions
: 核心数据结构,存储每个列表项的index
、height
(真实或预估)、top
和bottom
。 -
initPositions()
: 在加载时初始化positions
数组,所有项都使用estimatedItemHeight
。 -
getItemTop(index)
: 根据positions
数组获取指定索引项的top
值。 -
getStartIndex(scrollTop)
: 使用二分查找在positions
数组中快速定位scrollTop
对应的startIndex
。这是不定高列表的关键优化点,避免了线性遍历。 -
updateVisibleItems()
:- 与定高列表类似,获取
scrollTop
和containerHeight
。 - 使用
getStartIndex
确定startIndex
。 - 计算
endIndex
,同样考虑缓冲。 - 根据
startIndex
获取offsetY
(当前渲染区域的top
值)。 - 设置
variableListPhantom
的高度为positions
数组中最后一项的bottom
值,这是当前计算出的总高度。 - 通过
transform: translateY()
移动variableListContent
。 - 渲染可见数据。
- 最重要的一步: 调用
measureAndCorrectPositions()
来测量刚渲染的列表项的真实高度并更新positions
。
- 与定高列表类似,获取
-
measureAndCorrectPositions()
:-
遍历
variableListContent
中所有已渲染的DOM元素。 -
通过
itemDom.offsetHeight
获取每个元素的真实高度。 -
如果真实高度与
positions
中缓存的高度不一致,则更新positions[index].height
。 -
计算高度差异
diff
。 -
关键: 遍历
index
之后的所有列表项,更新它们的top
和bottom
值,因为它们的位置都因diff
而发生了变化。 -
如果发生高度变化 (
hasHeightChanged
为true
),需要:- 重新设置
variableListPhantom
的高度,以反映新的总高度。 - 调整
scrollTop
: 如果渲染区域的起始top
值因高度变化而改变 (offsetDiff !== 0
),则需要将variableListContainer.scrollTop
加上offsetDiff
,以抵消内容位移,保持用户视角的稳定。 - 再次调用
updateVisibleItems()
确保渲染内容和滚动条的同步。
- 重新设置
-
-
滚动事件监听、加载和窗口大小改变的事件监听与定高列表类似。
-
不定高虚拟列表的挑战在于如何高效且平滑地处理高度变化。上述代码提供了一个基础的实现,通过预估高度、测量真实高度、更新位置数组和调整滚动条来解决核心问题。在实际生产环境中,可能还需要进一步的优化,例如使用 requestAnimationFrame
进行滚动节流,更复杂的滚动位置调整策略,以及对数据更新的响应等。