虚拟列表与大数据渲染
虚拟列表是处理大数据量渲染的核心技术,只渲染可视区域元素,大幅提升性能。
问题背景
传统渲染问题
JavaScript
// ❌ 全量渲染万级数据
function renderList(data) {
const container = document.getElementById('list');
data.forEach(item => {
const element = document.createElement('div');
element.textContent = item.name;
container.appendChild(element); // 10000个DOM节点
});
}
// 问题:
// 1. DOM节点过多,内存占用大
// 2. 首次渲染时间长
// 3. 滚动卡顿
// 4. 重排重绘开销大
虚拟列表原理
JavaScript
只渲染可视区域内的元素
+ 维持滚动条真实高度(总数据高度)
+ 滚动时动态更新渲染内容
虚拟列表实现
基础结构
JavaScript
class VirtualList {
constructor(options) {
this.container = options.container;
this.data = options.data;
this.itemHeight = options.itemHeight; // 固定高度
this.bufferSize = options.bufferSize || 5; // 缓冲区大小
this.init();
}
init() {
// 创建容器结构
this.wrapper = document.createElement('div');
this.wrapper.style.position = 'relative';
this.wrapper.style.height = `${this.data.length * this.itemHeight}px`;
this.content = document.createElement('div');
this.content.style.position = 'absolute';
this.content.style.top = '0';
this.content.style.width = '100%';
this.container.style.overflow = 'auto';
this.container.appendChild(this.wrapper);
this.wrapper.appendChild(this.content);
// 监听滚动
this.container.addEventListener('scroll', this.handleScroll.bind(this));
// 首次渲染
this.renderVisibleItems();
}
handleScroll() {
this.renderVisibleItems();
}
getVisibleRange() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
// 计算可视区域索引范围
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / this.itemHeight);
// 添加缓冲区
const bufferedStart = Math.max(0, startIndex - this.bufferSize);
const bufferedEnd = Math.min(this.data.length, endIndex + this.bufferSize);
return { start: bufferedStart, end: bufferedEnd };
}
renderVisibleItems() {
const { start, end } = this.getVisibleRange();
// 清空当前内容
this.content.innerHTML = '';
// 渲染可视区域元素
for (let i = start; i < end; i++) {
const item = this.data[i];
const element = this.createItemElement(item, i);
this.content.appendChild(element);
}
// 定位内容区域
this.content.style.top = `${start * this.itemHeight}px`;
}
createItemElement(item, index) {
const element = document.createElement('div');
element.style.height = `${this.itemHeight}px`;
element.textContent = `${index}: ${item.name}`;
return element;
}
}
动态高度虚拟列表
JavaScript
class DynamicVirtualList {
constructor(options) {
this.container = options.container;
this.data = options.data;
this.estimatedHeight = options.estimatedHeight; // 预估高度
this.heightCache = new Map(); // 实际高度缓存
this.positionCache = []; // 位置缓存
this.init();
}
init() {
// 初始化位置缓存
this.initPositionCache();
// 创建结构
this.wrapper = document.createElement('div');
this.wrapper.style.position = 'relative';
this.wrapper.style.height = `${this.getTotalHeight()}px`;
this.content = document.createElement('div');
this.wrapper.appendChild(this.content);
this.container.style.overflow = 'auto';
this.container.appendChild(this.wrapper);
this.container.addEventListener('scroll', this.handleScroll.bind(this));
this.renderVisibleItems();
}
initPositionCache() {
this.positionCache = this.data.map((_, index) => ({
index,
height: this.estimatedHeight,
top: index * this.estimatedHeight,
bottom: (index + 1) * this.estimatedHeight
}));
}
getTotalHeight() {
if (this.positionCache.length === 0) return 0;
return this.positionCache[this.positionCache.length - 1].bottom;
}
handleScroll() {
this.renderVisibleItems();
}
getVisibleRange() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
// 二分查找起始位置
const start = this.binarySearchStart(scrollTop);
const end = this.binarySearchEnd(scrollTop + containerHeight);
return { start, end };
}
binarySearchStart(scrollTop) {
let low = 0;
let high = this.positionCache.length - 1;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const midBottom = this.positionCache[mid].bottom;
if (midBottom <= scrollTop) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
binarySearchEnd(scrollBottom) {
let low = 0;
let high = this.positionCache.length - 1;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const midTop = this.positionCache[mid].top;
if (midTop < scrollBottom) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
renderVisibleItems() {
const { start, end } = this.getVisibleRange();
this.content.innerHTML = '';
for (let i = start; i < Math.min(end + 5, this.data.length); i++) {
const item = this.data[i];
const position = this.positionCache[i];
const element = this.createItemElement(item, i);
element.style.position = 'absolute';
element.style.top = `${position.top}px`;
// 渲染后获取实际高度
this.content.appendChild(element);
this.updateHeight(i, element.offsetHeight);
}
}
updateHeight(index, actualHeight) {
const cached = this.positionCache[index];
if (cached.height !== actualHeight) {
const diff = actualHeight - cached.height;
// 更新当前位置
cached.height = actualHeight;
cached.bottom = cached.top + actualHeight;
// 更新后续位置
for (let i = index + 1; i < this.positionCache.length; i++) {
this.positionCache[i].top += diff;
this.positionCache[i].bottom += diff;
}
// 更新总高度
this.wrapper.style.height = `${this.getTotalHeight()}px`;
}
}
createItemElement(item, index) {
const element = document.createElement('div');
element.textContent = `${index}: ${item.name}`;
element.style.width = '100%';
return element;
}
}
IntersectionObserver实现
JavaScript
class ObserverVirtualList {
constructor(options) {
this.container = options.container;
this.data = options.data;
this.itemHeight = options.itemHeight;
this.sentinelTop = document.createElement('div');
this.sentinelBottom = document.createElement('div');
this.init();
}
init() {
// 创建哨兵元素
this.sentinelTop.style.height = '1px';
this.sentinelBottom.style.height = '1px';
this.container.appendChild(this.sentinelTop);
// 初始渲染部分元素
this.renderInitialItems();
this.container.appendChild(this.sentinelBottom);
// 设置观察器
this.observer = new IntersectionObserver(this.handleIntersection.bind(this), {
root: this.container,
rootMargin: '100px'
});
this.observer.observe(this.sentinelTop);
this.observer.observe(this.sentinelBottom);
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.target === this.sentinelTop && entry.isIntersecting) {
this.prependItems();
}
if (entry.target === this.sentinelBottom && entry.isIntersecting) {
this.appendItems();
}
});
}
prependItems() {
// 向前加载更多
if (this.startIndex > 0) {
this.startIndex = Math.max(0, this.startIndex - 10);
this.updateItems();
}
}
appendItems() {
// 向后加载更多
if (this.endIndex < this.data.length) {
this.endIndex = Math.min(this.data.length, this.endIndex + 10);
this.updateItems();
}
}
updateItems() {
// 更新渲染内容
}
}
React虚拟列表
react-window使用
JavaScript
import { FixedSizeList, VariableSizeList } from 'react-window';
// 固定高度列表
function FixedList({ data }) {
return (
<FixedSizeList
height={600}
itemCount={data.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{data[index].name}
</div>
)}
</FixedSizeList>
);
}
// 动态高度列表
function VariableList({ data }) {
const getItemSize = index => {
return data[index].height || 50;
};
return (
<VariableSizeList
height={600}
itemCount={data.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{data[index].content}
</div>
)}
</VariableSizeList>
);
}
自定义React虚拟列表
vue
function VirtualList({ data, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
const visibleData = data.slice(startIndex, endIndex + 5);
const handleScroll = (e) => {
setScrollTop(e.currentTarget.scrollTop);
};
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={handleScroll}
>
<div style={{ height: data.length * itemHeight, position: 'relative' }}>
{visibleData.map((item, i) => (
<div
key={startIndex + i}
style={{
position: 'absolute',
top: (startIndex + i) * itemHeight,
height: itemHeight
}}
>
{item.name}
</div>
))}
</div>
</div>
);
}
Vue虚拟列表
vue-virtual-scroller
JavaScript
<template>
<RecycleScroller
:items="data"
:item-size="50"
:buffer="200"
class="scroller"
>
<template #default="{ item, index }">
<div class="item">
{{ index }}: {{ item.name }}
</div>
</template>
</RecycleScroller>
</template>
<script>
import { RecycleScroller } from 'vue-virtual-scroller';
export default {
components: { RecycleScroller },
props: ['data']
};
</script>
性能优化要点
销毁不可见元素
JavaScript
// ✅ 只保留可视区域DOM
// 滚动时移除离开可视区的元素,添加新进入的元素
// ❌ 保留所有已渲染元素
// 隐藏而非移除会导致DOM累积
减少渲染复杂度
JavaScript
// 简化列表项组件
function SimpleItem({ item }) {
return (
<div className="item">
<span>{item.name}</span>
<span>{item.value}</span>
</div>
);
}
// ❌ 复杂组件导致滚动卡顿
function ComplexItem({ item }) {
return (
<div>
<HeavyComponent />
<ExpensiveCalculation />
<NestedComponents />
</div>
);
}
滚动事件优化
text
// 使用passive事件监听
container.addEventListener('scroll', handleScroll, { passive: true });
// 节流滚动处理
const throttledScroll = throttle(handleScroll, 16); // 约60fps
container.addEventListener('scroll', throttledScroll);
要点总结
- 核心原理:只渲染可视区域 + 占位空间维持滚动条
- 固定高度:简单计算索引范围,性能最优
- 动态高度:高度缓存 + 二分查找定位
- 缓冲区:前后预渲染防止滚动白屏
- IntersectionObserver:监听哨兵元素实现无限滚动
- 成熟库:react-window、vue-virtual-scroller开箱即用
存放路径:articles/JS/专家/高级性能分析/虚拟列表与大数据渲染.md
📝 发现内容有误?点击此处直接编辑