从零实现一个虚拟列表,支持固定高度与动态高度两种场景
在大数据列表渲染场景中,虚拟列表是提升性能的利器。本文将从原理到实践,带你手动实现一个支持固定高度和动态高度的虚拟列表组件。
前言
当页面需要展示成千上万条数据时,如果直接全部渲染到 DOM 中,会导致:
- DOM 节点过多:浏览器渲染压力大,页面卡顿
- 内存占用高:每个 DOM 节点都占用内存
- 滚动性能差:大量节点的重排重绘消耗性能
虚拟列表 的核心思想:只渲染可视区域内的元素,通过动态计算和位置定位,实现海量数据的高性能渲染。
核心原理
1. 基本概念
虚拟列表的实现基于以下几个关键点:
scss
┌─────────────────────────────────────┐
│ Container (可视区域) │
│ ┌─────────────────────────────┐ │
│ │ 可见列表项 (实际渲染) │ │
│ │ │ │
│ │ Item 3 │ │
│ │ Item 4 │ │
│ │ Item 5 │ │
│ │ Item 6 │ │
│ │ Item 7 │ │
│ └─────────────────────────────┘ │
│ │
│ ↑ 缓冲区 (预渲染) │
│ ↓ 缓冲区 (预渲染) │
└─────────────────────────────────────┘
│ Phantom (撑开容器) │ ← 总高度 = 所有项高度之和
└─────────────────────────────────────┘
- Container :固定高度的容器,设置
overflow: auto实现滚动 - Phantom:一个占位元素,高度等于所有列表项高度之和,用于撑开滚动条
- Visible Items:只渲染可视区域 + 缓冲区内的列表项
- Buffer:上下缓冲区,防止快速滚动时出现白屏
2. 两种场景对比
| 特性 | 固定高度 | 动态高度 |
|---|---|---|
| 位置计算 | index * itemHeight,O(1) 复杂度 |
需要累积计算,O(n) 复杂度 |
| 实现难度 | 简单 | 较复杂 |
| 适用场景 | 列表项高度一致 | 列表项高度不一致 |
| 性能 | 极高 | 较高(需要缓存和测量) |
实现方案
核心点一:位置计算
固定高度模式
javascript
// 固定高度:直接计算,O(1) 复杂度
function calculatePositions(data, itemHeight) {
return data.map((_, index) => ({
top: index * itemHeight,
height: itemHeight
}));
}
动态高度模式
javascript
// 动态高度:需要累积计算
function calculatePositions(data, heightCache, estimateHeight) {
const positions = [];
let currentTop = 0;
for (let i = 0; i < data.length; i++) {
// 优先使用已测量的高度,否则使用预估高度
const height = heightCache.get(i) ?? estimateHeight(data[i], i);
positions.push({
top: currentTop,
height
});
currentTop += height;
}
return positions;
}
核心点二:二分查找定位可视区域
当列表项数量巨大时,线性查找可视区域的起始和结束索引效率太低。使用二分查找可以将时间复杂度从 O(n) 降到 O(log n)。
javascript
/**
* 二分查找:找到第一个顶部位置 >= scrollTop 的项索引
* 时间复杂度:O(log n)
*/
function binarySearchFirstVisible(positions, scrollTop) {
let left = 0;
let right = positions.length - 1;
let result = 0;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const midBottom = positions[mid].top + positions[mid].height;
if (midBottom <= scrollTop) {
left = mid + 1;
} else {
result = mid;
right = mid - 1;
}
}
return result;
}
/**
* 二分查找:找到第一个底部位置 > scrollBottom 的项索引
*/
function binarySearchLastVisible(positions, scrollBottom) {
let left = 0;
let right = positions.length - 1;
let result = positions.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (positions[mid].top < scrollBottom) {
left = mid + 1;
} else {
result = mid;
right = mid - 1;
}
}
return result;
}
核心点三:缓冲区机制
快速滚动时,如果只渲染可视区域内的元素,会出现短暂的白屏。缓冲区机制通过预渲染可视区域上下额外的元素来解决这一问题。
javascript
/**
* 计算缓冲区大小
* 快速滚动时增大缓冲区,减少白屏
*/
function getBufferSize(containerHeight, bufferRatio, isScrolling) {
// 滚动中时增加缓冲区
return isScrolling
? containerHeight * bufferRatio * 2
: containerHeight * bufferRatio;
}
/**
* 获取可视区域的范围(含缓冲区)
*/
function getVisibleRange(positions, scrollTop, containerHeight, bufferSize, overscan) {
const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;
// 二分查找可视区域
let start = binarySearchFirstVisible(positions, scrollTopWithBuffer);
let end = binarySearchLastVisible(positions, scrollBottomWithBuffer);
// 添加 overscan 预渲染项
start = Math.max(0, start - overscan);
end = Math.min(positions.length - 1, end + overscan);
return { start, end };
}
核心点四:动态高度测量与缓存
动态高度的难点在于:渲染前无法知道元素的实际高度。解决方案:
- 初始预估 :使用
estimateHeight函数预估初始高度 - 渲染后测量 :使用
getBoundingClientRect()测量实际高度 - 缓存更新:将测量结果缓存,避免重复测量
- 批量更新:所有测量完成后统一更新位置,避免频繁重算
javascript
// 渲染可视区域的元素
function render() {
const { start, end } = getVisibleRange();
// 记录需要测量高度的元素
const pendingMeasure = [];
for (let i = start; i <= end; i++) {
if (!renderedItems.has(i)) {
const item = data[i];
const position = positions[i];
// 创建并定位元素
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.top = `${position.top}px`;
el.innerHTML = renderItem(item, i);
container.appendChild(el);
renderedItems.set(i, el);
// 动态高度:记录需要测量的元素
if (!isFixedHeight && !heightCache.has(i)) {
pendingMeasure.push({ el, index: i });
}
}
}
// 批量测量高度,避免频繁更新位置
if (pendingMeasure.length > 0) {
requestAnimationFrame(() => {
let hasUpdate = false;
pendingMeasure.forEach(({ el, index }) => {
const actualHeight = el.getBoundingClientRect().height;
heightCache.set(index, actualHeight);
hasUpdate = true;
});
// 所有高度测量完成后统一更新一次
if (hasUpdate) {
updatePositions();
rerenderVisible();
}
});
}
}
核心点五:滚动优化
滚动事件触发频繁,需要优化性能:
javascript
function bindEvents() {
let rafId = null;
let scrollTimer = null;
container.addEventListener('scroll', (e) => {
scrollTop = e.target.scrollTop;
// 快速滑动检测
isScrolling = true;
if (scrollTimer) {
clearTimeout(scrollTimer);
}
// 滚动停止后 150ms 重置状态
scrollTimer = setTimeout(() => {
isScrolling = false;
}, 150);
// 使用 requestAnimationFrame 优化渲染
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
render();
});
});
}
效果演示
固定高度模式
每项高度固定为 50px,列表滚动流畅,渲染项数稳定。切换到固定高度模式后,可以看到所有列表项高度一致,适合用于简单列表场景。


动态高度模式
不同类型的内容高度不同,通过颜色标签区分:
- 🔵 蓝色(单行):约 45px,简短内容
- 🟢 绿色(中等):约 85px,2-3 行内容
- 🟠 橙色(较长):约 155px,5-6 行内容
- 🔴 红色(超长):约 285px,包含多段内容
- 🟣 紫色(随机):约 60-120px,高度随机波动


性能优化总结
| 优化点 | 说明 | 效果 |
|---|---|---|
| 二分查找 | 定位可视区域 | O(log n) 查找效率 |
| 缓冲区 | 上下预渲染 | 减少快速滚动白屏 |
| 高度缓存 | 避免重复测量 | 每项只测量一次 |
| 批量更新 | 统一更新位置 | 减少频繁重算 |
| rAF 节流 | requestAnimationFrame | 平滑滚动渲染 |
| 滚动检测 | 快速滚动时增大缓冲区 | 提升用户体验 |
完整代码
原生 JavaScript 实现(可直接运行)
以下是完整的 HTML 文件,保存后可直接在浏览器中打开运行:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>虚拟列表 Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.demo-container {
max-width: 900px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
padding: 24px;
}
h1 { font-size: 24px; margin-bottom: 20px; color: #333; }
.control-panel {
display: flex;
gap: 24px;
margin-bottom: 24px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
flex-wrap: wrap;
align-items: center;
}
.control-group { display: flex; align-items: center; gap: 8px; }
.control-group label { font-size: 14px; color: #666; font-weight: 500; }
.control-group select {
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
background: #fff;
cursor: pointer;
min-width: 120px;
}
.stats {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 13px;
color: #999;
}
.stats span {
padding: 4px 12px;
background: #e6f7ff;
border-radius: 4px;
color: #1890ff;
}
.list-wrapper {
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
margin-bottom: 24px;
}
.virtual-list-container {
height: 600px;
overflow: auto;
position: relative;
background: #fff;
}
.virtual-list-phantom { position: relative; }
.virtual-list-item {
position: absolute;
left: 0;
right: 0;
border-bottom: 1px solid #f0f0f0;
}
.virtual-list-item:hover { background: #f5f5f5; }
.fixed-item {
height: 100%;
padding: 0 16px;
display: flex;
align-items: center;
}
.fixed-item .index { width: 80px; color: #999; font-size: 13px; }
.fixed-item .content { flex: 1; }
.dynamic-item { padding: 12px 16px; }
.dynamic-item .header {
font-weight: 600;
margin-bottom: 8px;
color: #1890ff;
display: flex;
justify-content: space-between;
align-items: center;
}
.dynamic-item .text { color: #666; line-height: 1.6; font-size: 14px; white-space: pre-line; }
.mode-tag {
display: inline-block;
padding: 2px 8px;
background: #52c41a;
color: #fff;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.mode-tag.dynamic { background: #722ed1; }
</style>
</head>
<body>
<div class="demo-container">
<h1>虚拟列表 Demo <span class="mode-tag dynamic" id="modeTag">动态高度</span></h1>
<div class="control-panel">
<div class="control-group">
<label>模式:</label>
<select id="modeSelect">
<option value="fixed">固定高度</option>
<option value="dynamic" selected>动态高度</option>
</select>
</div>
<div class="control-group">
<label>数据量:</label>
<select id="countSelect">
<option value="1000">1,000 条</option>
<option value="10000" selected>10,000 条</option>
<option value="100000">100,000 条</option>
</select>
</div>
<div class="control-group">
<label>缓冲区:</label>
<select id="bufferSelect">
<option value="0">无缓冲</option>
<option value="0.25">25%</option>
<option value="0.5" selected>50%</option>
<option value="1">100%</option>
</select>
</div>
<div class="stats">
<span id="renderCount">渲染: 0 项</span>
<span id="scrollPos">滚动: 0px</span>
</div>
</div>
<div class="list-wrapper">
<div class="virtual-list-container" id="container">
<div class="virtual-list-phantom" id="phantom"></div>
</div>
</div>
</div>
<script>
// 配置参数
const CONFIG = {
containerHeight: 600,
fixedItemHeight: 50,
bufferRatio: 0.5,
overscan: 3,
mode: 'dynamic',
itemCount: 10000
};
// DOM 元素
const container = document.getElementById('container');
const phantom = document.getElementById('phantom');
const renderCountEl = document.getElementById('renderCount');
const scrollPosEl = document.getElementById('scrollPos');
const modeTag = document.getElementById('modeTag');
// 数据生成
function generateData(count, mode) {
const result = [];
for (let i = 0; i < count; i++) {
if (mode === 'fixed') {
result.push({ id: i, text: `列表项 ${i + 1}`, index: i });
} else {
const heightType = i % 5;
let content = '', tag = '';
switch (heightType) {
case 0: content = '简短内容'; tag = '单行'; break;
case 1: content = '这是一段中等长度的内容,占据两到三行的空间。'; tag = '中等'; break;
case 2: content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。'; tag = '较长'; break;
case 3: content = '这是一段非常长的内容,模拟真实业务场景中的富文本展示。\n\n在实际开发中,列表项可能包含各种复杂内容。'; tag = '超长'; break;
case 4: content = Array(3).fill('这是随机内容行。').join('\n'); tag = '随机'; break;
}
result.push({ id: i, text: `列表项 ${i + 1}`, content, tag, heightType, index: i });
}
}
return result;
}
// 虚拟列表类
class VirtualList {
constructor(options) {
this.container = options.container;
this.phantom = options.phantom;
this.data = options.data || [];
this.itemHeight = options.itemHeight;
this.containerHeight = options.containerHeight;
this.bufferRatio = options.bufferRatio || 0.5;
this.overscan = options.overscan || 3;
this.renderItem = options.renderItem;
this.estimateHeight = options.estimateHeight;
this.isFixedHeight = this.itemHeight !== undefined;
this.heightCache = new Map();
this.positions = [];
this.scrollTop = 0;
this.isScrolling = false;
this.renderedItems = new Map();
this.init();
}
init() {
this.updatePositions();
this.render();
this.bindEvents();
}
updatePositions() {
this.positions = [];
let currentTop = 0;
for (let i = 0; i < this.data.length; i++) {
let height;
if (this.isFixedHeight) {
height = this.itemHeight;
} else {
height = this.heightCache.get(i) ?? (this.estimateHeight?.(this.data[i], i) ?? 50);
}
this.positions.push({ top: currentTop, height });
currentTop += height;
}
this.totalHeight = currentTop;
this.phantom.style.height = `${this.totalHeight}px`;
}
binarySearchStart(scrollTop) {
let left = 0, right = this.positions.length - 1, result = 0;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const midBottom = this.positions[mid].top + this.positions[mid].height;
if (midBottom <= scrollTop) { left = mid + 1; }
else { result = mid; right = mid - 1; }
}
return result;
}
binarySearchEnd(scrollBottom) {
let left = 0, right = this.positions.length - 1, result = this.positions.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (this.positions[mid].top < scrollBottom) { left = mid + 1; }
else { result = mid; right = mid - 1; }
}
return result;
}
getBufferSize() {
return this.isScrolling ? this.containerHeight * this.bufferRatio * 2 : this.containerHeight * this.bufferRatio;
}
getVisibleRange() {
if (this.positions.length === 0) return { start: 0, end: 0 };
const bufferSize = this.getBufferSize();
const scrollTopWithBuffer = Math.max(0, this.scrollTop - bufferSize);
const scrollBottomWithBuffer = this.scrollTop + this.containerHeight + bufferSize;
let start = this.binarySearchStart(scrollTopWithBuffer);
let end = this.binarySearchEnd(scrollBottomWithBuffer);
start = Math.max(0, start - this.overscan);
end = Math.min(this.positions.length - 1, end + this.overscan);
return { start, end };
}
render() {
const { start, end } = this.getVisibleRange();
this.renderedItems.forEach((el, index) => {
if (index < start || index > end) { el.remove(); this.renderedItems.delete(index); }
});
const pendingMeasure = [];
for (let i = start; i <= end; i++) {
if (!this.renderedItems.has(i)) {
const item = this.data[i];
const position = this.positions[i];
const el = document.createElement('div');
el.className = 'virtual-list-item';
el.style.cssText = `position: absolute; top: ${position.top}px; left: 0; right: 0;`;
if (this.isFixedHeight) el.style.height = `${this.itemHeight}px`;
el.innerHTML = this.renderItem(item, i, this.isFixedHeight);
this.phantom.appendChild(el);
this.renderedItems.set(i, el);
if (!this.isFixedHeight && !this.heightCache.has(i)) pendingMeasure.push({ el, index: i });
}
}
if (pendingMeasure.length > 0) {
requestAnimationFrame(() => {
let hasUpdate = false;
pendingMeasure.forEach(({ el, index }) => {
if (this.renderedItems.has(index)) {
this.heightCache.set(index, el.getBoundingClientRect().height);
hasUpdate = true;
}
});
if (hasUpdate) { this.updatePositions(); this.rerenderVisible(); }
});
}
renderCountEl.textContent = `渲染: ${end - start + 1} 项`;
}
rerenderVisible() {
this.renderedItems.forEach((el, index) => {
const position = this.positions[index];
if (position) el.style.top = `${position.top}px`;
});
}
bindEvents() {
let rafId = null, scrollTimer = null;
this.container.addEventListener('scroll', (e) => {
this.scrollTop = e.target.scrollTop;
scrollPosEl.textContent = `滚动: ${Math.round(this.scrollTop)}px`;
this.isScrolling = true;
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => { this.isScrolling = false; }, 150);
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => this.render());
});
}
setData(data) {
this.data = data;
this.heightCache.clear();
this.renderedItems.forEach(el => el.remove());
this.renderedItems.clear();
this.scrollTop = 0;
this.container.scrollTop = 0;
this.updatePositions();
this.render();
}
updateConfig(options) {
if ('itemHeight' in options) {
this.itemHeight = options.itemHeight;
this.isFixedHeight = options.itemHeight !== undefined && options.itemHeight !== null;
}
if ('estimateHeight' in options) this.estimateHeight = options.estimateHeight;
if (options.bufferRatio !== undefined) this.bufferRatio = options.bufferRatio;
if (options.overscan !== undefined) this.overscan = options.overscan;
this.heightCache.clear();
this.renderedItems.forEach(el => el.remove());
this.renderedItems.clear();
this.scrollTop = 0;
this.container.scrollTop = 0;
this.updatePositions();
}
}
// 渲染函数
function renderItem(item, index, isFixed) {
if (isFixed) {
const bgColor = index % 2 === 0 ? '#fff' : '#fafafa';
return `<div class="fixed-item" style="background: ${bgColor}"><span class="index">#${index + 1}</span><span class="content">${item.text}</span></div>`;
} else {
const colors = {
0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
};
const c = colors[item.heightType] || colors[0];
return `<div class="dynamic-item" style="background: ${c.bg}; border-left: 3px solid ${c.border};"><div class="header"><span>#${index + 1} - ${item.text}</span><span style="background: ${c.tag}; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${item.tag}</span></div><div class="text">${item.content}</div></div>`;
}
}
function estimateHeight(item) {
return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
}
// 初始化
let data = generateData(CONFIG.itemCount, CONFIG.mode);
const virtualList = new VirtualList({
container, phantom, data,
itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
containerHeight: CONFIG.containerHeight,
bufferRatio: CONFIG.bufferRatio,
overscan: CONFIG.overscan,
renderItem,
estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
});
// 事件绑定
document.getElementById('modeSelect').addEventListener('change', (e) => {
CONFIG.mode = e.target.value;
modeTag.textContent = CONFIG.mode === 'fixed' ? '固定高度' : '动态高度';
modeTag.className = `mode-tag ${CONFIG.mode === 'dynamic' ? 'dynamic' : ''}`;
virtualList.updateConfig({
itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
});
data = generateData(CONFIG.itemCount, CONFIG.mode);
virtualList.setData(data);
});
document.getElementById('countSelect').addEventListener('change', (e) => {
CONFIG.itemCount = parseInt(e.target.value);
data = generateData(CONFIG.itemCount, CONFIG.mode);
virtualList.setData(data);
});
document.getElementById('bufferSelect').addEventListener('change', (e) => {
CONFIG.bufferRatio = parseFloat(e.target.value);
virtualList.updateConfig({ bufferRatio: CONFIG.bufferRatio });
});
</script>
</body>
</html>
React 版本实现
React 版本使用 Hooks 实现,支持 TypeScript 类型,完全参照原生 JavaScript 版本的实现逻辑:
typescript
/**
* 虚拟列表完整实现 - React 版本
* 支持固定高度和动态高度两种模式
*/
import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react';
// ============================================
// 类型定义
// ============================================
interface VirtualListProps<T> {
data: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T, index: number) => string | number;
containerHeight: number;
itemHeight?: number; // 固定高度模式:传入此项则使用固定高度
estimateItemHeight?: (item: T, index: number) => number; // 动态高度预估函数
bufferRatio?: number;
overscan?: number;
}
// ============================================
// 二分查找函数:O(log n) 定位可视区域
// ============================================
function binarySearchStart(
positions: { top: number; height: number }[],
scrollTop: number
): number {
let left = 0;
let right = positions.length - 1;
let result = 0;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const midBottom = positions[mid].top + positions[mid].height;
if (midBottom <= scrollTop) {
left = mid + 1;
} else {
result = mid;
right = mid - 1;
}
}
return result;
}
function binarySearchEnd(
positions: { top: number; height: number }[],
scrollBottom: number
): number {
let left = 0;
let right = positions.length - 1;
let result = positions.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (positions[mid].top < scrollBottom) {
left = mid + 1;
} else {
result = mid;
right = mid - 1;
}
}
return result;
}
// ============================================
// 核心组件:虚拟列表
// ============================================
function VirtualList<T>({
data,
renderItem,
keyExtractor,
containerHeight,
itemHeight,
estimateItemHeight,
bufferRatio = 0.5,
overscan = 3,
}: VirtualListProps<T>) {
// 判断是否固定高度模式
const isFixedHeight = itemHeight !== undefined;
// Refs:使用 ref 存储可变值,避免频繁触发重渲染
const containerRef = useRef<HTMLDivElement>(null);
const phantomRef = useRef<HTMLDivElement>(null);
const itemsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const heightCacheRef = useRef<Map<number, number>>(new Map());
const positionsRef = useRef<{ top: number; height: number }[]>([]);
const scrollTopRef = useRef(0);
const isScrollingRef = useRef(false);
const scrollTimerRef = useRef<ReturnType<typeof setTimeout>>();
// 状态
const [, forceUpdate] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
// ============================================
// 核心点1:计算所有项的位置信息
// ============================================
const updatePositions = useCallback(() => {
const positions: { top: number; height: number }[] = [];
let currentTop = 0;
for (let i = 0; i < data.length; i++) {
let height: number;
if (isFixedHeight) {
height = itemHeight!;
} else {
if (heightCacheRef.current.has(i)) {
height = heightCacheRef.current.get(i)!;
} else if (estimateItemHeight) {
height = estimateItemHeight(data[i], i);
} else {
height = 50;
}
}
positions.push({
top: currentTop,
height,
});
currentTop += height;
}
positionsRef.current = positions;
// 更新 phantom 高度
if (phantomRef.current) {
phantomRef.current.style.height = `${currentTop}px`;
}
}, [data, isFixedHeight, itemHeight, estimateItemHeight]);
// ============================================
// 核心点2:计算缓冲区大小
// ============================================
const getBufferSize = useCallback(() => {
// 快速滚动时增大缓冲区,减少白屏
return isScrolling
? containerHeight * bufferRatio * 2
: containerHeight * bufferRatio;
}, [containerHeight, bufferRatio, isScrolling]);
// ============================================
// 核心点3:获取可视区域的项目(二分查找)
// ============================================
const getVisibleRange = useCallback(() => {
const positions = positionsRef.current;
if (positions.length === 0) {
const defaultEnd = Math.min(20, data.length - 1);
return { start: 0, end: Math.max(0, defaultEnd) };
}
const bufferSize = getBufferSize();
const scrollTop = scrollTopRef.current;
const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;
// 二分查找可视区域
let start = binarySearchStart(positions, scrollTopWithBuffer);
let end = binarySearchEnd(positions, scrollBottomWithBuffer);
// 添加预渲染项
start = Math.max(0, start - overscan);
end = Math.min(positions.length - 1, end + overscan);
return { start, end };
}, [containerHeight, getBufferSize, overscan, data.length]);
// ============================================
// 核心点4:重新渲染可见区域位置
// ============================================
const rerenderVisible = useCallback(() => {
const positions = positionsRef.current;
itemsRef.current.forEach((el, index) => {
const position = positions[index];
if (position) {
el.style.top = `${position.top}px`;
}
});
}, []);
// 初始化和更新
useEffect(() => {
updatePositions();
forceUpdate((prev) => prev + 1);
}, [updatePositions]);
// 监听 itemHeight 变化(模式切换)
const prevItemHeightRef = useRef(itemHeight);
useEffect(() => {
// 检测模式切换(固定高度 <-> 动态高度)
if ((prevItemHeightRef.current === undefined) !== (itemHeight === undefined)) {
// 模式切换时重置所有状态
// 注意:不要直接操作 DOM,让 React 自己处理 DOM 的更新
heightCacheRef.current.clear();
itemsRef.current.clear();
scrollTopRef.current = 0;
if (containerRef.current) {
containerRef.current.scrollTop = 0;
}
updatePositions();
forceUpdate((prev) => prev + 1);
}
prevItemHeightRef.current = itemHeight;
}, [itemHeight, updatePositions]);
// ============================================
// 核心点5:滚动事件处理
// ============================================
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
scrollTopRef.current = e.currentTarget.scrollTop;
isScrollingRef.current = true;
setIsScrolling(true);
if (scrollTimerRef.current) {
clearTimeout(scrollTimerRef.current);
}
// 滚动停止后重置状态
scrollTimerRef.current = setTimeout(() => {
isScrollingRef.current = false;
setIsScrolling(false);
}, 150);
forceUpdate((prev) => prev + 1);
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (scrollTimerRef.current) {
clearTimeout(scrollTimerRef.current);
}
};
}, []);
// 数据变化时重置
const prevDataLengthRef = useRef(data.length);
useEffect(() => {
if (data.length !== prevDataLengthRef.current) {
heightCacheRef.current.clear();
itemsRef.current.clear();
scrollTopRef.current = 0;
prevDataLengthRef.current = data.length;
if (containerRef.current) {
containerRef.current.scrollTop = 0;
}
updatePositions();
}
}, [data.length, updatePositions]);
// ============================================
// 计算可视数据
// ============================================
const { start, end } = getVisibleRange();
const visibleData = useMemo(() => {
return data.slice(start, end + 1).map((item, i) => ({
item,
index: start + i,
}));
}, [data, start, end]);
const totalHeight = useMemo(() => {
const positions = positionsRef.current;
if (positions.length === 0) return 0;
const last = positions[positions.length - 1];
return last.top + last.height;
}, [data.length, forceUpdate]);
// ============================================
// 动态高度测量:使用 requestAnimationFrame 批量更新
// ============================================
useEffect(() => {
if (isFixedHeight) return;
const pendingMeasure: { el: HTMLDivElement; index: number }[] = [];
itemsRef.current.forEach((el, index) => {
if (!heightCacheRef.current.has(index)) {
pendingMeasure.push({ el, index });
}
});
if (pendingMeasure.length > 0) {
requestAnimationFrame(() => {
let hasUpdate = false;
pendingMeasure.forEach(({ el, index }) => {
if (itemsRef.current.has(index)) {
const actualHeight = el.getBoundingClientRect().height;
heightCacheRef.current.set(index, actualHeight);
hasUpdate = true;
}
});
if (hasUpdate) {
updatePositions();
rerenderVisible();
}
});
}
}, [visibleData, isFixedHeight, updatePositions, rerenderVisible]);
// ============================================
// 渲染
// ============================================
return (
<div
ref={containerRef}
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative',
}}
onScroll={handleScroll}
>
<div
ref={phantomRef}
style={{
height: totalHeight,
position: 'relative',
}}
>
{visibleData.map(({ item, index }) => {
const position = positionsRef.current[index];
return (
<div
key={keyExtractor(item, index)}
ref={(el) => {
if (el) {
itemsRef.current.set(index, el);
} else {
itemsRef.current.delete(index);
}
}}
style={{
position: 'absolute',
top: position?.top ?? 0,
left: 0,
right: 0,
height: isFixedHeight ? itemHeight : 'auto',
}}
>
{renderItem(item, index)}
</div>
);
})}
</div>
</div>
);
}
export default VirtualList;
// ============================================
// 使用示例
// ============================================
/**
* 示例1:固定高度列表
*/
export const FixedHeightExample = () => {
const data = Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `列表项 ${i + 1}`,
}));
return (
<VirtualList
data={data}
containerHeight={600}
itemHeight={50}
keyExtractor={(item) => item.id}
renderItem={(item) => (
<div
style={{
height: '100%',
padding: '0 16px',
display: 'flex',
alignItems: 'center',
borderBottom: '1px solid #eee',
}}
>
{item.text}
</div>
)}
/>
);
};
/**
* 示例2:动态高度列表
*/
export const DynamicHeightExample = () => {
const data = Array.from({ length: 10000 }, (_, i) => {
const heightType = i % 5;
let content = '';
let tag = '';
switch (heightType) {
case 0:
content = '简短内容';
tag = '单行';
break;
case 1:
content = '这是一段中等长度的内容,占据两到三行的空间。';
tag = '中等';
break;
case 2:
content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
tag = '较长';
break;
case 3:
content = `这是一段非常长的内容,模拟真实业务场景中的富文本展示。
在实际开发中,列表项可能包含:
• 用户详细信息
• 商品卡片
• 订单摘要`;
tag = '超长';
break;
case 4:
content = Array(3).fill('这是随机内容行。').join('\n');
tag = '随机';
break;
}
return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
});
// 预估高度函数:根据内容类型返回预估高度
const estimateHeight = (item: { heightType: number }) => {
const heightMap: Record<number, number> = {
0: 45, // 单行
1: 85, // 中等
2: 155, // 较长
3: 285, // 超长
4: 100 // 随机
};
return heightMap[item.heightType] || 60;
};
const renderItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
const colorMap: Record<number, { bg: string; border: string; tag: string }> = {
0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
};
const colors = colorMap[item.heightType] || colorMap[0];
return (
<div
style={{
padding: '12px 16px',
backgroundColor: colors.bg,
borderLeft: `3px solid ${colors.border}`,
borderBottom: '1px solid #f0f0f0',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<span style={{ fontWeight: 600, color: '#333' }}>#{index + 1} - {item.text}</span>
<span
style={{
backgroundColor: colors.tag,
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
}}
>
{item.tag}
</span>
</div>
<div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>
{item.content}
</div>
</div>
);
};
return (
<VirtualList
data={data}
containerHeight={600}
keyExtractor={(item) => item.id}
estimateItemHeight={estimateHeight}
renderItem={renderItem}
/>
);
};
/**
* 示例3:完整 Demo 组件(支持模式切换)
*/
export const VirtualListDemo = () => {
const [mode, setMode] = useState<'fixed' | 'dynamic'>('dynamic');
const [itemCount, setItemCount] = useState(10000);
const fixedData = useMemo(
() => Array.from({ length: itemCount }, (_, i) => ({ id: i, text: `列表项 ${i + 1}` })),
[itemCount]
);
const dynamicData = useMemo(() => {
return Array.from({ length: itemCount }, (_, i) => {
const heightType = i % 5;
let content = '';
let tag = '';
switch (heightType) {
case 0:
content = '简短内容';
tag = '单行';
break;
case 1:
content = '这是一段中等长度的内容,占据两到三行的空间。';
tag = '中等';
break;
case 2:
content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
tag = '较长';
break;
case 3:
content = `这是一段非常长的内容,模拟真实业务场景。\n\n包含多行内容展示。`;
tag = '超长';
break;
case 4:
content = Array(3).fill('这是随机内容行。').join('\n');
tag = '随机';
break;
}
return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
});
}, [itemCount]);
const estimateHeight = (item: { heightType: number }) => {
return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
};
const renderFixedItem = (item: { text: string }, index: number) => (
<div
style={{
height: '100%',
padding: '0 16px',
display: 'flex',
alignItems: 'center',
backgroundColor: index % 2 === 0 ? '#fff' : '#f9f9f9',
borderBottom: '1px solid #eee',
}}
>
<span style={{ width: 80, color: '#999' }}>#{index + 1}</span>
<span>{item.text}</span>
</div>
);
const renderDynamicItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
const colors = {
0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
}[item.heightType] || { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' };
return (
<div
style={{
padding: '12px 16px',
backgroundColor: colors.bg,
borderLeft: `3px solid ${colors.border}`,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontWeight: 600, color: '#1890ff' }}>#{index + 1} - {item.text}</span>
<span style={{ backgroundColor: colors.tag, color: '#fff', padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>
{item.tag}
</span>
</div>
<div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>{item.content}</div>
</div>
);
};
return (
<div style={{ padding: 20 }}>
<h2>虚拟列表 Demo</h2>
<div style={{ marginBottom: 20, display: 'flex', gap: 16, alignItems: 'center' }}>
<div>
<label>模式:</label>
<select value={mode} onChange={(e) => setMode(e.target.value as 'fixed' | 'dynamic')}>
<option value="fixed">固定高度</option>
<option value="dynamic">动态高度</option>
</select>
</div>
<div>
<label>数据量:</label>
<select value={itemCount} onChange={(e) => setItemCount(Number(e.target.value))}>
<option value={1000}>1,000 条</option>
<option value={10000}>10,000 条</option>
<option value={100000}>100,000 条</option>
</select>
</div>
</div>
<div style={{ border: '1px solid #ddd', borderRadius: 8, overflow: 'hidden' }}>
{mode === 'fixed' ? (
<VirtualList
data={fixedData}
containerHeight={600}
itemHeight={50}
keyExtractor={(item) => item.id}
renderItem={renderFixedItem}
/>
) : (
<VirtualList
data={dynamicData}
containerHeight={600}
keyExtractor={(item) => item.id}
estimateItemHeight={estimateHeight}
renderItem={renderDynamicItem}
/>
)}
</div>
</div>
);
};
参考资料
- React Window - React 虚拟列表组件
- React Virtualized - React 虚拟列表组件
- Vue Virtual Scroller - Vue 虚拟滚动组件
总结
虚拟列表是处理大数据列表渲染的经典方案,核心思想是只渲染可视区域内的元素。本文详细介绍了:
- 固定高度模式:实现简单,O(1) 时间复杂度计算位置
- 动态高度模式:需要高度缓存和测量,O(n) 时间复杂度计算位置
- 性能优化:二分查找、缓冲区、批量更新等策略
掌握虚拟列表的实现原理,不仅能解决实际开发中的性能问题,也能加深对浏览器渲染机制的理解。希望本文对你有所帮助!
如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!