JavaScript 性能优化系列(四):应用响应速度优化——多页面切换场景的流畅体验-4
JavaScript 性能优化系列(四):应用响应速度优化——多页面切换场景的流畅体验-4
4.4 虚拟滚动:处理长列表场景的渲染性能
在多页面应用中,长列表是常见的UI模式(如产品列表、搜索结果、消息记录等)。当列表包含成百上千甚至上万个项目时,一次性渲染所有项目会导致严重的性能问题——大量的DOM节点创建和渲染会阻塞主线程,导致页面加载缓慢、滚动卡顿,甚至在低端设备上出现崩溃。
虚拟滚动(Virtual Scrolling)技术通过只渲染可视区域内的项目,显著减少DOM节点数量,从而解决长列表的性能问题。
4.4.1 原理:虚拟滚动如何实现高效渲染?
传统列表渲染的问题在于:
- 无论列表有多长,都会创建所有项目的DOM节点;
- 即使项目不在可视区域内,也会参与布局计算和绘制;
- 滚动时,浏览器需要处理大量DOM节点的重排和重绘。
虚拟滚动的核心思想是只渲染用户当前能看到的项目,主要原理包括:
- 计算可视区域:确定用户当前可见的列表范围(基于滚动位置和容器尺寸);
- 渲染可见项目:只创建和渲染可视区域内的项目;
- 使用空白填充:在可见项目上方和下方使用空白元素填充,模拟完整列表的滚动效果;
- 动态更新:当用户滚动时,动态更新可见项目,并调整填充空白的大小。
虚拟滚动的关键技术点:
- 视口计算:精确计算当前可见的项目范围;
- 项目尺寸:处理固定尺寸或动态尺寸的列表项;
- 缓存机制:缓存已渲染的项目,避免频繁创建和销毁;
- 滚动优化:使用防抖、节流或requestAnimationFrame优化滚动事件处理。
研究表明,对于包含10,000个项目的列表,虚拟滚动可以将DOM节点数量减少95%以上,滚动帧率从20fps提升到60fps,显著提升用户体验。
4.4.2 代码样例:虚拟滚动的实现方式
4.4.2.1 基础虚拟滚动实现(固定高度)
<!-- 基础虚拟滚动组件(固定高度列表项) -->
<div class="virtual-scroll-container">
<h3>基础虚拟滚动示例(10,000 个项目)</h3>
<div id="virtualScrollContainer" class="virtual-scroll">
<div id="scrollArea" class="scroll-area">
<!-- 可视区域项目将在这里动态渲染 -->
</div>
<!-- 滚动条轨道 -->
<div class="scrollbar-track">
<div id="scrollbarThumb" class="scrollbar-thumb"></div>
</div>
</div>
<div class="info-panel">
<p>总项目数: <span id="totalItems">10000</span></p>
<p>当前渲染数: <span id="renderedItems">0</span></p>
<p>DOM 节点数: <span id="domNodes">0</span></p>
</div>
</div>
<style>
.virtual-scroll-container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.virtual-scroll {
position: relative;
height: 500px;
border: 1px solid #ccc;
overflow: hidden;
}
.scroll-area {
position: absolute;
top: 0;
left: 0;
right: 0;
/* 高度会被动态设置为总内容高度 */
}
.list-item {
height: 50px; /* 固定高度 */
padding: 10px;
border-bottom: 1px solid #f0f0f0;
box-sizing: border-box;
}
.list-item:hover {
background-color: #f5f5f5;
}
/* 滚动条样式 */
.scrollbar-track {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 100%;
background-color: #f0f0f0;
}
.scrollbar-thumb {
position: absolute;
width: 100%;
background-color: #ccc;
border-radius: 4px;
cursor: pointer;
}
.scrollbar-thumb:hover {
background-color: #999;
}
.info-panel {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
}
</style>
<script>
// 基础虚拟滚动实现(固定高度)
class BasicVirtualScroller {
/**
* 初始化虚拟滚动器
* @param {Object} options - 配置选项
* @param {string} options.containerId - 容器ID
* @param {string} options.scrollAreaId - 滚动区域ID
* @param {string} options.thumbId - 滚动条ID
* @param {number} options.itemHeight - 每个项目的固定高度
* @param {number} options.totalItems - 总项目数
* @param {Function} options.renderItem - 渲染项目的函数
*/
constructor(options) {
// 配置参数
this.containerId = options.containerId;
this.scrollAreaId = options.scrollAreaId;
this.thumbId = options.thumbId;
this.itemHeight = options.itemHeight || 50;
this.totalItems = options.totalItems || 1000;
this.renderItem = options.renderItem || this.defaultRenderItem;
// DOM元素
this.container = document.getElementById(this.containerId);
this.scrollArea = document.getElementById(this.scrollAreaId);
this.thumb = document.getElementById(this.thumbId);
// 状态变量
this.scrollTop = 0; // 当前滚动位置
this.visibleCount = 0; // 可见项目数量
this.startIndex = 0; // 起始索引
this.endIndex = 0; // 结束索引
this.buffer = 5; // 缓冲区项目数量(可视区域外额外渲染的项目)
this.items = []; // 存储所有项目数据的数组
// 计算容器高度
this.containerHeight = this.container.clientHeight;
// 初始化
this.init();
}
/**
* 初始化
*/
init() {
// 生成示例数据
this.generateItems();
// 计算总高度
this.totalHeight = this.totalItems * this.itemHeight;
// 设置滚动区域高度
this.scrollArea.style.height = `${this.totalHeight}px`;
// 计算可见项目数量
this.visibleCount = Math.ceil(this.containerHeight / this.itemHeight) + this.buffer * 2;
// 初始化滚动条
this.initScrollbar();
// 渲染初始可见项目
this.renderVisibleItems();
// 绑定事件
this.bindEvents();
// 更新信息面板
this.updateInfoPanel();
}
/**
* 生成示例项目数据
*/
generateItems() {
for (let i = 0; i < this.totalItems; i++) {
this.items.push({
id: i,
title: `项目 ${i + 1}`,
description: `这是虚拟滚动列表中的第 ${i + 1} 个项目`
});
}
}
/**
* 默认项目渲染函数
* @param {Object} item - 项目数据
* @returns {string} HTML字符串
*/
defaultRenderItem(item) {
return `
<div class="list-item" data-id="${item.id}">
<h4>${item.title}</h4>
<p>${item.description}</p>
</div>
`;
}
/**
* 初始化滚动条
*/
initScrollbar() {
// 计算滚动条高度
const thumbHeight = Math.max(30, (this.containerHeight / this.totalHeight) * this.containerHeight);
this.thumb.style.height = `${thumbHeight}px`;
this.thumbHeight = thumbHeight;
}
/**
* 计算可见项目范围
*/
calculateVisibleRange() {
// 根据滚动位置计算起始索引
this.startIndex = Math.floor(this.scrollTop / this.itemHeight) - this.buffer;
this.startIndex = Math.max(0, this.startIndex);
// 计算结束索引
this.endIndex = this.startIndex + this.visibleCount;
this.endIndex = Math.min(this.totalItems, this.endIndex);
}
/**
* 渲染可见项目
*/
renderVisibleItems() {
// 计算可见范围
this.calculateVisibleRange();
// 计算偏移量(用于定位可见项目)
const offsetY = this.startIndex * this.itemHeight;
// 生成可见项目HTML
let html = '';
for (let i = this.startIndex; i < this.endIndex; i++) {
html += this.renderItem(this.items[i]);
}
// 更新滚动区域
this.scrollArea.innerHTML = html;
// 设置偏移量,使项目显示在正确位置
this.scrollArea.style.transform = `translateY(${offsetY}px)`;
// 更新滚动条位置
this.updateScrollbarPosition();
// 更新信息面板
this.updateInfoPanel();
}
/**
* 更新滚动条位置
*/
updateScrollbarPosition() {
const scrollPercentage = this.scrollTop / this.totalHeight;
const maxThumbTop = this.containerHeight - this.thumbHeight;
const thumbTop = scrollPercentage * maxThumbTop;
this.thumb.style.top = `${thumbTop}px`;
}
/**
* 更新信息面板
*/
updateInfoPanel() {
document.getElementById('totalItems').textContent = this.totalItems;
document.getElementById('renderedItems').textContent = this.endIndex - this.startIndex;
// 计算DOM节点数量
const domNodes = this.scrollArea.getElementsByTagName('*').length;
document.getElementById('domNodes').textContent = domNodes;
}
/**
* 处理滚动事件
* @param {number} scrollTop - 新的滚动位置
*/
handleScroll(scrollTop) {
// 限制滚动范围
scrollTop = Math.max(0, Math.min(scrollTop, this.totalHeight - this.containerHeight));
// 如果滚动位置没有变化,不做处理
if (scrollTop === this.scrollTop) {
return;
}
// 更新滚动位置
this.scrollTop = scrollTop;
// 渲染可见项目
this.renderVisibleItems();
}
/**
* 绑定事件处理函数
*/
bindEvents() {
// 鼠标拖动滚动条
let isDragging = false;
let startY = 0;
let startScrollTop = 0;
// 鼠标按下
this.thumb.addEventListener('mousedown', (e) => {
isDragging = true;
startY = e.clientY;
startScrollTop = this.scrollTop;
this.thumb.classList.add('dragging');
e.preventDefault();
});
// 鼠标移动
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaY = e.clientY - startY;
const maxThumbTop = this.containerHeight - this.thumbHeight;
const scrollPercentage = deltaY / maxThumbTop;
const newScrollTop = startScrollTop + scrollPercentage * this.totalHeight;
this.handleScroll(newScrollTop);
});
// 鼠标释放
document.addEventListener('mouseup', () => {
isDragging = false;
this.thumb.classList.remove('dragging');
});
// 鼠标滚轮
this.container.addEventListener('wheel', (e) => {
// 计算新的滚动位置(乘以一个系数使滚动更自然)
const newScrollTop = this.scrollTop + e.deltaY * 0.5;
this.handleScroll(newScrollTop);
e.preventDefault();
});
// 窗口大小改变时重新计算
window.addEventListener('resize', () => {
this.containerHeight = this.container.clientHeight;
this.visibleCount = Math.ceil(this.containerHeight / this.itemHeight) + this.buffer * 2;
this.initScrollbar();
this.renderVisibleItems();
});
}
}
// 初始化虚拟滚动器
document.addEventListener('DOMContentLoaded', () => {
new BasicVirtualScroller({
containerId: 'virtualScrollContainer',
scrollAreaId: 'scrollArea',
thumbId: 'scrollbarThumb',
itemHeight: 50,
totalItems: 10000,
// 自定义渲染函数
renderItem: (item) => `
<div class="list-item" data-id="${item.id}">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h4 style="margin: 0;">${item.title}</h4>
<p style="margin: 5px 0 0; font-size: 12px; color: #666;">${item.description}</p>
</div>
<span style="color: #666;">ID: ${item.id}</span>
</div>
</div>
`
});
});
</script>
4.4.2.2 动态高度虚拟滚动实现
<!-- 动态高度虚拟滚动组件 -->
<div class="dynamic-virtual-scroll-container">
<h3>动态高度虚拟滚动示例</h3>
<p>列表项高度不固定,根据内容自动调整</p>
<div id="dynamicScrollContainer" class="virtual-scroll">
<div id="dynamicScrollArea" class="scroll-area">
<!-- 可视区域项目将在这里动态渲染 -->
</div>
<!-- 滚动条轨道 -->
<div class="scrollbar-track">
<div id="dynamicScrollbarThumb" class="scrollbar-thumb"></div>
</div>
</div>
<div class="info-panel">
<p>总项目数: <span id="dynamicTotalItems">5000</span></p>
<p>当前渲染数: <span id="dynamicRenderedItems">0</span></p>
<p>DOM 节点数: <span id="dynamicDomNodes">0</span></p>
</div>
</div>
<style>
.dynamic-virtual-scroll-container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
/* 复用之前的虚拟滚动样式,并添加动态项目样式 */
.dynamic-list-item {
padding: 10px;
border-bottom: 1px solid #f0f0f0;
box-sizing: border-box;
}
.dynamic-list-item:hover {
background-color: #f5f5f5;
}
.item-content {
margin-top: 5px;
font-size: 14px;
color: #333;
}
</style>
<script>
// 动态高度虚拟滚动实现
class DynamicVirtualScroller {
/**
* 初始化动态高度虚拟滚动器
* @param {Object} options - 配置选项
*/
constructor(options) {
// 配置参数
this.containerId = options.containerId;
this.scrollAreaId = options.scrollAreaId;
this.thumbId = options.thumbId;
this.totalItems = options.totalItems || 1000;
this.renderItem = options.renderItem || this.defaultRenderItem;
this.estimateHeight = options.estimateHeight || 100; // 预估高度
this.buffer = options.buffer || 5; // 缓冲区项目数量
// DOM元素
this.container = document.getElementById(this.containerId);
this.scrollArea = document.getElementById(this.scrollAreaId);
this.thumb = document.getElementById(this.thumbId);
// 状态变量
this.scrollTop = 0;
this.containerHeight = this.container.clientHeight;
this.visibleCount = 0;
this.startIndex = 0;
this.endIndex = 0;
// 存储项目高度信息
this.itemHeights = new Array(this.totalItems).fill(this.estimateHeight); // 项目高度缓存
this.itemOffsets = [0]; // 项目顶部偏移量缓存
// 计算总高度(初始基于预估高度)
this.totalHeight = this.totalItems * this.estimateHeight;
// 项目数据
this.items = [];
// 初始化
this.init();
}
/**
* 初始化
*/
init() {
// 生成示例数据(内容长度随机,导致高度变化)
this.generateItems();
// 设置滚动区域高度
this.scrollArea.style.height = `${this.totalHeight}px`;
// 计算可见项目数量(基于预估高度)
this.visibleCount = Math.ceil(this.containerHeight / this.estimateHeight) + this.buffer * 2;
// 初始化滚动条
this.initScrollbar();
// 渲染初始可见项目
this.renderVisibleItems();
// 绑定事件
this.bindEvents();
// 更新信息面板
this.updateInfoPanel();
}
/**
* 生成示例项目数据(内容长度随机)
*/
generateItems() {
const loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";
const words = loremIpsum.split(' ');
for (let i = 0; i < this.totalItems; i++) {
// 随机生成内容长度,使项目高度不同
const wordCount = 5 + Math.floor(Math.random() * 50);
const randomContent = words.slice(0, wordCount).join(' ');
this.items.push({
id: i,
title: `动态项目 ${i + 1}`,
content: randomContent
});
}
}
/**
* 默认项目渲染函数
*/
defaultRenderItem(item) {
return `
<div class="dynamic-list-item" data-id="${item.id}">
<h4>${item.title}</h4>
<div class="item-content">${item.content}</div>
</div>
`;
}
/**
* 初始化滚动条
*/
initScrollbar() {
// 计算滚动条高度
const thumbHeight = Math.max(30, (this.containerHeight / this.totalHeight) * this.containerHeight);
this.thumb.style.height = `${thumbHeight}px`;
this.thumbHeight = thumbHeight;
}
/**
* 计算项目偏移量(顶部位置)
*/
calculateOffsets() {
this.itemOffsets = [0];
let currentOffset = 0;
for (let i = 0; i < this.totalItems; i++) {
currentOffset += this.itemHeights[i];
this.itemOffsets.push(currentOffset);
}
// 更新总高度
this.totalHeight = currentOffset;
this.scrollArea.style.height = `${this.totalHeight}px`;
}
/**
* 找到滚动位置对应的起始索引
*/
findStartIndex() {
// 使用二分查找高效找到起始索引
let low = 0;
let high = this.totalItems;
while (low < high) {
const mid = (low + high) >> 1; // 等同于 Math.floor((low + high) / 2)
if (this.itemOffsets[mid] >= this.scrollTop) {
high = mid;
} else {
low = mid + 1;
}
}
// 减去缓冲区
return Math.max(0, low - this.buffer);
}
/**
* 计算可见项目范围
*/
calculateVisibleRange() {
// 找到起始索引
this.startIndex = this.findStartIndex();
// 找到结束索引
const visibleBottom = this.scrollTop + this.containerHeight;
this.endIndex = this.startIndex;
// 找到可见区域底部对应的索引
while (this.endIndex < this.totalItems && this.itemOffsets[this.endIndex] < visibleBottom) {
this.endIndex++;
}
// 加上缓冲区
this.endIndex = Math.min(this.totalItems, this.endIndex + this.buffer);
}
/**
* 测量已渲染项目的实际高度并更新缓存
*/
measureItemHeights() {
const items = this.scrollArea.getElementsByClassName('dynamic-list-item');
let hasChanged = false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const id = parseInt(item.dataset.id, 10);
const actualHeight = item.offsetHeight;
// 如果实际高度与缓存的高度不同,更新缓存
if (this.itemHeights[id] !== actualHeight) {
this.itemHeights[id] = actualHeight;
hasChanged = true;
}
}
// 如果高度有变化,重新计算偏移量
if (hasChanged) {
this.calculateOffsets();
return true;
}
return false;
}
/**
* 渲染可见项目
*/
renderVisibleItems() {
// 计算可见范围
this.calculateVisibleRange();
// 计算偏移量(用于定位可见项目)
const offsetY = this.itemOffsets[this.startIndex];
// 生成可见项目HTML
let html = '';
for (let i = this.startIndex; i < this.endIndex; i++) {
html += this.renderItem(this.items[i]);
}
// 更新滚动区域
this.scrollArea.innerHTML = html;
// 设置偏移量,使项目显示在正确位置
this.scrollArea.style.transform = `translateY(${offsetY}px)`;
// 测量实际高度并更新缓存
const heightsChanged = this.measureItemHeights();
// 如果高度有变化,重新渲染
if (heightsChanged) {
this.renderVisibleItems();
return;
}
// 更新滚动条位置
this.updateScrollbarPosition();
// 更新信息面板
this.updateInfoPanel();
}
/**
* 更新滚动条位置
*/
updateScrollbarPosition() {
const scrollPercentage = this.scrollTop / this.totalHeight;
const maxThumbTop = this.containerHeight - this.thumbHeight;
const thumbTop = scrollPercentage * maxThumbTop;
this.thumb.style.top = `${thumbTop}px`;
}
/**
* 更新信息面板
*/
updateInfoPanel() {
document.getElementById('dynamicTotalItems').textContent = this.totalItems;
document.getElementById('dynamicRenderedItems').textContent = this.endIndex - this.startIndex;
// 计算DOM节点数量
const domNodes = this.scrollArea.getElementsByTagName('*').length;
document.getElementById('dynamicDomNodes').textContent = domNodes;
}
/**
* 处理滚动事件
*/
handleScroll(scrollTop) {
// 限制滚动范围
scrollTop = Math.max(0, Math.min(scrollTop, this.totalHeight - this.containerHeight));
// 如果滚动位置没有变化,不做处理
if (scrollTop === this.scrollTop) {
return;
}
// 更新滚动位置
this.scrollTop = scrollTop;
// 渲染可见项目
this.renderVisibleItems();
}
/**
* 绑定事件处理函数
*/
bindEvents() {
// 鼠标拖动滚动条
let isDragging = false;
let startY = 0;
let startScrollTop = 0;
// 鼠标按下
this.thumb.addEventListener('mousedown', (e) => {
isDragging = true;
startY = e.clientY;
startScrollTop = this.scrollTop;
this.thumb.classList.add('dragging');
e.preventDefault();
});
// 鼠标移动
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaY = e.clientY - startY;
const maxThumbTop = this.containerHeight - this.thumbHeight;
const scrollPercentage = deltaY / maxThumbTop;
const newScrollTop = startScrollTop + scrollPercentage * this.totalHeight;
this.handleScroll(newScrollTop);
});
// 鼠标释放
document.addEventListener('mouseup', () => {
isDragging = false;
this.thumb.classList.remove('dragging');
});
// 鼠标滚轮
this.container.addEventListener('wheel', (e) => {
// 使用requestAnimationFrame优化滚动性能
requestAnimationFrame(() => {
const newScrollTop = this.scrollTop + e.deltaY * 0.7;
this.handleScroll(newScrollTop);
});
e.preventDefault();
});
// 窗口大小改变时重新计算
window.addEventListener('resize', () => {
this.containerHeight = this.container.clientHeight;
this.visibleCount = Math.ceil(this.containerHeight / this.estimateHeight) + this.buffer * 2;
this.initScrollbar();
this.renderVisibleItems();
});
}
}
// 初始化动态高度虚拟滚动器
document.addEventListener('DOMContentLoaded', () => {
new DynamicVirtualScroller({
containerId: 'dynamicScrollContainer',
scrollAreaId: 'dynamicScrollArea',
thumbId: 'dynamicScrollbarThumb',
totalItems: 5000,
estimateHeight: 100,
buffer: 5,
// 自定义渲染函数
renderItem: (item) => `
<div class="dynamic-list-item" data-id="${item.id}">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<h4 style="margin: 0;">${item.title}</h4>
<div class="item-content" style="margin-top: 8px;">${item.content}</div>
</div>
<span style="color: #999; font-size: 12px;">ID: ${item.id}</span>
</div>
</div>
`
});
});
</script>
4.4.2.3 React虚拟滚动组件(使用hooks)
import React, { useState, useRef, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
// React虚拟滚动组件
const VirtualList = ({
data,
height = 500,
itemHeight = 50,
buffer = 5,
renderItem,
estimateHeight = 50,
dynamicHeight = false
}) => {
// 容器和滚动区域的ref
const containerRef = useRef(null);
const scrollAreaRef = useRef(null);
const contentRef = useRef(null);
// 状态管理
const [scrollTop, setScrollTop] = useState(0);
const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(0);
const [itemHeights, setItemHeights] = useState([]);
const [itemOffsets, setItemOffsets] = useState([0]);
const [totalHeight, setTotalHeight] = useState(0);
// 计算可见项目数量
const visibleCount = Math.ceil(height / (dynamicHeight ? estimateHeight : itemHeight)) + buffer * 2;
// 初始化高度缓存
useEffect(() => {
if (dynamicHeight && data.length > 0) {
// 初始使用预估高度
const initialHeights = new Array(data.length).fill(estimateHeight);
setItemHeights(initialHeights);
// 计算初始偏移量
const offsets = [0];
let currentOffset = 0;
for (let i = 0; i < data.length; i++) {
currentOffset += initialHeights[i];
offsets.push(currentOffset);
}
setItemOffsets(offsets);
setTotalHeight(currentOffset);
} else {
// 固定高度
setTotalHeight(data.length * itemHeight);
setItemHeights(new Array(data.length).fill(itemHeight));
// 计算偏移量
const offsets = [0];
for (let i = 0; i < data.length; i++) {
offsets.push((i + 1) * itemHeight);
}
setItemOffsets(offsets);
}
}, [data.length, itemHeight, dynamicHeight, estimateHeight]);
// 计算可见项目范围
const calculateVisibleRange = useCallback(() => {
if (data.length === 0) return;
let newStartIndex, newEndIndex;
if (dynamicHeight) {
// 动态高度:使用二分查找找到起始索引
let low = 0;
let high = data.length;
while (low < high) {
const mid = (low + high) >> 1;
if (itemOffsets[mid] >= scrollTop) {
high = mid;
} else {
low = mid + 1;
}
}
newStartIndex = Math.max(0, low - buffer);
// 找到结束索引
const visibleBottom = scrollTop + height;
newEndIndex = newStartIndex;
while (newEndIndex < data.length && itemOffsets[newEndIndex] < visibleBottom) {
newEndIndex++;
}
newEndIndex = Math.min(data.length, newEndIndex + buffer);
} else {
// 固定高度:直接计算
newStartIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
newEndIndex = Math.min(data.length, newStartIndex + visibleCount);
}
setStartIndex(newStartIndex);
setEndIndex(newEndIndex);
}, [data.length, scrollTop, height, dynamicHeight, itemOffsets, itemHeight, visibleCount, buffer]);
// 当滚动位置变化时重新计算可见范围
useEffect(() => {
calculateVisibleRange();
}, [scrollTop, calculateVisibleRange]);
// 测量实际项目高度并更新缓存
const measureItemHeights = useCallback(() => {
if (!dynamicHeight || !contentRef.current) return false;
const items = contentRef.current.children;
const newHeights = [...itemHeights];
let hasChanged = false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const id = parseInt(item.dataset.index, 10);
const actualHeight = item.offsetHeight;
if (newHeights[id] !== actualHeight) {
newHeights[id] = actualHeight;
hasChanged = true;
}
}
if (hasChanged) {
// 重新计算偏移量
const offsets = [0];
let currentOffset = 0;
for (let i = 0; i < data.length; i++) {
currentOffset += newHeights[i];
offsets.push(currentOffset);
}
setItemHeights(newHeights);
setItemOffsets(offsets);
setTotalHeight(currentOffset);
return true;
}
return false;
}, [dynamicHeight, itemHeights, data.length]);
// 当可见范围变化时测量高度
useEffect(() => {
if (dynamicHeight) {
const heightsChanged = measureItemHeights();
// 如果高度变化,重新计算可见范围
if (heightsChanged) {
calculateVisibleRange();
}
}
}, [startIndex, endIndex, dynamicHeight, measureItemHeights, calculateVisibleRange]);
// 处理滚动事件
const handleScroll = useCallback((e) => {
const newScrollTop = e.target.scrollTop;
setScrollTop(newScrollTop);
}, []);
// 获取可见项目
const visibleItems = data.slice(startIndex, endIndex);
// 计算内容偏移量
const contentOffset = dynamicHeight ? itemOffsets[startIndex] : startIndex * itemHeight;
return (
<div
ref={containerRef}
style={{
height: `${height}px`,
overflow: 'auto',
position: 'relative',
border: '1px solid #ccc'
}}
onScroll={handleScroll}
>
<div
ref={scrollAreaRef}
style={{
height: `${totalHeight}px`,
position: 'relative'
}}
>
<div
ref={contentRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${contentOffset}px)`
}}
>
{visibleItems.map((item, index) => {
const actualIndex = startIndex + index;
return (
<div
key={actualIndex}
data-index={actualIndex}
style={dynamicHeight ? {} : { height: `${itemHeight}px` }}
>
{renderItem(item, actualIndex)}
</div>
);
})}
</div>
</div>
</div>
);
};
// 类型检查
VirtualList.propTypes = {
data: PropTypes.array.isRequired,
height: PropTypes.number,
itemHeight: PropTypes.number,
buffer: PropTypes.number,
renderItem: PropTypes.func.isRequired,
estimateHeight: PropTypes.number,
dynamicHeight: PropTypes.bool
};
// 使用示例
const VirtualListExample = () => {
// 生成示例数据
const [data, setData] = useState([]);
useEffect(() => {
// 生成10000条示例数据
const loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";
const words = loremIpsum.split(' ');
const items = [];
for (let i = 0; i < 10000; i++) {
// 随机生成内容长度,使项目高度不同
const wordCount = 5 + Math.floor(Math.random() * 30);
const randomContent = words.slice(0, wordCount).join(' ');
items.push({
id: i,
title: `项目 ${i + 1}`,
content: randomContent
});
}
setData(items);
}, []);
// 渲染项目
const renderItem = (item, index) => (
<div style={{
padding: '10px',
borderBottom: '1px solid #f0f0f0',
hover: {
backgroundColor: '#f5f5f5'
}
}}>
<h4 style={{ margin: '0 0 5px 0' }}>{item.title}</h4>
<p style={{ margin: 0, fontSize: '14px', color: '#333' }}>{item.content}</p>
</div>
);
return (
<div style={{ maxWidth: '800px', margin: '20px auto' }}>
<h2>React 虚拟滚动示例</h2>
<p>总项目数: {data.length}</p>
<p>当前渲染数: {Math.min(data.length, 30)}</p>
<VirtualList
data={data}
height={500}
renderItem={renderItem}
dynamicHeight={true}
estimateHeight={80}
buffer={5}
/>
</div>
);
};
export default VirtualListExample;
4.4.3 实践反例:虚拟滚动中的常见错误
4.4.3.1 反例1:过度优化固定高度列表
// 错误:为固定高度列表使用复杂的动态高度虚拟滚动实现
class OverengineeredVirtualScroller {
constructor(options) {
this.data = options.data;
this.itemHeight = options.itemHeight || 50; // 固定高度
this.dynamicHeight = true; // 错误:强制使用动态高度模式
this.itemHeights = new Array(this.data.length).fill(this.itemHeight);
this.itemOffsets = this.calculateOffsets();
// 其他初始化代码...
}
// 错误:为固定高度实现复杂的高度测量逻辑
measureItemHeights() {
const items = this.container.getElementsByClassName('list-item');
let hasChanged = false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const id = parseInt(item.dataset.id, 10);
const actualHeight = item.offsetHeight;
// 对于固定高度列表,这总是false,但仍在执行
if (this.itemHeights[id] !== actualHeight) {
this.itemHeights[id] = actualHeight;
hasChanged = true;
}
}
if (hasChanged) {
this.itemOffsets = this.calculateOffsets();
this.updateTotalHeight();
return true;
}
return false;
}
// 其他不必要的复杂方法...
}
问题:为固定高度的列表实现动态高度虚拟滚动是一种过度优化。动态高度实现中的高度测量、偏移量重新计算等逻辑会带来额外的性能开销,而这些开销对于固定高度列表来说是完全不必要的。简单的固定高度实现通常性能更好,代码也更简洁易维护。
4.4.3.2 反例2:忽略缓存机制导致重复计算
// 错误:没有缓存项目高度和偏移量,导致重复计算
class UnoptimizedVirtualScroller {
constructor(options) {
this.data = options.data;
this.itemHeight = options.itemHeight || 50;
// 错误:没有缓存高度和偏移量
// this.itemHeights = [];
// this.itemOffsets = [];
}
// 错误:每次计算可见范围都重新计算所有偏移量
calculateVisibleRange(scrollTop) {
// 每次都重新计算所有项目的偏移量
const offsets = [0];
let currentOffset = 0;
for (let i = 0; i < this.data.length; i++) {
currentOffset += this.itemHeight;
offsets.push(currentOffset);
}
// 然后计算可见范围...
let startIndex = 0;
while (startIndex < this.data.length && offsets[startIndex] < scrollTop) {
startIndex++;
}
// ...
return { startIndex, endIndex };
}
// 错误:每次渲染都重新计算所有内容
render(scrollTop) {
const { startIndex, endIndex } = this.calculateVisibleRange(scrollTop);
// 重新计算总高度
const totalHeight = this.data.length * this.itemHeight;
// 生成HTML
let html = '';
for (let i = startIndex; i < endIndex; i++) {
html += this.renderItem(this.data[i]);
}
// 更新DOM
this.scrollArea.innerHTML = html;
this.scrollArea.style.height = `${totalHeight}px`;
this.scrollArea.style.transform = `translateY(${startIndex * this.itemHeight}px)`;
}
}
问题:没有缓存项目高度和偏移量会导致在每次滚动时重新计算这些值,造成大量重复计算。对于包含10,000个项目的列表,每次滚动都需要执行10,000次计算,这会显著影响滚动性能,导致卡顿。正确的做法是缓存这些计算结果,只在必要时(如数据变化)才重新计算。
4.4.3.3 反例3:缓冲区设置不当
// 错误:缓冲区设置过小或过大
class BadBufferVirtualScroller {
constructor(options) {
this.data = options.data;
this.itemHeight = options.itemHeight || 50;
this.buffer = options.buffer || 0; // 错误:缓冲区设置为0
// 或者设置过大的缓冲区
// this.buffer = 100; // 对于大多数场景来说太大了
// 其他初始化代码...
}
calculateVisibleRange(scrollTop) {
const startIndex = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer);
const visibleCount = Math.ceil(this.containerHeight / this.itemHeight);
const endIndex = Math.min(this.data.length, startIndex + visibleCount + this.buffer);
return { startIndex, endIndex };
}
// 其他代码...
}
问题:
- 缓冲区设置过小(如0):当用户快速滚动时,会看到空白区域,因为新内容还没来得及渲染;
- 缓冲区设置过大(如100):会导致渲染过多不在可视区域内的项目,增加DOM节点数量和渲染时间,降低性能。
合适的缓冲区大小通常为3-5个项目,具体取决于项目高度和预期的滚动速度。
4.4.3.4 反例4:滚动事件处理不当
// 错误:滚动事件处理不当导致性能问题
class BadScrollHandlingScroller {
constructor(options) {
// 初始化代码...
this.bindEvents();
}
bindEvents() {
// 错误1:没有使用节流或防抖
this.container.addEventListener('scroll', (e) => {
this.handleScroll(e.target.scrollTop);
});
// 错误2:在滚动事件中执行重计算和DOM操作
this.handleScroll = (scrollTop) => {
this.scrollTop = scrollTop;
// 直接在滚动事件中执行渲染
this.renderVisibleItems();
};
}
renderVisibleItems() {
// 计算可见范围
const { startIndex, endIndex } = this.calculateVisibleRange();
// 生成HTML
let html = '';
for (let i = startIndex; i < endIndex; i++) {
html += this.renderItem(this.data[i]);
}
// 直接更新DOM
this.scrollArea.innerHTML = html;
this.scrollArea.style.transform = `translateY(${this.itemOffsets[startIndex]}px)`;
}
}
问题:滚动事件会在用户滚动时高频触发(通常每秒60次)。在滚动事件处理函数中直接执行计算和DOM操作会导致主线程被阻塞,影响滚动流畅度。正确的做法是使用requestAnimationFrame或节流(throttling)技术限制处理频率,确保浏览器有足够的时间进行渲染。
4.4.4 代码评审要点:虚拟滚动的检查清单
| 评审维度 | 检查要点 | 工具支持 |
|---|---|---|
| 实现选择 | 1. 是否根据列表项特性(固定/动态高度)选择了合适的实现? 2. 虚拟滚动是否真的必要(小列表可能不需要)? 3. 是否避免了过度设计和不必要的复杂性? | 1. 代码复杂度分析工具 2. 手动性能测试 |
| 性能优化 | 1. 是否缓存了项目高度和偏移量计算结果? 2. 滚动事件处理是否使用了requestAnimationFrame或节流? 3. 缓冲区大小是否合理(通常3-5个项目)? | 1. Chrome DevTools的Performance面板 2. FPS监控工具 |
| 数据处理 | 1. 大数据集是否采用了分页或按需加载? 2. 数据更新时是否高效地重新计算必要的部分? 3. 是否避免了在渲染循环中创建新函数或对象? | 1. 内存使用监控 2. 代码审查 |
| 用户体验 | 1. 快速滚动时是否会出现空白区域? 2. 滚动位置是否准确反映内容位置? 3. 虚拟滚动是否支持键盘导航和屏幕阅读器? | 1. 手动测试(包括快速滚动) 2. 可访问性测试工具 |
| 边界情况 | 1. 数据为空时是否有合理的 fallback 展示? 2. 数据量小于可视区域时是否正常工作? 3. 窗口大小变化时是否正确调整? | 1. 单元测试和集成测试 2. 边界情况手动测试 |
| 代码质量 | 1. 虚拟滚动逻辑是否与业务逻辑分离? 2. 是否有清晰的注释和文档? 3. 是否处理了可能的错误和异常? | 1. 代码审查 2. 静态代码分析工具 |
对话小剧场:虚拟滚动的那些事儿
场景:开发团队正在讨论一个电商应用的产品列表性能问题。
小美(前端开发):“最近很多用户反馈我们的产品列表页面滚动很卡,特别是在手机上。我看了一下,我们现在是一次性渲染所有产品,当产品数量超过1000时,页面上会有几千个DOM节点,肯定会卡。”
小迪(前端开发):“确实,我用Chrome的Performance面板分析了一下,滚动时帧率经常掉到30fps以下,主线程被长时间阻塞。我们应该用虚拟滚动来优化。”
大熊(后端开发):“虚拟滚动?是不是那种只加载用户能看到的内容的技术?我之前在一些图片网站上见过类似的效果。”
小美:“对,就是这样。传统方式会创建所有DOM节点,而虚拟滚动只渲染可视区域内的项目,能显著减少DOM节点数量。”
小稳(前端开发):“那我们直接用一个现成的虚拟滚动库吧,比如react-window或者vue-virtual-scroller,这些库都经过了充分测试和优化。”
小美:“我考虑过,但我们的产品卡片高度不固定,有些有折扣标签,有些有额外的描述信息,高度差异很大。很多基础虚拟滚动库只支持固定高度。”
小迪:“那我们需要实现动态高度的虚拟滚动。基本思路是先预估每个项目的高度,渲染可见项目后再测量实际高度,更新缓存并调整滚动位置。”
小燕(质量工程师):“从测试角度看,我担心几个问题:快速滚动时会不会出现空白?不同尺寸的屏幕上显示是否正常?还有,无障碍访问方面,屏幕阅读器能正确识别内容吗?”
小稳:“这些都是很好的问题。对于快速滚动,我们可以设置适当的缓冲区,在可视区域外多渲染几个项目。响应式方面,我们需要监听窗口大小变化,重新计算可视范围。无障碍访问可能需要额外处理,确保虚拟滚动的内容能被正确识别。”
大熊:“后端方面,我们可以支持分页加载,当用户滚动到接近列表底部时,再加载下一页数据,这样前端不需要一次性处理所有数据。”
小美:“这个主意不错,结合虚拟滚动和分页加载,性能会更好。另外,我们还需要考虑缓存机制,避免重复计算项目高度和偏移量。”
小迪:“我来设计一下实现方案:首先创建一个VirtualList组件,接收数据、渲染函数等参数;然后实现可见范围计算、项目渲染和滚动处理逻辑;最后添加动态高度测量和缓存更新机制。”
小燕:“开发完成后,我会重点测试以下场景:大量数据(10000+项目)的滚动性能、快速滚动时的表现、不同屏幕尺寸下的适配性、以及数据动态更新时的行为。”
小美:“听起来很全面。我们争取本周完成这个优化,然后进行A/B测试,看看性能提升效果。”
总结:多页面切换场景的响应速度优化之道
在多页面切换场景中,应用响应速度直接影响用户体验和留存率。本文介绍的四大优化策略从不同角度解决性能瓶颈:
-
路由优化:通过懒加载减少初始加载资源,通过缓存加速重复访问。合理的路由分割策略和缓存机制能使页面切换时间减少50%以上。
-
组件复用:减少组件创建和销毁的开销,保留DOM结构和组件状态。组件池和缓存机制能显著提升包含复杂组件的页面切换性能。
-
状态管理优化:避免不必要的状态更新和重渲染。通过合理的状态设计、精确的依赖管理和不可变数据模式,可减少70%以上的不必要重渲染。
-
虚拟滚动:解决长列表场景的性能问题。通过只渲染可视区域项目,将DOM节点数量减少95%以上,使滚动帧率从20fps提升到60fps。
这些策略并非孤立存在,而是相互配合、协同工作的。在实际项目中,应根据具体场景选择合适的优化组合,例如:
- 电商应用的产品列表页:路由懒加载 + 虚拟滚动 + 数据缓存
- 管理后台的多标签页:组件复用 + 状态管理优化
- 社交应用的消息列表:虚拟滚动 + 组件池 + 增量加载
性能优化是一个持续迭代的过程,建议结合性能监控工具(如Lighthouse、Chrome DevTools)定期评估应用性能,找出新的瓶颈并进行针对性优化。记住,良好的性能是优秀用户体验的基础,每100ms的响应速度提升都可能带来显著的业务指标改善。
在本系列的下一篇文章中,我们将探讨"内存优化",敬请期待。
更多推荐


所有评论(0)