深度解讀虛擬列表:從原理到實戰,解決長列表渲染性能難題
前言:被長列表 “卡崩” 的前端日常
“萬級數據加載後,頁面滾動像幻燈片?”
“列表項含圖片時,滾動到一半突然‘跳位’?”
“DOM 數量破萬後,瀏覽器直接提示‘頁面無響應’?”
做前端開發的你,大概率遇到過這些場景。這不是代碼能力的問題 —— 瀏覽器的渲染瓶頸擺在那裏:每新增一個 DOM 元素,都會增加重排重繪的計算成本,當 DOM 數量突破 5000 時,多數設備都會出現明顯卡頓。
而虛擬列表(Virtual List),正是為解決這個痛點而生。它的核心邏輯極其簡潔:只渲染當前可視區域內的列表項,非可視區域內容完全不渲染。通過 “用空間換時間” 的思路,把 DOM 數量牢牢控制在幾十到幾百的常量級別,哪怕數據量達到十萬級,頁面也能保持絲滑滾動。
本文將完全圍繞下面提供的 “可變高度虛擬列表(可配置版)”Demo 展開,從核心原理拆解、關鍵步驟實現,到 Demo 的實戰亮點、落地避坑,幫你把虛擬列表從 “面試知識點” 變成 “業務可用的工具”。
給你附上完整demo (這還不值得你一鍵三連嗎?!)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>可變高度虛擬列表(可配置版)</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
padding: 20px;
font-family: Arial, sans-serif;
background: #f5f5f5;
}
.container {
display: flex;
gap: 30px;
max-width: 1200px;
margin: 0 auto;
}
/* 虛擬列表樣式 */
.virtual-list-container {
height: 600px; /* 可視區域高度 */
overflow-y: auto;
position: relative;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: white;
width: 600px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.virtual-list-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1; /* 不影響滾動 */
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 0 16px;
}
.virtual-list-item {
margin: 12px 0;
padding: 16px;
border-radius: 6px;
background: #fafafa;
border: 1px solid #eee;
transition: background 0.2s;
}
.virtual-list-item:hover {
background: #f0f9ff;
border-color: #e1f5fe;
}
/* 調試面板樣式 */
.debug-panel {
flex: 1;
min-width: 300px;
background: white;
border-radius: 8px;
padding: 20px;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.debug-panel h3 {
margin-bottom: 20px;
color: #2d3748;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.debug-item {
margin-bottom: 12px;
display: flex;
justify-content: space-between;
}
.debug-label {
color: #4a5568;
font-size: 14px;
}
.debug-value {
color: #2563eb;
font-weight: 600;
font-size: 14px;
min-width: 60px;
text-align: right;
}
/* 配置輸入區域樣式 */
.config-group {
margin: 20px 0;
padding: 16px;
background: #f8fafc;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.config-group h4 {
margin-bottom: 12px;
color: #2d3748;
font-size: 15px;
}
.config-item {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.config-item label {
flex: 1;
color: #4a5568;
font-size: 14px;
}
.config-item input {
flex: 1;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-size: 14px;
width: 100px;
}
.config-item input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.btn-apply {
width: 100%;
padding: 10px;
margin-top: 8px;
border: none;
border-radius: 4px;
background: #10b981;
color: white;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.btn-apply:hover {
background: #059669;
}
.control-group {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.control-group button {
padding: 8px 16px;
margin-right: 10px;
margin-bottom: 10px;
border: none;
border-radius: 4px;
background: #2563eb;
color: white;
cursor: pointer;
transition: background 0.2s;
}
.control-group button:hover {
background: #1d4ed8;
}
.control-group button.reset {
background: #94a3b8;
}
.control-group button.reset:hover {
background: #64748b;
}
.info-text {
margin-top: 10px;
font-size: 12px;
color: #718096;
line-height: 1.5;
}
.error-text {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
height: 16px;
}
</style>
</head>
<body>
<div class="container">
<!-- 虛擬列表容器 -->
<div class="virtual-list-container">
<div class="virtual-list-placeholder"></div>
<div class="virtual-list-content"></div>
</div>
<!-- 調試面板 -->
<div class="debug-panel">
<h3>虛擬列表調試信息</h3>
<div class="debug-item">
<span class="debug-label">總列表項數:</span>
<span class="debug-value" id="total-count">0</span>
</div>
<div class="debug-item">
<span class="debug-label">已渲染項數:</span>
<span class="debug-value" id="rendered-count">0</span>
</div>
<div class="debug-item">
<span class="debug-label">可視起始索引:</span>
<span class="debug-value" id="start-index">0</span>
</div>
<div class="debug-item">
<span class="debug-label">可視結束索引:</span>
<span class="debug-value" id="end-index">0</span>
</div>
<div class="debug-item">
<span class="debug-label">滾動位置(scrollTop):</span>
<span class="debug-value" id="scroll-top">0</span>
</div>
<div class="debug-item">
<span class="debug-label">列表總高度:</span>
<span class="debug-value" id="total-height">0</span>
</div>
<div class="debug-item">
<span class="debug-label">預估高度:</span>
<span class="debug-value" id="estimate-height">80</span>
</div>
<div class="debug-item">
<span class="debug-label">緩衝項數量:</span>
<span class="debug-value" id="buffer-count">2</span>
</div>
<div class="debug-item">
<span class="debug-label">最大緩存列表項條數:</span>
<span class="debug-value" id="max-cache-size">100</span>
</div>
<!-- 新增:配置輸入區域 -->
<div class="config-group">
<h4>自定義配置</h4>
<div class="config-item">
<label for="custom-total">列表總條數:</label>
<input type="number" id="custom-total" placeholder="默認1000" min="1" max="100000">
</div>
<div class="config-item">
<label for="custom-buffer">緩衝項數量:</label>
<input type="number" id="custom-buffer" placeholder="默認2" min="0" max="10">
</div>
<div class="config-item">
<label for="custom-estimate">預估高度(px):</label>
<input type="number" id="custom-estimate" placeholder="默認80" min="20" max="500">
</div>
<div class="config-item">
<label for="custom-maxCacheSize">最大緩存列表項條數:</label>
<input type="number" id="custom-maxCacheSize" placeholder="默認100" min="0" max="200">
</div>
<div class="error-text" id="config-error"></div>
<button class="btn-apply" id="apply-config">應用配置</button>
</div>
<div class="control-group">
<button id="refresh-data">刷新測試數據</button>
<button id="reset" class="reset">重置默認配置</button>
<div class="info-text">
説明:<br>
1. 支持手動輸入列表總數(1-100000)、緩衝數(0-10)、預估高度(20-500px)、緩存條數(0-200)<br>
2. 列表項高度隨機(含部分圖片),滾動時自動校準真實高度<br>
3. 緩衝數越大,滾動越流暢但渲染DOM越多;緩衝數為0可能出現空白<br>
3. 緩存數越大,滾動越流暢但渲染DOM越多;複用列表項,不會重新渲染<br>
4. 總數建議不超過10萬,避免內存佔用過高
</div>
</div>
</div>
</div>
<script>
class VariableHeightVirtualList {
constructor(options) {
// 配置參數
this.container = options.container;
this.data = options.data;
this.estimateHeight = options.estimateHeight || 80;
this.buffer = options.buffer || 2;
this.maxCacheSize = options.maxCacheSize || 100;
this.defaultTotal = this.data.length;
this.defaultEstimateHeight = this.estimateHeight;
this.defaultBuffer = this.buffer;
this.defaultMaxCacheSize = this.maxCacheSize;
// 核心數據
this.itemHeights = new Array(this.data.length).fill(this.estimateHeight);
this.prefixHeights = [0];
this.containerHeight = this.container.clientHeight;
this.scrollTop = 0;
this.currentStartIndex = 0;
this.currentEndIndex = 0;
this.cacheElements = [];
this.cacheElementsRecord = [];
// DOM元素
this.placeholder = this.container.querySelector('.virtual-list-placeholder');
this.content = this.container.querySelector('.virtual-list-content');
// 調試DOM
this.debugElements = {
totalCount: document.getElementById('total-count'),
renderedCount: document.getElementById('rendered-count'),
startIndex: document.getElementById('start-index'),
endIndex: document.getElementById('end-index'),
scrollTop: document.getElementById('scroll-top'),
totalHeight: document.getElementById('total-height'),
estimateHeight: document.getElementById('estimate-height'),
bufferCount: document.getElementById('buffer-count'),
maxCacheSize: document.getElementById('max-cache-size')
};
// 配置輸入DOM
this.configElements = {
customTotal: document.getElementById('custom-total'),
customBuffer: document.getElementById('custom-buffer'),
customEstimate: document.getElementById('custom-estimate'),
customMaxCacheSize: document.getElementById('custom-maxCacheSize'),
configError: document.getElementById('config-error'),
applyBtn: document.getElementById('apply-config')
};
// 初始化
this.init();
}
// 初始化
init() {
this.calcPrefixHeights();
this.updatePlaceholderHeight();
this.updateVisibleItems();
this.updateDebugInfo(); // 初始化調試信息
this.bindEvents();
this.bindConfigEvents(); // 綁定配置相關事件
}
// 計算前綴和
calcPrefixHeights() {
for (let i = 0; i < this.data.length; i++) {
this.prefixHeights[i + 1] = this.prefixHeights[i] + this.itemHeights[i];
}
}
// 更新佔位高度
updatePlaceholderHeight() {
const totalHeight = this.prefixHeights[this.data.length];
this.placeholder.style.height = `${totalHeight}px`;
// 更新調試信息中的總高度
this.debugElements.totalHeight.textContent = Math.round(totalHeight);
}
// 二分查找起始索引
findStartIndex() {
const scrollTop = this.scrollTop;
let low = 0, high = this.prefixHeights.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (this.prefixHeights[mid] <= scrollTop) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return Math.max(0, low - 1);
}
// 計算結束索引
findEndIndex(startIndex) {
const scrollBottom = this.scrollTop + this.containerHeight;
let endIndex = startIndex;
while (endIndex < this.data.length && this.prefixHeights[endIndex + 1] <= scrollBottom) {
endIndex++;
}
endIndex = Math.min(this.data.length, endIndex + this.buffer);
return endIndex;
}
// 渲染可見項
updateVisibleItems() {
this.currentStartIndex = this.findStartIndex();
this.currentEndIndex = this.findEndIndex(this.currentStartIndex);
const visibleData = this.data.slice(this.currentStartIndex, this.currentEndIndex);
// 渲染項(包含索引和高度信息,方便調試)
this.content.innerHTML = '';
visibleData.forEach((item, idx) => {
const realIndex = this.currentStartIndex + idx;
this.cacheElementsRecord = this.cacheElementsRecord.filter(i => i !== realIndex);
this.cacheElementsRecord.unshift(realIndex);
if(this.cacheElementsRecord.length > this.maxCacheSize){
const removeIndex = this.cacheElementsRecord.pop();
delete this.cacheElements[removeIndex];
}
if(this.cacheElements[realIndex]){
this.content.appendChild(this.cacheElements[realIndex]);
return;
}
const itemHeight = this.itemHeights[realIndex];
const element = document.createElement('div');
element.innerHTML = `
<div class="virtual-list-item" data-index="${realIndex}">
<div style="margin-bottom: 8px; color: #64748b; font-size: 12px;">
索引: ${realIndex} | 高度: ${itemHeight}px
</div>
<div style="color: #2d3748; line-height: 1.6;">
${item.content}
</div>
</div>
`;
this.cacheElements[realIndex] = element;
this.content.appendChild(element);
});
// 定位內容區
const offsetTop = this.prefixHeights[this.currentStartIndex];
this.content.style.transform = `translateY(${offsetTop}px)`;
// 校準高度
this.calibrateHeights();
// 更新調試信息
this.updateDebugInfo();
}
// 校準真實高度
calibrateHeights() {
const items = this.content.querySelectorAll('.virtual-list-item');
let isHeightChanged = false;
items.forEach(item => {
const index = parseInt(item.dataset.index);
const realHeight = item.offsetHeight;
if (this.itemHeights[index] !== realHeight) {
this.itemHeights[index] = realHeight;
isHeightChanged = true;
// 實時更新項內的高度顯示(調試用)
item.querySelector('div:first-child').textContent =
`索引: ${index} | 高度: ${realHeight}px (已校準)`;
}
});
if (isHeightChanged) {
this.calcPrefixHeights();
this.updatePlaceholderHeight();
this.updateVisibleItems();
}
}
// 更新調試信息
updateDebugInfo() {
this.debugElements.totalCount.textContent = this.data.length;
this.debugElements.renderedCount.textContent = this.currentEndIndex - this.currentStartIndex;
this.debugElements.startIndex.textContent = this.currentStartIndex;
this.debugElements.endIndex.textContent = this.currentEndIndex - 1; // 顯示最後一個可見索引
this.debugElements.scrollTop.textContent = Math.round(this.scrollTop);
this.debugElements.estimateHeight.textContent = this.estimateHeight;
this.debugElements.bufferCount.textContent = this.buffer;
this.debugElements.maxCacheSize.textContent = this.maxCacheSize;
// 同步輸入框默認值(顯示當前配置)
this.configElements.customTotal.placeholder = this.data.length;
this.configElements.customBuffer.placeholder = this.buffer;
this.configElements.customMaxCacheSize.placeholder = this.maxCacheSize;
this.configElements.customEstimate.placeholder = this.estimateHeight;
}
// 綁定基礎事件(滾動、resize等)
bindEvents() {
// 滾動事件(添加防抖,優化性能)
let scrollTimer = null;
this.container.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
this.scrollTop = this.container.scrollTop;
this.updateVisibleItems();
}, 10); // 10ms防抖
});
// 窗口resize
window.addEventListener('resize', () => {
this.containerHeight = this.container.clientHeight;
this.updateVisibleItems();
});
// 圖片加載完成後校準高度(如果項內有圖片)
this.content.addEventListener('load', (e) => {
if (e.target.tagName === 'IMG') {
this.calibrateHeights();
}
}, true);
}
// 綁定配置相關事件
bindConfigEvents() {
// 應用配置按鈕點擊事件
this.configElements.applyBtn.addEventListener('click', () => {
this.applyCustomConfig();
});
// 輸入框回車觸發應用配置
[this.configElements.customTotal, this.configElements.customBuffer, this.configElements.customEstimate, this.configElements.customMaxCacheSize]
.forEach(input => {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.applyCustomConfig();
}
});
});
}
// 應用自定義配置
applyCustomConfig() {
const customTotal = this.configElements.customTotal.value.trim();
const customBuffer = this.configElements.customBuffer.value.trim();
const customEstimate = this.configElements.customEstimate.value.trim();
const customMaxCacheSize = this.configElements.customMaxCacheSize.value.trim();
const errorEl = this.configElements.configError;
// 驗證輸入
let errorMsg = '';
let newTotal = this.data.length;
let newBuffer = this.buffer;
let newEstimate = this.estimateHeight;
let newMaxCacheSize = this.maxCacheSize;
// 驗證總數
if (customTotal) {
const num = parseInt(customTotal);
if (isNaN(num) || num < 1 || num > 100000) {
errorMsg = '列表總數必須是1-100000的數字';
} else {
newTotal = num;
}
}
// 驗證緩衝數(如果輸入了)
if (!errorMsg && customBuffer) {
const num = parseInt(customBuffer);
if (isNaN(num) || num < 0 || num > 10) {
errorMsg = '緩衝數必須是0-10的數字';
} else {
newBuffer = num;
}
}
// 驗證預估高度(如果輸入了)
if (!errorMsg && customEstimate) {
const num = parseInt(customEstimate);
if (isNaN(num) || num < 20 || num > 500) {
errorMsg = '預估高度必須是20-500的數字';
} else {
newEstimate = num;
}
}
// 驗證最大緩存數(如果輸入了)
if (!errorMsg && customMaxCacheSize) {
const num = parseInt(customMaxCacheSize);
if (isNaN(num) || num < 0 || num > 200) {
errorMsg = '最大緩存列表項數必須是0-200的數字';
} else {
newMaxCacheSize = num;
}
}
// 處理錯誤
if (errorMsg) {
errorEl.textContent = errorMsg;
errorEl.style.color = '#fc5430';
setTimeout(() => {
errorEl.textContent = '';
}, 3000);
return;
}
// 生成新數據(如果總數變化)
let newData = this.data;
if (newTotal !== this.data.length) {
newData = generateMockData(newTotal);
}
// 更新配置和數據
this.updateConfig({
buffer: newBuffer,
estimateHeight: newEstimate,
maxCacheSize: newMaxCacheSize
});
this.updateData(newData);
// 清空輸入框
this.configElements.customTotal.value = '';
this.configElements.customBuffer.value = '';
this.configElements.customEstimate.value = '';
this.configElements.customMaxCacheSize.value = '';
// 提示成功
errorEl.textContent = '配置應用成功!';
errorEl.style.color = '#10b981';
setTimeout(() => {
errorEl.textContent = '';
}, 2000);
}
// 外部API:更新數據
updateData(newData) {
this.data = newData;
this.itemHeights = new Array(this.data.length).fill(this.estimateHeight);
this.prefixHeights = [0];
this.cacheElements = [];
this.cacheElementsRecord = [];
this.calcPrefixHeights();
this.updatePlaceholderHeight();
this.updateVisibleItems();
}
// 外部API:修改配置
updateConfig(config) {
if (config.estimateHeight) this.estimateHeight = config.estimateHeight;
if (config.buffer !== undefined) this.buffer = config.buffer;
if (config.maxCacheSize !== undefined) this.maxCacheSize = config.maxCacheSize;
this.itemHeights = new Array(this.data.length).fill(this.estimateHeight);
this.prefixHeights = [0];
this.cacheElements = [];
this.cacheElementsRecord = [];
this.calcPrefixHeights();
this.updatePlaceholderHeight();
this.updateVisibleItems();
}
reset() {
this.updateConfig({
estimateHeight: this.defaultEstimateHeight,
buffer: this.defaultBuffer,
maxCacheSize: this.defaultMaxCacheSize
});
this.updateData(generateMockData(this.defaultTotal));
}
}
// ---------------- 測試數據生成 ----------------
function generateMockData(count = 1000) {
// 隨機內容長度,模擬不同高度
const contentLengths = [1, 2, 3, 4, 5, 6, 8, 10];
return Array.from({ length: count }, (_, i) => {
const length = contentLengths[Math.floor(Math.random() * contentLengths.length)];
return {
content: `可變高度列表項 ${i + 1}
${'—— 測試內容重複'.repeat(length)}
${Math.random() > 0.7 ? '<br><img src="https://picsum.photos/200/80?random=' + i + '" style="max-width:100%;border-radius:4px;margin-top:8px;" alt="測試圖">' : ''}`
};
});
}
// ---------------- 初始化 + 調試控制 ----------------
const initialData = generateMockData(1000);
const virtualList = new VariableHeightVirtualList({
container: document.querySelector('.virtual-list-container'),
data: initialData,
estimateHeight: 80,
buffer: 2,
maxCacheSize: 10
});
// 刷新數據按鈕
document.getElementById('refresh-data').addEventListener('click', () => {
const currentTotal = virtualList.data.length;
const newData = generateMockData(currentTotal);
virtualList.updateData(newData);
alert(`已刷新數據,當前共 ${currentTotal} 條`);
});
// 重置按鈕
document.getElementById('reset').addEventListener('click', () => {
virtualList.reset();
alert(`已重置默認配置:總數=${virtualList.defaultTotal},預估高度=${virtualList.defaultEstimateHeight}px,緩衝項=${virtualList.defaultBuffer},最大緩存列表項=${virtualList.defaultMaxCacheSize}`);
});
</script>
</body>
</html>
一、先搞懂:虛擬列表的核心邏輯與分類
在寫一行代碼前,先理清虛擬列表的底層邏輯 —— 這是避免後續 “越寫越亂” 的關鍵。
1.1 虛擬列表的 3 個核心問題
不管是固定高度還是可變高度,所有虛擬列表都要解決 3 個核心問題,Demo 也不例外:
- 範圍確定:滾動時,如何精準計算 “哪些列表項在可視區域內”?
比如可視區域高度 500px,列表項高度 100px,就需要知道當前該顯示第 3-7 項。 - 平滑滾動:只渲染部分項,如何讓用户感覺是在滾動 “完整列表”?
不能讓用户看到 “跳着走” 的卡頓感,需要通過定位模擬完整滾動效果。 - 高度適配:列表項高度不固定時(如含圖片、富文本),如何避免定位錯位?
這是最複雜的問題 —— Demo 正是針對這個場景設計的。
1.2 虛擬列表的 2 種核心分類
根據列表項高度是否固定,虛擬列表可分為兩類,適用場景天差地別:
| 類型 | 核心特點 | 實現難度 | 適用場景 |
|---|---|---|---|
| 固定高度虛擬列表 | 所有項高度一致,可視範圍可通過公式直接計算 | 低 | 表格數據、固定卡片(如商品列表) |
| 可變高度虛擬列表 | 項高度動態變化,需預估 + 校準真實高度 | 高 | 評論列表、富文本內容、含圖片列表 |
Demo 屬於 “可變高度虛擬列表”—— 這也是實際業務中最常用、最能體現技術深度的類型。接下來,我們就以 Demo 為藍本,拆解它的實現邏輯。
二、原理拆解:可變高度虛擬列表的 5 步實現(基於Demo)
Demo 把可變高度虛擬列表的實現拆解成了 5 個環環相扣的步驟,每個步驟都對應解決一個核心問題。我們一步步來看:
2.1 步驟 1:初始化配置與核心數據定義
一切從VariableHeightVirtualList類的構造函數開始 —— 這裏定義了整個虛擬列表的 “骨架”,Demo 在這一步做了很靈活的配置化設計:
constructor(options) {
// 1. 外部可配置參數(靈活適配不同業務)
this.container = options.container; // 虛擬列表容器(可視區域DOM)
this.data = options.data; // 完整列表數據(萬級/十萬級)
this.estimateHeight = options.estimateHeight || 80; // 預估高度(默認80px)
this.buffer = options.buffer || 2; // 緩衝項數量(避免滾動空白)
this.maxCacheSize = options.maxCacheSize || 100; // 最大DOM緩存數(防內存溢出)
// 2. 高度相關核心數據(解決可變高度的關鍵)
this.itemHeights = new Array(this.data.length).fill(this.estimateHeight); // 存儲真實高度
this.prefixHeights = [0]; // 高度前綴和:prefixHeights[i] = 前i項總高度
this.containerHeight = this.container.clientHeight; // 可視區域高度
this.scrollTop = 0; // 當前滾動位置(px)
// 3. 可視區域範圍數據
this.currentStartIndex = 0; // 可視區域起始項索引
this.currentEndIndex = 0; // 可視區域結束項索引
// 4. DOM緩存(性能優化:複用已渲染DOM,減少重排)
this.cacheElements = []; // 緩存DOM元素的數組
this.cacheElementsRecord = []; // 記錄緩存的索引,控制緩存大小
}
這一步有 3 個 “靈魂數據”,直接決定了後續能否處理可變高度:
estimateHeight(預估高度):初始化時不知道真實高度,先假設一個值(如 80px),用於計算初始的可視範圍和列表總高度。itemHeights(真實高度數組):長度和列表數據一致,初始化時用預估高度填充,後續會通過 DOM 實際高度校準。prefixHeights(高度前綴和):比如prefixHeights[3]= 前 3 項總高度,通過它能快速定位滾動位置對應的列表項(後面會詳細説)。
2.2 步驟 2:計算高度前綴和(快速定位的核心)
前綴和數組prefixHeights是虛擬列表的 “導航地圖”—— 沒有它,就無法快速找到滾動位置對應的列表項。Demo 裏用calcPrefixHeights方法實現:
// 計算前綴和:prefixHeights[i+1] = prefixHeights[i] + itemHeights[i]
calcPrefixHeights() {
for (let i = 0; i < this.data.length; i++) {
this.prefixHeights[i + 1] = this.prefixHeights[i] + this.itemHeights[i];
}
// 更新列表總高度(用於佔位,讓滾動條長度正確)
this.updatePlaceholderHeight();
}
// 更新佔位容器高度(模擬完整列表高度)
updatePlaceholderHeight() {
const totalHeight = this.prefixHeights[this.data.length];
this.placeholder.style.height = `${totalHeight}px`;
}
舉個具體例子理解:
如果有 3 個列表項,真實高度分別是 80px、120px、100px,那麼:
prefixHeights = [0, 80, 200, 300]- 第 2 項(索引 1)的頂部位置 =
prefixHeights[1] = 80px - 第 2 項的底部位置 =
prefixHeights[2] = 200px - 列表總高度 =
prefixHeights[3] = 300px
有了這個數組,後續不管滾動到哪個位置,都能快速找到對應的列表項。
2.3 步驟 3:確定可視區域範圍(滾動時的 “導航”)
當用户滾動列表時,第一步要做的就是 “確定當前該顯示哪些項”—— 這需要兩個關鍵方法:findStartIndex(找起始項)和findEndIndex(找結束項)。
2.3.1 用二分查找找起始項(性能優化)
起始項是 “當前滾動位置對應的第一個可見項”。如果直接遍歷前綴和數組,十萬級數據會很慢,Demo 用了二分查找,把時間複雜度從 O (n) 降到 O (log n):
// 二分查找:找到scrollTop對應的起始項索引
findStartIndex() {
const scrollTop = this.scrollTop;
let low = 0, high = this.prefixHeights.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
// 如果mid項的總高度 <= 滾動位置,説明起始項在mid右邊
if (this.prefixHeights[mid] <= scrollTop) {
low = mid + 1;
} else {
// 否則在mid左邊
high = mid - 1;
}
}
// low-1就是第一個頂部位置<=scrollTop的項(起始項)
return Math.max(0, low - 1);
}
還是用前面的例子:如果滾動位置scrollTop = 150px,二分查找會發現:
prefixHeights[1] = 80px ≤ 150pxprefixHeights[2] = 200px > 150px
所以起始項索引是1(第 2 項)—— 精準且高效。
2.3.2 計算結束項(加緩衝防空白)
結束項是 “可視區域最後一個可見項”,Demo 還加了buffer(緩衝項)—— 這是避免滾動空白的關鍵:
// 計算結束項:從起始項開始,找到超過滾動底部的項
findEndIndex(startIndex) {
const scrollBottom = this.scrollTop + this.containerHeight; // 可視區域底部位置
let endIndex = startIndex;
// 找到第一個底部位置>scrollBottom的項
while (endIndex < this.data.length && this.prefixHeights[endIndex + 1] <= scrollBottom) {
endIndex++;
}
// 加緩衝項(比如buffer=2,就多渲染前後2項)
endIndex = Math.min(this.data.length, endIndex + this.buffer);
return endIndex;
}
比如buffer=2,即使用户快速滾動,也會提前渲染 2 個 “備用項”,不會因為渲染不及時出現空白 —— 這是很多新手實現虛擬列表時容易忽略的優化點。
2.4 步驟 4:渲染可視區域項 + 滾動定位
確定了起始和結束項,就可以渲染這部分列表項了。Demo 在這裏做了兩個關鍵優化:DOM 緩存複用和transform定位。
// 更新可視區域渲染內容
updateVisibleItems() {
// 1. 先算當前可視範圍
this.currentStartIndex = this.findStartIndex();
this.currentEndIndex = this.findEndIndex(this.currentStartIndex);
// 2. 取可視區域的數據
const visibleData = this.data.slice(this.currentStartIndex, this.currentEndIndex);
// 3. 渲染可視項(複用緩存DOM,減少重排)
this.content.innerHTML = ''; // 清空內容區(但緩存還在)
visibleData.forEach((item, idx) => {
const realIndex = this.currentStartIndex + idx; // 真實數據索引
// 優化1:複用已緩存的DOM,不用重新創建
if (this.cacheElements[realIndex]) {
this.content.appendChild(this.cacheElements[realIndex]);
return;
}
// 優化2:未緩存則創建新DOM,並加入緩存
const element = document.createElement('div');
element.className = 'virtual-list-item';
element.dataset.index = realIndex; // 記錄真實索引,後續校準高度用
element.innerHTML = `
<div>索引: ${realIndex} | 高度: ${this.itemHeights[realIndex]}px</div>
<div>${item.content}</div>
`;
// 加入緩存,控制緩存大小(防內存溢出)
this.cacheElements[realIndex] = element;
this.cacheElementsRecord.push(realIndex);
if (this.cacheElementsRecord.length > this.maxCacheSize) {
// 緩存超限時,刪除最早的緩存項
const oldIndex = this.cacheElementsRecord.shift();
delete this.cacheElements[oldIndex];
}
this.content.appendChild(element);
});
// 4. 定位內容區:用transform模擬滾動(比top性能好,不觸發重排)
const offsetTop = this.prefixHeights[this.currentStartIndex];
this.content.style.transform = `translateY(${offsetTop}px)`;
// 5. 關鍵步驟:校準真實高度(解決可變高度問題)
this.calibrateHeights();
}
這裏有兩個必須注意的細節:
- DOM 緩存複用:避免滾動時反覆創建 / 銷燬 DOM—— 這是性能優化的核心,Demo 還通過
maxCacheSize控制緩存大小,防止內存溢出。 transform定位:用translateY代替top定位,因為transform屬於 “合成層操作”,不會觸發瀏覽器重排,滾動更流暢。
2.5 步驟 5:校準真實高度(可變高度的 “靈魂”)
前面用了預估高度,但實際列表項高度可能和預估不同(比如圖片加載後高度增加)。Demo 用calibrateHeights方法校準真實高度,這是解決可變高度的關鍵:
// 校準真實高度:用DOM實際高度更新數據
calibrateHeights() {
const items = this.content.querySelectorAll('.virtual-list-item');
let isHeightChanged = false; // 標記高度是否有變化
items.forEach(item => {
const realIndex = parseInt(item.dataset.index);
const realHeight = item.offsetHeight; // 獲取DOM真實高度
// 如果真實高度和記錄的不一致,更新數據
if (this.itemHeights[realIndex] !== realHeight) {
this.itemHeights[realIndex] = realHeight;
isHeightChanged = true;
// 實時更新項內的高度顯示(調試友好)
item.querySelector('div:first-child').textContent =
`索引: ${realIndex} | 高度: ${realHeight}px (已校準)`;
}
});
// 高度變化後,重新計算前綴和和列表總高度
if (isHeightChanged) {
this.calcPrefixHeights();
this.updateVisibleItems(); // 重新渲染,確保定位準確
}
}
比如預估高度 80px,實際 DOM 高度 120px—— 校準後,itemHeights數組會更新為 120px,前綴和也會重新計算,後續滾動定位就不會錯位了。Demo 還在項內實時顯示校準後的高度,非常方便調試。
三、實戰亮點:Demo 做對了這些事
所提供的 “可變高度虛擬列表(可配置版)”Demo,不只是實現了核心功能,還加了很多貼近業務的設計,這些細節讓它能直接落地到項目中:
3.1 全配置化設計(靈活適配業務)
你把預估高度、緩衝項數量、最大緩存數等關鍵參數都做成了外部可配置:
// 初始化時可自定義所有核心參數
const virtualList = new VariableHeightVirtualList({
container: document.querySelector('.virtual-list-container'),
data: initialData, // 業務數據
estimateHeight: 100, // 按業務調整預估高度
buffer: 3, // 緩衝項3個,更流暢
maxCacheSize: 150 // 緩存150個DOM,平衡性能和內存
});
// 還支持運行時更新配置
virtualList.updateConfig({
estimateHeight: 120,
buffer: 2
});
這種設計讓虛擬列表能適配不同業務場景 —— 比如商品列表用 80px 預估高度,評論列表用 120px,不用修改核心代碼。
3.2 調試面板(開發友好)
Demo 右側加了調試面板,實時顯示總項數、已渲染項數、可視範圍、滾動位置等核心數據:
- 開發時能直觀看到 “可視範圍是否正確”“渲染項數是否合理”;
- 測試時能快速定位問題 —— 比如滾動時起始索引是否跳變,高度校準是否生效。
這是很多開源虛擬列表庫都沒有的細節,對開發和調試太友好了。
3.3 圖片加載後重新校準(解決實際痛點)
列表項含圖片時,圖片加載後高度會變化 —— Demo 考慮到了這個場景,加了圖片加載監聽:
// 監聽圖片加載,重新校準高度
listenImageLoad() {
this.content.addEventListener('load', (e) => {
if (e.target.tagName === 'IMG') {
this.calibrateHeights(); // 圖片加載後重新校準
}
}, true);
}
這一個小細節,就避免了 “圖片加載後列表錯位” 的常見問題 —— 很多新手實現的虛擬列表,就是因為沒處理這個場景,導致上線後出現 bug。
四、避坑指南:虛擬列表落地的 6 個高頻問題
結合Demo 和實際業務經驗,總結了 6 個最容易踩的坑,每個坑都有對應的解決方案:
4.1 坑點 1:滾動時出現空白區域
原因:緩衝項數量不足,或預估高度與真實高度偏差太大。
解決方案(Demo 已實現):
- 緩衝項
buffer設為 2-3(根據滾動速度調整); - 預估高度儘量貼近真實高度(比如按業務數據統計平均高度);
- 圖片加載後重新校準高度。
4.2 坑點 2:滾動定位錯位(項的位置不對)
原因:沒及時校準真實高度,或前綴和計算錯誤。
解決方案:
- 渲染完成後必須調用
calibrateHeights; - 檢查前綴和計算邏輯:確保
prefixHeights[i+1] = prefixHeights[i] + itemHeights[i]; - 避免在滾動事件中做耗時操作,導致校準延遲。
4.3 坑點 3:DOM 緩存導致內存溢出
原因:緩存的 DOM 數量太多,尤其是十萬級數據時。
解決方案(Demo 已實現):
- 用
maxCacheSize控制緩存大小(建議 100-200,根據項複雜度調整); - 緩存超限時,刪除最早的緩存項(
cacheElementsRecord記錄索引,先進先出)。
4.4 坑點 4:滾動卡頓(不流暢)
原因:滾動事件觸發太頻繁,或渲染邏輯太重。
解決方案:
- 給滾動事件加 10-20ms 防抖(Demo 用了 10ms);
- 用
transform代替top定位(避免重排); - 減少列表項內的 DOM 嵌套(越簡單越好)。
4.5 坑點 5:初始化時滾動條長度不對
原因:用預估高度計算的列表總高度,和真實總高度偏差太大。
解決方案(Demo 已實現):
- 用
placeholder(佔位容器)顯示列表總高度; - 高度校準後,及時更新
placeholder的高度(updatePlaceholderHeight)。
4.6 坑點 6:列表項點擊事件錯位
原因:DOM 複用後,事件綁定的索引沒更新。
解決方案:
- 給每個列表項加
data-index記錄真實索引(Demo 已做); - 點擊事件中通過
e.target.closest('.virtual-list-item').dataset.index獲取真實索引,不要依賴循環變量。
五、落地建議:從 Demo 到生產環境
Demo 已經實現了核心功能,要落地到項目中,還需要補充這些細節:
5.1 兼容性處理
- 低版本瀏覽器:
offsetHeight、transform在 IE11 中可用,但forEach、slice等方法需要 polyfill; - 移動端:監聽
touchmove事件(配合touchend),避免滾動延遲。
5.2 異常場景處理
- 數據為空:顯示 “暫無數據” 佔位,不要渲染空列表;
- 數據加載中:加加載動畫,避免用户以為 “列表沒出來”;
- 數據更新:數據變化後,重置
itemHeights和prefixHeights,重新初始化。
5.3 性能測試
在不同場景下測試性能,確保滿足業務需求:
- 數據量測試:分別測試 1 萬、5 萬、10 萬條數據的滾動流暢度;
- 設備測試:在低端安卓機、iPhone 舊機型上測試,避免性能瓶頸;
- 內存測試:滾動 10 分鐘後,通過 Chrome DevTools 查看內存佔用,確保無泄漏。
六、總結:虛擬列表不是銀彈,但能解決大問題
虛擬列表的核心價值是 “解決長列表的性能問題”,但它不是萬能的:
- 適合場景:數據量≥1000 條、列表項高度不固定、對滾動流暢度要求高;
- 不適合場景:數據量≤500 條(直接渲染更簡單,沒必要用虛擬列表)。
提供的“可變高度虛擬列表(可配置版)”Demo,已經覆蓋了虛擬列表的核心難點:可變高度校準、DOM 緩存複用、緩衝防空白,再補充一些兼容性和異常處理,就能直接落地到生產環境。
最後記住:虛擬列表的本質是 “取捨”—— 用少量計算成本,換取 DOM 數量的大幅減少。理解了這個核心,不管遇到什麼業務場景,都能靈活調整實現方案。總而言之,一鍵點贊、評論、喜歡加收藏吧!這對我很重要!