在之前的文章中,我们介绍了多数据处理的时间分片思想,即通过 “拆分任务 + 异步调度” ,解决大量数据渲染时的 JS 阻塞问题。
但是,当数据量达到万级以上(如 10 万条、100 万条)时,即使使用时间分片,最终仍会生成大量 DOM 节点,从而导致页面内存占用飙升、滚动卡顿(浏览器渲染大量 DOM 时,重排重绘开销会指数级增长)。
此时,我们带来一种更进阶的解决方案 ——虚拟列表(Virtual List)。它的核心是 “按需渲染”,只保留视口内可见的 DOM 节点,彻底解决 “大量 DOM 导致的性能问题”。
一、什么是虚拟列表?—— 从 “全量渲染” 到 “按需渲染”
1.1 虚拟列表的定义
虚拟列表(也称 “窗口化列表” Windowing List),它是一种大数据长列表渲染优化技术,它通过计算 “当前视口可见区域” 的范围,只渲染 “视口内 + 少量预渲染” 的列表项(DOM 节点),而非全量渲染所有数据。
即使数据量达到 10 万条,页面中实际存在的 DOM 节点也仅为 “视口高度 / 单条高度 + 预渲染数量”(通常不超过 50 个),从而极大降低内存占用和渲染开销。
1.2 与时间分片的核心区别
虚拟列表和时间分片都是为了解决 “大量数据渲染性能问题”,但优化维度完全不同:
| 优化维度 | 时间分片(Time Slicing) | 虚拟列表(Virtual List) |
|---|---|---|
| 核心思想 | 时间优化:拆分长任务,避免 JS 线程阻塞 | 空间优化:减少 DOM 节点,降低渲染开销 |
| 最终 DOM 数量 | 全量 DOM(所有数据都会渲染成节点) | 仅视口内 + 预渲染 DOM(数量固定且极少) |
| 适用场景 | 数据量中等(千级),DOM 节点不致过多 | 数据量极大(万级以上),避免大量 DOM |
| 本质 | “化整为零” 的任务调度 | “按需加载” 的节点管理 |
二、虚拟列表的核心思想:用 “计算” 换 “空间”
虚拟列表的本质是 “以计算换空间”—— 通过少量 JS 计算(如滚动位置、可见范围),替代 “全量 DOM 渲染” 带来的空间开销。其核心逻辑基于三个关键认知:
1. 用户只能看到 “视口内” 的内容,视口外的内容无需渲染;
2. 滚动时,视口内的内容会动态变化,只需实时更新 “可见区域的 DOM” 即可;
3. 为避免滚动时 “空白闪烁”,可预渲染视口上下少量节点(称为overscan),提前加载即将进入视口的内容。
简单来说,虚拟列表就像 “电影院的银幕”—— 银幕(视口)只有固定大小,观众只能看到银幕内的画面(可见节点),银幕外的胶片(未渲染数据)无需展示,只需根据观众的 “视角移动”(滚动)切换银幕内的画面即可。
三、虚拟列表的实现原理:5 个关键步骤
虚拟列表的实现依赖于 “滚动位置计算” 和 “DOM 动态更新”,核心分为 5 个步骤,无论哪种框架(React/Vue/ 原生 JS),原理都是一致的:
步骤 1:确定基础参数
首先需要明确 4 个核心参数,这些参数是后续计算的基础:
- 视口高度(
containerHeight) :虚拟列表容器的固定高度(如用户代码中window.innerHeight - 100); - 单条列表项高度(
itemHeight) :每条数据渲染后的固定高度(如用户代码中80px,暂讨论固定高度场景,动态高度后续延伸); - 总数据量(
totalCount) :需要渲染的总数据条数(如用户代码中10000); - 预渲染数量(
overscan) :视口上下额外预渲染的节点数(如用户代码中3,避免滚动空白)。
步骤 2:计算 “可见区域范围”
当用户滚动列表时,通过滚动距离(scrollTop) 计算当前需要渲染的列表项索引范围:
- 可见起始索引(
startIndex) :当前视口顶部对应的列表项索引,计算公式:Math.floor(scrollTop / itemHeight); - 可见结束索引(
endIndex) :当前视口底部对应的列表项索引,需先计算 “视口内可容纳的最大条数”(visibleCount = Math.ceil(containerHeight / itemHeight)),再加上预渲染数量:endIndex = startIndex + visibleCount + overscan; - 边界处理:确保
startIndex ≥ 0,endIndex ≤ totalCount,避免索引越界。
步骤 3:筛选 “可见数据”
从总数据中筛选出[startIndex, endIndex]范围内的数据,作为 “当前需要渲染的列表项”(仅这部分数据会生成 DOM)。
步骤 4:计算 “偏移量”
为了让 “可见数据” 在视口中正确定位(避免滚动后列表错位),需要计算 “视口外已滚动的高度”,并通过transform: translateY()实现偏移:
- 偏移高度(
offsetTop) :视口顶部之前所有不可见列表项的总高度,计算公式:startIndex * itemHeight; - 通过给 “可见数据容器” 设置
transform: translateY(${offsetTop}px),将可见区域 “拉” 到视口内的正确位置。
步骤 5:监听滚动事件,实时更新
给虚拟列表容器添加scroll事件监听,每次滚动时重复步骤 2-4,动态更新 “可见索引范围”“可见数据” 和 “偏移量”,实现列表的流畅滚动。
四、手写 React 虚拟列表
App.jsx:
import { useState, useRef, useEffect } from 'react';
import './App.css';
import VirtualList from './components/VirtualList';
// 生成测试数据:创建指定数量的列表项数据
const generateData = (count) => {
// 修复原始代码缺陷:添加return语句返回生成的数组
return Array.from({ length: count }, (_, i) => ({
id: i, // 唯一标识,用于React列表的key
name: `item${i}`, // 列表项名称
description: `这是第 ${i} 个item,描述信息:${Math.random().toFixed(2)}` // 列表项描述
}));
};
function App() {
// 生成10000条测试数据(验证虚拟列表性能的典型数据量)
const data = generateData(10000);
// 列表项渲染函数:自定义每个列表项的UI结构
const renderItem = (item, index) => (
<div
key={item.id} // 必须提供唯一key,避免React重渲染时的性能问题和警告
style={{
padding: '10px',
borderBottom: '1px solid #ccc',
// 奇偶行不同背景色:依赖真实索引index实现
backgroundColor: index % 2 === 0 ? '#f0f0f0' : '#fff',
height: '80px', // 固定高度:与VirtualList组件的itemHeight保持一致
boxSizing: 'border-box' // 盒模型计算:避免padding导致实际高度超过80px
}}
>
<strong>[{index}]</strong> {item.name}
<p style={{ margin: '5px 0', fontSize: '0.9em', color: '#666' }}>
{item.description}
</p>
</div>
);
return (
<div style={{ padding: '20px', fontFamily: 'Arial' }}>
<h1>React 虚拟列表示例</h1>
<p>Smooth scrolling with 10000 items</p>
{/* 虚拟列表组件使用 */}
<VirtualList
data={data} // 总数据集合
containerHeight={window.innerHeight - 100} // 容器高度:视口高度减去边距
itemHeight={80} // 列表项高度:与renderItem中定义的高度一致
renderItem={renderItem} // 自定义渲染函数
overscan={3} // 预渲染数量:视口外额外渲染3项,避免滚动空白
/>
</div>
);
}
export default App;
App.jsx解析:
-
数据生成函数(generateData)
- 核心修复:补充了原始代码缺失的
return语句,确保能正确返回数据数组 - 数据结构:为每条数据生成
id(唯一标识)、name(名称)、description(描述),满足列表渲染的基本需求 - 扩展性:通过参数
count控制数据量,便于测试不同规模数据的渲染性能
- 核心修复:补充了原始代码缺失的
-
列表项渲染函数(renderItem)
- 固定高度设计:明确设置
height: '80px',并通过boxSizing: 'border-box'确保 padding 不影响总高度,这是固定高度虚拟列表的关键前提 - 索引依赖处理:通过
index % 2实现奇偶行背景色区分,体现了 "真实索引" 的重要性(后续虚拟列表需专门处理索引映射) - 自定义能力:将渲染逻辑暴露为函数,使虚拟列表组件具备通用性,可适配不同 UI 需求
- 固定高度设计:明确设置
-
虚拟列表使用配置
- 核心参数传递:
data(数据源)、containerHeight(容器高度)、itemHeight(项高度)、renderItem(渲染函数)、overscan(预渲染数量)构成虚拟列表的完整配置 - 动态高度计算:
containerHeight使用window.innerHeight - 100,使列表高度自适应视口,提升用户体验
- 核心参数传递:
VirtualList.jsx:
import { useState, useRef, useEffect, useMemo } from 'react';
const VirtualList = ({
data = [], // 总数据集合(必传参数)
containerHeight = 500, // 容器高度(默认500px)
itemHeight = 50, // 列表项高度(默认50px)
renderItem, // 列表项渲染函数(必传参数)
overscan = 2 // 预渲染数量(默认2项)
}) => {
// 1. Refs存储:用于DOM访问和固定参数缓存
const containerRef = useRef(null); // 滚动容器DOM引用
// 计算列表总高度(固定参数,用ref缓存避免每次渲染重新计算)
const totalHeightRef = useRef(data.length * itemHeight);
// 2. 状态管理:存储滚动相关的动态数据
const [scrollTop, setScrollTop] = useState(0); // 当前滚动距离(垂直方向)
// 3. 核心计算逻辑:用useMemo缓存计算结果,依赖变化时才重新计算
const { startIndex, endIndex, visibleData, offsetTop } = useMemo(() => {
// ① 计算视口内可容纳的最大列表项数量
const visibleCount = Math.ceil(containerHeight / itemHeight);
// ② 计算可见区域起始索引
// - 滚动距离÷单项高度 = 已滚动过的项数(向下取整)
// - 减去overscan:提前渲染视口上方的项,避免滚动时突然出现
// - Math.max(0):确保索引不小于0
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
// ③ 计算可见区域结束索引
// - 起始索引 + 视口可容纳数量 + 2*预渲染数量(上下各overscan)
// - Math.min:确保索引不超过总数据长度
const endIndex = Math.min(
data.length,
startIndex + visibleCount + 2 * overscan
);
// ④ 筛选可见数据:仅截取需要渲染的部分数据(核心优化点)
const visibleData = data.slice(startIndex, endIndex);
// ⑤ 计算偏移高度:可见区域顶部相对于列表顶部的偏移量
const offsetTop = startIndex * itemHeight;
return { startIndex, endIndex, visibleData, offsetTop };
}, [scrollTop, containerHeight, itemHeight, data, overscan]);
// 4. 滚动事件处理:实时更新滚动距离
const handleScroll = () => {
if (containerRef.current) {
// 获取容器当前的滚动距离,更新到状态中
setScrollTop(containerRef.current.scrollTop);
}
};
// 5. 事件监听:组件挂载时绑定滚动事件,卸载时解绑(避免内存泄漏)
useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
// 清理函数:组件卸载时移除事件监听
return () => container.removeEventListener('scroll', handleScroll);
}
}, []); // 空依赖数组:仅在组件挂载和卸载时执行
// 6. 渲染逻辑:构建虚拟列表的DOM结构
return (
<div
ref={containerRef}
style={{
height: `${containerHeight}px`, // 固定容器高度:产生滚动条的前提
overflow: 'auto', // 开启滚动:超出容器高度时显示滚动条
position: 'relative', // 相对定位:为内部绝对定位元素提供基准
border: '1px solid #eee',
}}
>
{/* 占位容器:关键角色是撑开滚动条,模拟全量数据的滚动效果 */}
<div style={{ height: `${totalHeightRef.current}px` }} />
{/* 可见数据容器:通过绝对定位和偏移实现"视口内显示" */}
<div
style={{
position: 'absolute', // 绝对定位:脱离文档流,覆盖在占位容器上
top: 0,
left: 0,
width: '100%',
// 核心偏移逻辑:通过transform将可见数据"移动"到视口内
transform: `translateY(${offsetTop}px)`,
}}
>
{/* 渲染可见数据:仅渲染[startIndex, endIndex]范围内的项 */}
{visibleData.map((item, idx) => {
// 计算真实索引:局部索引(idx)+ 起始索引(startIndex)
const realIndex = startIndex + idx;
// 调用外部传入的渲染函数,传递真实索引
return renderItem(item, realIndex);
})}
</div>
</div>
);
};
export default VirtualList;
VirtualList.jsx 解析:
-
Refs 设计与作用
containerRef:获取滚动容器的 DOM 实例,用于监听滚动事件和读取实时滚动位置(scrollTop)totalHeightRef:缓存列表总高度(数据量 × 单项高度),避免每次渲染重新计算,同时为 "占位容器" 提供高度值,确保滚动条长度正确
-
状态管理(scrollTop)
- 存储容器当前的垂直滚动距离,是触发可见区域重新计算的核心状态
- 通过滚动事件实时更新,驱动后续的可见范围计算逻辑
-
核心计算逻辑(useMemo 部分)
-
性能优化:使用
useMemo缓存计算结果,仅在依赖项(scrollTop、containerHeight等)变化时重新计算,减少不必要的性能消耗 -
可见范围计算:
visibleCount:计算视口能容纳的最大项数(向上取整确保不遗漏边缘项)startIndex与endIndex:结合滚动距离和预渲染数量(overscan),确定需要渲染的项索引范围,实现 "按需渲染"visibleData:通过数组切片仅获取需要渲染的数据,将 DOM 节点数量控制在最小范围(通常为视口容量 + 2 * 预渲染数量)
-
偏移量计算:
offsetTop确定可见区域相对于列表顶部的偏移距离,为后续定位提供依据
-
-
滚动事件处理
handleScroll:实时读取容器的滚动距离并更新到scrollTop状态,形成 "滚动→状态更新→重新计算→重新渲染" 的闭环- 事件绑定与清理:通过
useEffect在组件挂载时绑定事件,卸载时移除,避免内存泄漏和重复绑定
-
渲染结构设计
-
滚动容器:通过
height和overflow: auto创建固定高度的可滚动区域,position: relative为内部元素提供定位基准 -
占位容器:高度设为列表总高度,用于撑开滚动条(关键机制),使用户感知到 "全量数据" 的滚动体验
-
可见数据容器:
- 绝对定位覆盖在占位容器上,通过
transform: translateY(${offsetTop}px)将可见数据移动到视口内正确位置 - 使用
transform而非marginTop,利用 GPU 加速的合成层操作减少重排,提升滚动性能
- 绝对定位覆盖在占位容器上,通过
-
真实索引映射:通过
startIndex + idx计算数据在总集合中的真实索引,确保依赖索引的业务逻辑(如奇偶行变色)正确执行
-
七、总结:虚拟列表的适用场景与最佳实践
1. 适用场景
- 数据量极大(万级以上)的长列表(如订单列表、用户列表、聊天记录);
- 列表项结构复杂(含图片、文本、按钮等),渲染开销大;
- 对滚动流畅度要求高(如移动端 App、后台管理系统)。
2. 最佳实践
-
优先使用固定高度:固定高度虚拟列表实现简单、性能最优,若能设计成固定高度(如限制文本行数、图片定高),尽量选择固定高度;
-
合理设置 overscan:
overscan建议设为 2-5(过多会增加 DOM 数量,过少会出现滚动空白); -
避免滚动事件节流过度:
scroll事件本身触发频率不高(约 60 次 / 秒),无需过度节流;若计算逻辑复杂,可使用requestAnimationFrame包裹计算; -
结合时间分片:若列表项渲染逻辑复杂(如含大量计算、图片加载),可在
renderItem中使用时间分片,避免单次渲染过多项导致 JS 阻塞。