背景
在前端开发中,渲染大规模列表数据时,性能问题是一个重要的挑战。直接渲染所有数据会导致很多性能问题比如:
- 内存占用过多
据统计,10000个DOM节点占用约400MB内存
, - 重绘重排风暴:每次滚动触发全局样式计算
- GPU过载现象:复合图层超出显存承载能力
- 交互延迟痛点:点击响应时间超过300ms人机工程红线 导致 DOM 过载、页面卡顿,甚至崩溃。
此时我们就需要虚拟滚动(Virtual Scrolling)来解决这一问题, 通过仅渲染可见区域的数据,显著提升性能和用户体验。
什么是虚拟滚动
虚拟滚动是一种优化长列表渲染的技术,它的核心思想是:
- 只渲染可见区域的列表项,避免一次性加载所有数据。
- 动态替换渲染内容,随着用户滚动,仅更新当前视图内的数据,而不改变整体的滚动行为。
虚拟滚动的基本原理
虚拟滚动列表的核心逻辑如下:
- 测量容器和每个列表项的高度,计算可见区域内的元素数量。
- 动态渲染可见元素,隐藏超出可视范围的部分。
- 调整占位符的高度,确保滚动条行为符合预期,即在滚动时滚动条不会抖动。
- 监听滚动事件,更新可见列表项。
- 做好防抖处理
在我们实现这个基本的容器组件时,一般只实现交互效果和数据渲染,对于具体的渲染数据以及列表中的每一项我们应该暴露给组件的调用者去决定。
初步实现虚拟列表滚动
实现虚拟滚动的关键几个点在于
- 容器高度:viewportHeight
- 单个列表项高度:itemHeight
- 列表总数据量:totalItems
- 维持高度避免动态渲染的时候导致高度坍塌
- 根据滚动来提前渲染元素提高用户体验
整个视图如下:
其中有几个问题需要注意
- 当获取到元素数量时,我们根据每个元素的高度可以计算出需要渲染列表的高度,当我们进行滚动时可以使用
transform: translateY(${start * itemHeight}px)
来实现高性能的滚动,
transform 可以使用 GPU 加速。 - 每当用户滚到一定的程度时我们可以提前一段距离加载数据,有利于提升用户体验,不至于长时间的Loading,而计算出要加载的时机就是滚动的高度加上视口的高度达到总高度减去400(提前加载)时就会触发加载
- 当用户快速滚动时可能会频繁的触发加载事件,所以我们最好做一个防抖的作用。
完整的实现代码如下:
import { useEffect, useRef, useState, useCallback } from 'react';
import './VirtualList.css';
const VirtualList = ({
items,
itemHeight = 50,
windowHeight = 700,
renderItem,
loadMore,
}) => {
const [start, setStart] = useState(0);
const [visibleItems, setVisibleItems] = useState([]);
const [isLoading, setIsLoading] = useState(false); // 改用 state 来管理加载状态
const containerRef = useRef(null);
const timeoutRef = useRef(null);
const contentRef = useRef(null);
// 计算可视区域能显示的项目数量
const visibleCount = Math.ceil(windowHeight / itemHeight);
// 使用防抖的 loadMore 函数
const debouncedLoadMore = useCallback(() => {
if (isLoading) return;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsLoading(true);
loadMore?.();
// 1秒后重置加载状态
setTimeout(() => {
setIsLoading(false);
}, 1000);
}, 100);
}, [loadMore, isLoading]);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// 处理滚动事件
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const scrollTop = containerRef.current.scrollTop;
const firstVisibleIndex = Math.floor(scrollTop / itemHeight);
setStart(firstVisibleIndex);
// 修改加载触发条件
const clientHeight = containerRef.current.clientHeight;
const contentRefHeight = contentRef.current.clientHeight;
const scrolledToLoad = scrollTop + clientHeight >= contentRefHeight - 400; // 提前400px触发
if (scrolledToLoad && !isLoading) {
debouncedLoadMore();
}
}, [itemHeight, debouncedLoadMore, isLoading]);
// 更新可视项目
useEffect(() => {
const startIndex = Math.max(0, start);
const endIndex = Math.min(items.length, start + visibleCount);
const visibleData = items.slice(startIndex, endIndex);
setVisibleItems(visibleData.map((item, index) => ({
...item,
originalIndex: startIndex + index
})));
}, [start, items, visibleCount]);
return (
<div
ref={containerRef}
className="lazy-list-container"
style={{ height: windowHeight }}
onScroll={handleScroll}
>
<div
ref={contentRef}
className="lazy-list-content"
style={{ height: items.length * (itemHeight) }}
>
<div
className="lazy-list-items"
style={{
transform: `translateY(${start * itemHeight}px)`,
}}
>
{visibleItems.map(item => (
<div
key={item.originalIndex}
className="lazy-list-item"
style={{ height: itemHeight }}
>
{renderItem(item, item.originalIndex)}
</div>
))}
</div>
</div>
</div>
);
};
export default VirtualList;
完整的效果如下所示:
可以看到每次滚动时在页面上只会渲染视口内的几条数据,对于不在视口内的数据是不渲染的
初步实现虚拟滚动在线Demo
进一步优化
上面基本实现了虚拟滚动的效果,但还可以进一步的优化。
使用IntersectionObserver
IntersectionObserver
提供了一种异步观察目标元素与其祖先元素或顶级文档视口(viewport)交叉状态的方法。上面在scroll
事件实际需要处理很多细节比如:
- 节流/防抖
- 计算位置
- 处理各种边界情况
- 优化性能
- 清理监听器
而对应的IntersectionObserver
具有很多优势:
- 性能更好:减少不必要的计算,避免频繁的 DOM 查询,浏览器级别的优化
- 更少的资源消耗:减少内存使用,减少 CPU 使用
- 更精确的控制:准确的进入/离开检测,可配置的触发条件,更少的误触发
- 代码更简洁:无需手动优化,更少的边界情况,更容易维护
- 浏览器原生支持:
他有三个只读实例属性,分别是root
、rootMargin
、thresholds
,其含义如下:
- root: 用作边界盒的元素或者文档,默认值是顶级文档
- rootMargin: 计算交叉时添加到根边界盒的偏移量,可以用具体的像素或者是百分比来表达
- thresholds: 一个包含阈值的列表,升序排列,列表中的每个阈值都是监听对象的交叉区域与边界区域的比率。默认值是0
用一张图来表示三个参数的含义如下:
我们可以在列表的底部设置一个div
元素用于被观察,一旦这个被观察的元素进入我们设置的扩展区域之内我就就触发重新绘制可视区域内展示数据的事件,这样我们就不用监听scroll
事件了。
当我们渲染完数据之后展示在视口内,随着用户的向上滚动,底部被观察的元素进入IntersectionObserver
的扩展区域,此时就更新视口内的数据。整个过程如下所示:
监听底部的核心代码如下:
// 监听底部 IntersectionObserver
useEffect(() => {
if (!loaderRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore?.();
}
},
{
root: scrollContainerRef.current,
rootMargin: "200px", // 提前触发距离
threshold: 0 // 任何可见度都触发
}
);
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [loadMore]);
先实例化一个IntersectionObserver对象,设置一些阈值,这里容器元素就是scrollContainerRef.current
扩展区域设置了200px
在被观察元素刚刚进入扩展区间时就去加载并更新视口内的数据,其中isIntersecting
是被观察元素的一个属性,表被观察元素是否在扩展区域内可见。还有一些实用的性能优化小技巧,比如使用willChange & transform
来提高页面的性能,其中willChange
它用来提前告诉浏览器元素将要进行的变化,而让浏览器提前做好优化准备, transform
可以强制 GPU 加速。整体的代码如下:
/* eslint-disable react/prop-types */
import { useState, useRef, useEffect, useCallback } from "react";
const BUFFER_SIZE = 5; // 额外缓冲区,避免滚动时的闪烁
const InfiniteScrollList = ({
items,
itemHeight = 100,
containerHeight = 700,
renderItem,
loadMore,
// hasMore = false
}) => {
const [scrollTop, setScrollTop] = useState(0);
const loaderRef = useRef(null);
const scrollContainerRef = useRef(null);
// 计算可见区域的索引范围
const visibleItemCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - BUFFER_SIZE);
const endIndex = Math.min(
items.length,
startIndex + visibleItemCount + BUFFER_SIZE
);
// 获取可见的列表项
const visibleItems = items.slice(startIndex, endIndex);
// 优化的滚动处理函数
const handleScroll = useCallback((e) => {
setScrollTop(e.target.scrollTop);
}, []);
// 监听底部 IntersectionObserver
useEffect(() => {
if (!loaderRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore?.();
}
},
{
root: scrollContainerRef.current,
rootMargin: "200px", // 提前触发距离
threshold: 0 // 任何可见度都触发
}
);
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [loadMore]);
return (
<div
ref={scrollContainerRef}
style={{
height: containerHeight,
overflowY: "auto",
border: "1px solid #ddd",
position: "relative",
}}
onScroll={handleScroll}
>
{/* 总高度容器 */}
<div
style={{
height: items.length * itemHeight,
position: "relative",
willChange: "transform"
}}
>
{/* 可视区域容器 */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${startIndex * itemHeight}px)`,
willChange: "transform",
transform:"translateZ(0)"
}}
>
{/* 渲染可见元素 */}
{visibleItems.map((item) => (
<div
key={item.id}
style={{
height: itemHeight,
padding: "8px",
boxSizing: "border-box",
borderBottom: "1px solid #eee",
transform: "translateZ(0)", // 强制 GPU 加速
willChange: "transform"
}}
>
{renderItem(item)}
</div>
))}
</div>
</div>
<div
ref={loaderRef}
style={{
height: "10px",
background: "transparent"
}}
/>
</div>
);
};
export default InfiniteScrollList;
整个代码效果如下:
优化版本虚拟滚动
虚拟表格的实现
可以使用同样的套路来实现虚拟表格的滚动,除去表头外,表格的滚动其实就可以看做是一个虚拟列表。和上面的套路类似,我们在只渲染表格内可见的元素。对于不可见的元素为了防止在滚动的时候滚动条滚动,我们要在表格的头部和尾部做好占位。整个示意图如下:
对于顶部的站位行的计算可以用每一行的高度乘以开始渲染的函数,对于底部的站位行的高度可以使用数据总数减去endIndex
再乘以高度来展示。中间body
部分展示的就是视口内可见的元素。关于这两个部分的关键代码如下:
spacer: {
height: startIndex * rowHeight,
},
bottomSpacer: {
height: (data.length - endIndex) * rowHeight,
}
在刚开始,展示的第一行元素从0开始,展示的最后一行元素可以根据容器的高度除以每行的高度来计算。有了开始和结束的行,就可以从数组中截取需要展示的行数据了。这个计算我们可以使用useMemo
部分进行缓存以提高性能,这部分的关键代码如下
// 使用 useMemo 优化计算
const { visibleRows, startIndex, endIndex } = useMemo(() => {
const visibleRowCount = Math.ceil(containerHeight / rowHeight);
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - bufferSize);
const end = Math.min(data.length, start + visibleRowCount + bufferSize);
return {
startIndex: start,
endIndex: end,
visibleRows: data.slice(start, end)
};
}, [scrollTop, data.length, containerHeight, rowHeight, bufferSize]);
在滚动的时候我们如果直接在 scroll 事件回调中执行 setState 或 DOM 操作,可能会导致 高频调用,进而造成 UI 卡顿。
requestAnimationFrame 通过 与浏览器刷新周期同步(一般 60FPS,对应约 16.67ms 一次),让操作在最适合的时机执行,提升渲染效率。可以使用requestAnimationFrame
来提高页面的性能,使用requestAnimationFrame
有以下好处:
- 防止高频触发:scroll 事件可能每毫秒触发多次,requestAnimationFrame 只会在浏览器下一次绘制前更新 scrollTop,减少 setState 调用次数。
- 取消未执行的帧:使用 cancelAnimationFrame(rafRef.current) 确保上一个未完成的帧被取消,避免重复执行。
- 提高流畅度:由于更新节奏与浏览器刷新同步,可以减少不必要的重排(Reflow)和重绘(Repaint),提升性能。
这部分的关键代码如下:
// 优化的滚动处理
const handleScroll = useCallback((e) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
setScrollTop(e.target.scrollTop);
});
}, []);
// 清理 RAF
useEffect(() => {
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);
我们在视口元素内部添加了一个观察元素,一旦这个元素进入可视区域,就会触发新的加载。这个和之前的套路一样,其关键代码如下:
// 监听底部加载更多
useEffect(() => {
if (!loaderRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
onLoadMore?.();
}
},
{
root: containerRef.current,
rootMargin: '200px',
threshold: 0
}
);
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [onLoadMore]);
完整可运行的代码如下:
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
const VTable = ({
data,
columns,
rowHeight = 40,
containerHeight = 400,
bufferSize = 5,
onLoadMore
}) => {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const loaderRef = useRef(null);
const rafRef = useRef(null);
// 使用 useMemo 优化计算
const { visibleRows, startIndex, endIndex } = useMemo(() => {
const visibleRowCount = Math.ceil(containerHeight / rowHeight);
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - bufferSize);
const end = Math.min(data.length, start + visibleRowCount + bufferSize);
return {
startIndex: start,
endIndex: end,
visibleRows: data.slice(start, end)
};
}, [scrollTop, data.length, containerHeight, rowHeight, bufferSize]);
// 优化的滚动处理
const handleScroll = useCallback((e) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
setScrollTop(e.target.scrollTop);
});
}, []);
// 清理 RAF
useEffect(() => {
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);
// 监听底部加载更多
useEffect(() => {
if (!loaderRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
onLoadMore?.();
}
},
{
root: containerRef.current,
rootMargin: '200px',
threshold: 0
}
);
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [onLoadMore]);
const tableStyles = {
container: {
height: containerHeight,
overflow: 'auto',
position: 'relative',
border: '1px solid #eee',
},
table: {
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
position: 'relative'
},
thead: {
position: 'sticky',
top: 0,
zIndex: 1,
backgroundColor: '#f5f5f5',
},
th: {
padding: '12px 8px',
textAlign: 'center',
fontWeight: 'bold',
borderBottom: '2px solid #ddd',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
td: {
padding: '8px',
textAlign: 'center',
borderBottom: '1px solid #eee',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
spacer: {
height: startIndex * rowHeight,
},
bottomSpacer: {
height: (data.length - endIndex) * rowHeight,
}
};
return (
<div ref={containerRef} style={tableStyles.container} onScroll={handleScroll}>
<table style={tableStyles.table}>
<thead style={tableStyles.thead}>
<tr>
{columns.map((column) => (
<th
key={column.key}
style={{
...tableStyles.th,
width: `${(column.width || 1) * 100}px`
}}
>
{column.title}
</th>
))}
</tr>
</thead>
<tbody>
{/* 顶部空白站位 */}
<tr>
<td colSpan={columns.length} style={tableStyles.spacer} />
</tr>
{visibleRows.map((row, index) => (
<tr
key={row.id}
style={{
height: rowHeight,
backgroundColor: index % 2 === 0 ? '#fff' : '#fafafa',
}}
>
{columns.map((column) => (
<td
key={column.key}
style={{
...tableStyles.td,
width: `${(column.width || 1) * 100}px`
}}
>
{row[column.key]}
</td>
))}
</tr>
))}
<tr>
<td colSpan={columns.length} style={tableStyles.bottomSpacer} />
</tr>
</tbody>
</table>
{/* 加载更多触发器 */}
<div
ref={loaderRef}
style={{
height: '10px',
background: 'transparent'
}}
/>
</div>
);
};
export default VTable;
整体效果如下:
懒加载的实现
有了上面的基础,实现一个图片的懒加载就非常简单了。其核心点在于判断当前的元素是否在视口内,如果在视口内就返回完整的img
标签即可。否则返回一个占位符即可,整个代码如下:
import { useEffect, useRef, useState } from 'react';
const LazyLoadImage = ({ src, alt, className }) => {
// 创建图片容器的引用,用于观察元素是否进入视口
const imgRef = useRef(null);
// 添加一个状态来控制是否在视口中
const [isInView, setIsInView] = useState(false);
useEffect(() => {
// 创建 IntersectionObserver 实例
const observer = new IntersectionObserver(
(entries) => {
// 根据是否在视口中来设置状态
setIsInView(entries[0].isIntersecting);
},
{
root: null,
// 设置交叉比例阈值,元素出现 10% 就触发回调
threshold: 0,
// 设置根元素的外边距,提前 50px 开始加载
rootMargin: '10px',
}
);
// 如果元素存在,开始观察
if (imgRef.current) {
observer.observe(imgRef.current);
}
// 组件卸载时的清理函数
return () => {
if (imgRef.current) {
observer.unobserve(imgRef.current);
}
};
}, []);
return (
<div ref={imgRef} className={className}>
{/* 根据加载状态显示图片或占位符 */}
{isInView ? (
<img src={src} alt={alt} className={className} />
) : (
<div className="placeholder">Scroll to load</div>
)}
</div>
);
};
export default LazyLoadImage;
效果如下:
可以看到只有当div
标签进入预设的视口内才会插入img
标签进行图片的加载。
图片懒加载在线Demo
这里我们同样可以使用虚拟列表的方式来对图片进行加载这样也是可以的。
参考
初步实现虚拟滚动在线Demo
优化版本虚拟滚动
虚拟表格在线Demo
图片懒加载在线Demo
IntersectionObserver资料