高性能虚拟列表实现:从原理到生产级组件
10 万条数据的列表,直接渲染?DOM 节点撑爆内存,滚动卡成 PPT。这不是夸张——一个普通的列表项哪怕只有 3 个 DOM 节点,10 万条就是 30 万个 DOM 节点,任何浏览器都扛不住。
虚拟列表(Virtual List / Virtualized List)是解决长列表性能问题的核心技术。它的原理简单而优雅:只渲染可视区域内的元素。但从原理到生产级实现,中间有大量的工程细节需要处理。
本文将从零开始,手写定高虚拟列表、不定高虚拟列表、动态测量方案、滚动锚定和无限滚动,一步步构建一个完整的生产级虚拟列表组件。
一、虚拟列表的基本原理
1.1 核心思路
┌─────────────────────────┐│ 不可见区域(上方) │ ← 不渲染,用 padding/transform 占位├─────────────────────────┤│ ││ 可视区域(viewport) │ ← 只渲染这些元素│ │├─────────────────────────┤│ 不可见区域(下方) │ ← 不渲染,用 padding/transform 占位└─────────────────────────┘三个关键值:
- scrollTop:当前滚动位置
- startIndex:可视区域第一个元素的索引
- endIndex:可视区域最后一个元素的索引
1.2 数学关系(定高情况)
// 假设每项高度为 itemHeight,容器高度为 containerHeightconst startIndex = Math.floor(scrollTop / itemHeight);const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);const visibleCount = endIndex - startIndex;
// 上方占位高度const offsetTop = startIndex * itemHeight;// 总列表高度const totalHeight = totalCount * itemHeight;二、定高虚拟列表:最简实现
先从最简单的场景开始——每个列表项高度固定。
2.1 HTML 结构
<div id="virtual-list" style="height: 600px; overflow: auto;"> <div id="phantom" style="position: relative;"> <!-- 实际渲染的列表项 --> </div></div>2.2 完整实现
class FixedHeightVirtualList { constructor(container, options) { this.container = container; this.itemHeight = options.itemHeight; this.renderItem = options.renderItem; this.data = options.data || []; this.overscan = options.overscan || 5; // 上下各多渲染几个,减少白屏
// 创建 DOM 结构 this.phantom = document.createElement('div'); this.phantom.style.position = 'relative'; this.container.appendChild(this.phantom);
this.contentWrapper = document.createElement('div'); this.contentWrapper.style.position = 'absolute'; this.contentWrapper.style.top = '0'; this.contentWrapper.style.left = '0'; this.contentWrapper.style.width = '100%'; this.phantom.appendChild(this.contentWrapper);
// 状态 this.startIndex = 0; this.endIndex = 0; this.visibleItems = [];
// 绑定事件 this._onScroll = this._onScroll.bind(this); this.container.addEventListener('scroll', this._onScroll, { passive: true });
this._update(); }
setData(data) { this.data = data; this._update(); }
_onScroll() { this._update(); }
_update() { const scrollTop = this.container.scrollTop; const containerHeight = this.container.clientHeight; const totalCount = this.data.length;
// 计算可见范围 let startIndex = Math.floor(scrollTop / this.itemHeight) - this.overscan; let endIndex = Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.overscan;
startIndex = Math.max(0, startIndex); endIndex = Math.min(totalCount, endIndex);
// 如果范围没变,跳过渲染 if (startIndex === this.startIndex && endIndex === this.endIndex) return;
this.startIndex = startIndex; this.endIndex = endIndex;
// 设置总高度(撑开滚动条) const totalHeight = totalCount * this.itemHeight; this.phantom.style.height = totalHeight + 'px';
// 设置内容偏移 const offsetTop = startIndex * this.itemHeight; this.contentWrapper.style.transform = `translateY(${offsetTop}px)`;
// 渲染可见项 this._renderItems(); }
_renderItems() { // 清空旧内容 this.contentWrapper.innerHTML = '';
const fragment = document.createDocumentFragment(); for (let i = this.startIndex; i < this.endIndex; i++) { const item = this.renderItem(this.data[i], i); item.style.height = this.itemHeight + 'px'; item.style.boxSizing = 'border-box'; fragment.appendChild(item); } this.contentWrapper.appendChild(fragment); }
destroy() { this.container.removeEventListener('scroll', this._onScroll); this.phantom.remove(); }}
// 使用const container = document.getElementById('virtual-list');const data = Array.from({ length: 100000 }, (_, i) => ({ id: i, text: `Item #${i} — ${Math.random().toString(36).slice(2)}`,}));
const list = new FixedHeightVirtualList(container, { itemHeight: 50, overscan: 5, data, renderItem(item, index) { const div = document.createElement('div'); div.className = 'list-item'; div.textContent = item.text; div.style.borderBottom = '1px solid #eee'; div.style.padding = '12px 16px'; div.style.lineHeight = '26px'; return div; },});2.3 性能数据
10 万条数据,定高虚拟列表 vs 直接渲染:
直接渲染: - DOM 节点数:~300,000 - 首次渲染时间:4200ms - 内存占用:~180MB - 滚动帧率:8-15fps
虚拟列表: - DOM 节点数:~40(视口内 + overscan) - 首次渲染时间:12ms - 内存占用:~15MB - 滚动帧率:60fps三、不定高虚拟列表:真正的挑战
真实场景中,列表项的高度几乎不可能完全一致。评论列表、聊天消息、Feed 流……每项内容不同,高度自然不同。
3.1 核心难点
- 总高度未知:没渲染就不知道每项的实际高度
- 无法用除法快速定位 startIndex:因为每项高度不同
- 滚动位置映射复杂:scrollTop → index 需要累加计算
3.2 解决思路:预估 + 动态修正
class DynamicHeightVirtualList { constructor(container, options) { this.container = container; this.renderItem = options.renderItem; this.data = options.data || []; this.estimatedHeight = options.estimatedHeight || 80; // 预估高度 this.overscan = options.overscan || 5;
// 缓存每项的实际高度和位置 // positions[i] = { top, bottom, height } this.positions = []; this._initPositions();
// DOM 结构 this.phantom = document.createElement('div'); this.phantom.style.position = 'relative'; this.container.appendChild(this.phantom);
this.contentWrapper = document.createElement('div'); this.contentWrapper.style.position = 'absolute'; this.contentWrapper.style.top = '0'; this.contentWrapper.style.left = '0'; this.contentWrapper.style.width = '100%'; this.phantom.appendChild(this.contentWrapper);
// 状态 this.startIndex = 0; this.endIndex = 0;
// 绑定事件 this._onScroll = this._onScroll.bind(this); this.container.addEventListener('scroll', this._onScroll, { passive: true });
this._update(); }
_initPositions() { this.positions = this.data.map((_, index) => ({ index, height: this.estimatedHeight, top: index * this.estimatedHeight, bottom: (index + 1) * this.estimatedHeight, })); }
// 二分搜索:根据 scrollTop 找到 startIndex _binarySearch(scrollTop) { let low = 0; let high = this.positions.length - 1; let result = 0;
while (low <= high) { const mid = Math.floor((low + high) / 2); const midBottom = this.positions[mid].bottom;
if (midBottom === scrollTop) { return mid + 1; } else if (midBottom < scrollTop) { low = mid + 1; } else { // midBottom > scrollTop result = mid; high = mid - 1; } } return result; }
// 渲染后更新实际高度 _updatePositions() { const items = this.contentWrapper.children;
for (let i = 0; i < items.length; i++) { const item = items[i]; const index = this.startIndex + i; if (index >= this.positions.length) break;
const rect = item.getBoundingClientRect(); const actualHeight = rect.height; const oldHeight = this.positions[index].height;
// 只在高度变化时更新 if (Math.abs(actualHeight - oldHeight) > 0.5) { const diff = actualHeight - oldHeight; this.positions[index].height = actualHeight; this.positions[index].bottom += diff;
// 更新后续所有项的位置 for (let j = index + 1; j < this.positions.length; j++) { this.positions[j].top = this.positions[j - 1].bottom; this.positions[j].bottom = this.positions[j].top + this.positions[j].height; } } } }
_getTotalHeight() { const last = this.positions[this.positions.length - 1]; return last ? last.bottom : 0; }
_onScroll() { this._update(); }
_update() { const scrollTop = this.container.scrollTop; const containerHeight = this.container.clientHeight;
// 二分查找 startIndex let startIndex = this._binarySearch(scrollTop) - this.overscan; startIndex = Math.max(0, startIndex);
// 从 startIndex 开始,累加高度找到 endIndex let endIndex = startIndex; let accHeight = this.positions[startIndex] ? this.positions[startIndex].top - scrollTop : 0;
while (endIndex < this.data.length && accHeight < containerHeight) { accHeight += this.positions[endIndex].height; endIndex++; } endIndex = Math.min(this.data.length, endIndex + this.overscan);
this.startIndex = startIndex; this.endIndex = endIndex;
// 更新 phantom 总高度 this.phantom.style.height = this._getTotalHeight() + 'px';
// 偏移 const offsetTop = this.positions[startIndex] ? this.positions[startIndex].top : 0; this.contentWrapper.style.transform = `translateY(${offsetTop}px)`;
// 渲染 this._renderItems();
// 渲染后测量实际高度并更新 requestAnimationFrame(() => { this._updatePositions(); // 重新更新 phantom 高度(可能因为实际高度和预估不同而变化) this.phantom.style.height = this._getTotalHeight() + 'px'; }); }
_renderItems() { this.contentWrapper.innerHTML = ''; const fragment = document.createDocumentFragment();
for (let i = this.startIndex; i < this.endIndex; i++) { const item = this.renderItem(this.data[i], i); item.dataset.index = i; fragment.appendChild(item); }
this.contentWrapper.appendChild(fragment); }
// 滚动到指定索引 scrollToIndex(index) { if (index >= 0 && index < this.positions.length) { this.container.scrollTop = this.positions[index].top; } }
destroy() { this.container.removeEventListener('scroll', this._onScroll); this.phantom.remove(); }}3.3 优化:减少 _updatePositions 的开销
上面的实现在更新位置时,需要遍历后续所有项。对于 10 万条数据,这个 O(n) 的操作会很慢。可以用前缀和数组或线段树来优化。
// 使用前缀和数组优化位置查询class PrefixSumPositionCache { constructor(estimatedHeight, count) { this.heights = new Float64Array(count).fill(estimatedHeight); this.prefixSum = new Float64Array(count + 1); this._rebuildPrefixSum(); }
_rebuildPrefixSum() { this.prefixSum[0] = 0; for (let i = 0; i < this.heights.length; i++) { this.prefixSum[i + 1] = this.prefixSum[i] + this.heights[i]; } }
// 更新某项高度 —— O(n),但只在高度变化时调用 updateHeight(index, newHeight) { const oldHeight = this.heights[index]; if (Math.abs(newHeight - oldHeight) < 0.5) return false;
const diff = newHeight - oldHeight; this.heights[index] = newHeight;
// 只更新 index 之后的前缀和 for (let i = index + 1; i <= this.heights.length; i++) { this.prefixSum[i] += diff; } return true; }
// 获取某项的 top 位置 —— O(1) getTop(index) { return this.prefixSum[index]; }
// 获取某项的 bottom 位置 —— O(1) getBottom(index) { return this.prefixSum[index + 1]; }
// 总高度 —— O(1) getTotalHeight() { return this.prefixSum[this.heights.length]; }
// 二分查找 scrollTop 对应的 index —— O(log n) findIndex(scrollTop) { let low = 0; let high = this.heights.length - 1; let result = 0;
while (low <= high) { const mid = (low + high) >>> 1; if (this.prefixSum[mid + 1] <= scrollTop) { low = mid + 1; } else { result = mid; high = mid - 1; } } return result; }}四、动态测量:ResizeObserver 方案
上面的方案在 requestAnimationFrame 中手动测量高度,存在一个问题:如果列表项内部有异步内容(图片加载、文本展开),高度变化后不会自动更新。
更好的方案是使用 ResizeObserver:
class ResizeObserverVirtualList extends DynamicHeightVirtualList { constructor(container, options) { super(container, options);
// 使用 ResizeObserver 监听每个渲染项的尺寸变化 this.resizeObserver = new ResizeObserver((entries) => { let needsUpdate = false;
for (const entry of entries) { const index = parseInt(entry.target.dataset.index, 10); if (isNaN(index)) continue;
const newHeight = entry.contentBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
if (this.positions[index]) { const oldHeight = this.positions[index].height; if (Math.abs(newHeight - oldHeight) > 0.5) { this.positions[index].height = newHeight; needsUpdate = true; } } }
if (needsUpdate) { this._recalculatePositions(); this.phantom.style.height = this._getTotalHeight() + 'px'; } }); }
_recalculatePositions() { // 从头重新计算所有位置 for (let i = 1; i < this.positions.length; i++) { this.positions[i].top = this.positions[i - 1].bottom; this.positions[i].bottom = this.positions[i].top + this.positions[i].height; } }
_renderItems() { // 先解除对旧元素的观察 const oldItems = this.contentWrapper.children; for (const item of oldItems) { this.resizeObserver.unobserve(item); }
// 渲染新元素 super._renderItems();
// 观察新元素 const newItems = this.contentWrapper.children; for (const item of newItems) { this.resizeObserver.observe(item); } }
destroy() { this.resizeObserver.disconnect(); super.destroy(); }}五、滚动锚定(Scroll Anchoring)
不定高虚拟列表有一个常见问题:当上方元素的实际高度与预估高度不同时,滚动位置会发生跳动。
5.1 问题复现
场景:1. 用户滚动到第 100 项2. 此时第 1-50 项的图片加载完成,实际高度比预估高了 200px3. 整个列表的总高度增加了 200px4. 用户看到的内容突然"跳"了一下5.2 滚动锚定实现
class AnchoredVirtualList extends ResizeObserverVirtualList { constructor(container, options) { super(container, options); this._lastScrollTop = 0; this._anchorIndex = 0; this._anchorOffset = 0; }
_onScroll() { const scrollTop = this.container.scrollTop;
// 记录锚点:当前视口顶部对应的元素和偏移 const anchorIndex = this._binarySearch(scrollTop); const anchorTop = this.positions[anchorIndex]?.top || 0; this._anchorIndex = anchorIndex; this._anchorOffset = scrollTop - anchorTop; this._lastScrollTop = scrollTop;
this._update(); }
// 当上方元素高度变化时,修正滚动位置 _recalculatePositions() { const oldAnchorTop = this.positions[this._anchorIndex]?.top || 0;
super._recalculatePositions();
const newAnchorTop = this.positions[this._anchorIndex]?.top || 0; const diff = newAnchorTop - oldAnchorTop;
if (Math.abs(diff) > 1) { // 修正滚动位置,保持锚点元素在视口中的相对位置不变 this.container.scrollTop += diff; this._lastScrollTop = this.container.scrollTop; } }}5.3 CSS 原生滚动锚定
现代浏览器也内置了滚动锚定机制:
/* 启用浏览器原生滚动锚定(默认开启) */.scroll-container { overflow-anchor: auto;}
/* 某些元素不作为锚点 */.ad-banner, .loading-spinner { overflow-anchor: none;}但对于虚拟列表,原生滚动锚定往往不够用(因为元素是动态创建/销毁的),需要手动实现。
六、无限滚动(Infinite Scroll)
虚拟列表通常需要配合无限滚动来实现”滚到底部自动加载更多”的效果。
6.1 基于 IntersectionObserver 的实现
class InfiniteVirtualList extends AnchoredVirtualList { constructor(container, options) { super(container, options); this.loadMore = options.loadMore; // 加载更多数据的回调 this.isLoading = false; this.hasMore = true; this.loadThreshold = options.loadThreshold || 5; // 距离底部多少项时触发
// 创建哨兵元素 this.sentinel = document.createElement('div'); this.sentinel.style.height = '1px'; this.sentinel.className = 'infinite-scroll-sentinel'; this.container.appendChild(this.sentinel);
// 使用 IntersectionObserver 监听哨兵 this.intersectionObserver = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && !this.isLoading && this.hasMore) { this._loadMore(); } }, { root: this.container, rootMargin: '0px 0px 200px 0px', // 提前 200px 触发 } ); this.intersectionObserver.observe(this.sentinel); }
async _loadMore() { this.isLoading = true; this._showLoading();
try { const newItems = await this.loadMore(this.data.length);
if (!newItems || newItems.length === 0) { this.hasMore = false; this._showEndMessage(); return; }
// 追加数据 this.data = [...this.data, ...newItems];
// 初始化新项的位置 const lastPos = this.positions[this.positions.length - 1]; const lastBottom = lastPos ? lastPos.bottom : 0;
for (let i = 0; i < newItems.length; i++) { const index = this.positions.length; this.positions.push({ index, height: this.estimatedHeight, top: lastBottom + i * this.estimatedHeight, bottom: lastBottom + (i + 1) * this.estimatedHeight, }); }
this._update(); } catch (error) { console.error('加载失败:', error); this._showError(); } finally { this.isLoading = false; this._hideLoading(); } }
_showLoading() { this.sentinel.textContent = '加载中...'; this.sentinel.style.height = '40px'; this.sentinel.style.textAlign = 'center'; this.sentinel.style.lineHeight = '40px'; }
_hideLoading() { this.sentinel.textContent = ''; this.sentinel.style.height = '1px'; }
_showEndMessage() { this.sentinel.textContent = '没有更多了'; this.sentinel.style.height = '40px'; this.sentinel.style.textAlign = 'center'; this.sentinel.style.lineHeight = '40px'; this.sentinel.style.color = '#999'; this.intersectionObserver.disconnect(); }
_showError() { this.sentinel.innerHTML = '加载失败,<a href="#">点击重试</a>'; this.sentinel.querySelector('a')?.addEventListener('click', (e) => { e.preventDefault(); this._loadMore(); }); }
destroy() { this.intersectionObserver.disconnect(); this.sentinel.remove(); super.destroy(); }}
// 使用const list = new InfiniteVirtualList(container, { estimatedHeight: 80, overscan: 5, data: initialData, renderItem(item, index) { const div = document.createElement('div'); div.className = 'feed-item'; div.innerHTML = ` <h3>${item.title}</h3> <p>${item.content}</p> <span class="meta">${item.author} · ${item.time}</span> `; return div; }, async loadMore(offset) { const response = await fetch(`/api/feed?offset=${offset}&limit=20`); const data = await response.json(); return data.items; },});七、滚动性能优化技巧
7.1 使用 passive 事件监听
// ✅ passive 告诉浏览器不会调用 preventDefault()// 浏览器可以立即开始滚动,不用等 JS 执行完container.addEventListener('scroll', handler, { passive: true });7.2 使用 will-change 和 contain
.virtual-list-container { overflow: auto; contain: strict; will-change: scroll-position;}
.virtual-list-item { contain: content;}7.3 使用 content-visibility
/* 对于不在视口内但已渲染的 overscan 元素 */.virtual-list-item { content-visibility: auto; contain-intrinsic-size: auto 80px;}7.4 减少 innerHTML 重绘
// ❌ 每次更新都清空重建_renderItems() { this.contentWrapper.innerHTML = ''; // ... 重新创建所有元素}
// ✅ DOM 复用(对象池模式)class DOMPool { constructor(createFn, recycleFn) { this.pool = []; this.createFn = createFn; this.recycleFn = recycleFn; }
acquire() { return this.pool.pop() || this.createFn(); }
release(element) { this.recycleFn(element); this.pool.push(element); }}
// 使用 DOM 复用const domPool = new DOMPool( () => { const div = document.createElement('div'); div.className = 'list-item'; return div; }, (el) => { el.textContent = ''; el.style.cssText = ''; });7.5 使用 requestAnimationFrame 节流
_onScroll() { if (this._rafId) return;
this._rafId = requestAnimationFrame(() => { this._update(); this._rafId = null; });}八、键盘导航与无障碍
虚拟列表容易忽视无障碍(Accessibility)。因为只有部分元素在 DOM 中,屏幕阅读器和键盘导航可能出问题。
class AccessibleVirtualList extends InfiniteVirtualList { constructor(container, options) { super(container, options);
// ARIA 属性 this.container.setAttribute('role', 'listbox'); this.container.setAttribute('aria-label', options.label || '列表'); this.container.setAttribute('tabindex', '0');
// 键盘导航 this._focusedIndex = -1; this.container.addEventListener('keydown', this._onKeyDown.bind(this)); }
_onKeyDown(event) { switch (event.key) { case 'ArrowDown': event.preventDefault(); this._moveFocus(1); break; case 'ArrowUp': event.preventDefault(); this._moveFocus(-1); break; case 'Home': event.preventDefault(); this._setFocus(0); break; case 'End': event.preventDefault(); this._setFocus(this.data.length - 1); break; case 'Enter': case ' ': event.preventDefault(); this._selectItem(this._focusedIndex); break; } }
_moveFocus(delta) { const newIndex = Math.max( 0, Math.min(this.data.length - 1, this._focusedIndex + delta) ); this._setFocus(newIndex); }
_setFocus(index) { this._focusedIndex = index;
// 确保聚焦项可见 this.scrollToIndex(index);
// 更新 ARIA this.container.setAttribute('aria-activedescendant', `item-${index}`);
// 更新视觉焦点 const items = this.contentWrapper.children; for (const item of items) { const itemIndex = parseInt(item.dataset.index, 10); item.classList.toggle('focused', itemIndex === index); item.setAttribute('aria-selected', itemIndex === index ? 'true' : 'false'); } }
_renderItems() { super._renderItems();
// 为每个渲染的项添加 ARIA 属性 const items = this.contentWrapper.children; for (const item of items) { const index = parseInt(item.dataset.index, 10); item.setAttribute('role', 'option'); item.setAttribute('id', `item-${index}`); item.setAttribute( 'aria-selected', index === this._focusedIndex ? 'true' : 'false' ); item.setAttribute('aria-setsize', this.data.length); item.setAttribute('aria-posinset', index + 1); } }}九、测试数据与总结
9.1 完整性能对比
测试环境:Chrome 120, 中端 Android 手机数据量:50,000 条,不定高(60-200px)
| 方案 | DOM 节点 | 首屏渲染 | 滚动帧率 | 内存 ||------|---------|---------|---------|------|| 直接渲染 | 150,000 | 8.5s | 5-10fps | 250MB || 定高虚拟列表 | 45 | 8ms | 60fps | 12MB || 不定高虚拟列表 | 50 | 15ms | 55-60fps | 15MB || 不定高+ResizeObserver | 50 | 16ms | 55-60fps | 16MB || 不定高+锚定+无限滚动 | 50 | 18ms | 52-58fps | 18MB |9.2 什么时候需要虚拟列表?
经验法则:
- < 100 条:不需要,直接渲染
- 100 - 1000 条:可能需要,取决于列表项复杂度
- > 1000 条:几乎一定需要
- > 10000 条:必须使用
9.3 生产级方案选择
如果你不想从零造轮子,可以考虑以下库:
- @tanstack/virtual:框架无关的虚拟滚动核心库,极度灵活
- vue-virtual-scroller:Vue 生态的虚拟列表
- Svelte Virtual List:Svelte 生态方案
但无论用什么库,理解底层原理是最重要的——当遇到库处理不了的边界情况时,你需要知道该在哪里动手。
虚拟列表看似简单,实则是前端工程的一个缩影:从简单的数学关系出发,逐步处理各种真实场景中的复杂问题——不定高度、动态内容、滚动锚定、无限加载、无障碍……每一步都是从”能用”走向”好用”的工程实践。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!