1️⃣虚拟化渲染
只渲染用户当前能看到的那几行,其他的用空白占位。这样DOM节点数大大减少,性能飙升。
// 下载
npm install react-virtuoso
// 引入
import { Virtuoso } from 'react-virtuoso';
// 模拟数据
const generateDynamicItems = (count: number) => {
return Array.from({ length: count }, (_, index) => ({
id: index,
title: `Post ${index}`,
// 随机生成不同长度的内容
content: 'Lorem ipsum '.repeat(Math.floor(Math.random() * 20) + 1),
}));
};
const VirtuosoDynamicExample = () => {
const items = generateDynamicItems(5000);
return (
<div style={{ border: '1px solid #ccc', padding: '10px' }}>
<h3>Virtuoso (动态高度,自动测量)</h3>
<Virtuoso
style={{ height: '400px', width: '100%' }}
totalCount={items.length}
itemContent={(index) => {
const item = items[index];
return (
<div
style={{
padding: '12px',
borderBottom: '1px solid #eee',
backgroundColor: index % 2 ? '#f9f9f9' : '#fff',
}}
>
<h4 style={{ margin: '0 0 6px 0' }}>{item.title}</h4>
<p style={{ margin: 0 }}>{item.content}</p>
</div>
);
}}
// 增加上下预加载缓冲区,防止快速滚动出现空白
overscan={200}
/>
</div>
);
};
export default VirtuosoDynamicExample;
- 优点:简单、成熟、支持交互。
- 缺点:每个项还是DOM,如果列表项内部太复杂(比如嵌套图表、大量图片),DOM节点依然可能拖垮性能。
2️⃣Canvas 手绘列表
既然DOM是瓶颈,那我们干脆不用DOM!用 <canvas> 直接画!把列表项当成画布上的线条和文字,滚动时重绘视口内容。没有DOM节点,只有像素,性能直接起飞。
- 用一个
<canvas>覆盖整个列表容器。 - 监听滚动事件,计算当前应该显示哪些数据。
- 调用 Canvas API 绘制这些数据。
- 用占位
div撑开滚动条。
// 数据准备
const generateItems = (count: number) => Array.from({ length: count }, (_, i) => `Item ${i} - ${Math.random().toFixed(2)}`);
// 固定配置
const ITEM_HEIGHT = 30; // 每行高度
const LIST_HEIGHT = 200; // 容器高度
const TOTAL_COUNT = 10000; // 总数据量
// 滚动容器
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 渲染容器
const canvasWrapperRef = useRef<HTMLDivElement>(null);
// canvas实例
const canvasRef = useRef<HTMLCanvasElement>(null);
// 同步存储:数据 + 滚动偏移
const dataRef = useRef(generateItems(TOTAL_COUNT));
const scrollTopRef = useRef(0);
初始化 Canvas 画布
// 初始化 Canvas 尺寸(只执行一次)
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrapper = canvasWrapperRef.current;
if (!canvas || !wrapper) return;
const dpr = window.devicePixelRatio || 1;
const width = wrapper.clientWidth;
const height = LIST_HEIGHT;
// 强制设置 Canvas 尺寸(物理 + 显示)
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 初始化上下文
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
}
}, []);
初始化+窗口改变再次初始化
// 初始化 + 窗口resize
useEffect(() => {
initCanvas();
renderFrame();
// 监听resize
const handleResize = () => {
initCanvas();
renderFrame();
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [initCanvas, renderFrame]);
监听滚动事件
// 滚动监听:同步更新 + 立即绘制
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
// 同步更新滚动偏移
scrollTopRef.current = container.scrollTop;
// 强制逐帧绘制
requestAnimationFrame(renderFrame);
}, [renderFrame]);
// 监听滚动事件(直接绑DOM,跳过React合成事件)
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
核心的绘制方法
// 核心绘制:纯同步,无任何异步依赖
const renderFrame = useCallback(() => {
const canvas = canvasRef.current;
const wrapper = canvasWrapperRef.current;
const container = scrollContainerRef.current;
if (!canvas || !wrapper || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = wrapper.clientWidth;
const height = LIST_HEIGHT;
const scrollTop = scrollTopRef.current;
// 1. 清空画布(强制重绘,无残留)
ctx.clearRect(0, 0, width, height);
// 2. 计算可视范围(极简,无边界错误)
const firstIdx = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT));
const lastIdx = Math.min(firstIdx + Math.ceil(height / ITEM_HEIGHT) + 2, TOTAL_COUNT - 1);
// 3. 逐行绘制(坐标绝对正确)
for (let i = firstIdx; i <= lastIdx; i++) {
// y 坐标
const y = i * ITEM_HEIGHT - scrollTop;
// 背景
ctx.fillStyle = i % 2 ? "#f5f5f5" : "#ffffff";
ctx.fillRect(0, y, width, ITEM_HEIGHT);
// 文字
ctx.fillStyle = "#333";
ctx.font = "14px Arial";
ctx.textBaseline = "middle";
ctx.fillText(dataRef.current[i], 10, y + ITEM_HEIGHT / 2);
}
}, []);
UI 展示组件
const CanvasVirtualList = () => {
return (
{/* 1. 整体容器(超出隐藏,作为顶层定位元素) */}
<div
style={{
width: "100%",
height: LIST_HEIGHT,
position: "relative",
border: "1px solid #ccc",
overflow: "hidden",
}}
>
{/* 2. 滚动容器(只负责滚动,无视觉渲染) */}
<div
ref={scrollContainerRef}
style={{
width: "100%",
height: "100%",
overflowY: "auto",
opacity: 0,
position: "relative",
zIndex: 1,
}}
>
<div style={{ height: `${TOTAL_COUNT * ITEM_HEIGHT}px` }} />
</div>
{/* 3. Canvas容器(绝对定位覆盖,负责渲染) */}
<div
ref={canvasWrapperRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 0,
}}
>
<canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />
</div>
</div>
);
};
- 效果:丝般顺滑!10万条数据随便滚,CPU占用极低。
- 缺点:文本无法选中,无法直接绑定点击事件(需要自己用坐标判断),样式全靠Canvas API。
3️⃣离屏 Canvas + 双缓冲
上面的方案每次滚动都重绘整个可见区,如果列表项很复杂(比如带渐变、阴影),每次绘制可能还是会有点卡。怎么办?预绘制 + 双缓冲!
- 准备一个离屏Canvas(内存里的画布),尺寸比可视区大几倍(比如3倍高)。
- 在离屏Canvas上预先绘制好当前视口附近的数据。
- 当用户滚动时,直接从离屏Canvas裁剪相应区域绘制到主Canvas,省去了绘制逻辑。
- 当滚动超出预绘制范围时,重新生成离屏Canvas。
// 数据准备
const generateItems = (count: number) => Array.from({ length: count }, (_, i) => `Item ${i} - ${Math.random().toFixed(2)}`);
// 固定配置
const ITEM_HEIGHT = 30;
const LIST_HEIGHT = 200;
const TOTAL_COUNT = 10000;
// DOM 引用
const scrollContainerRef = useRef<HTMLDivElement>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// 离屏渲染(每个项独立缓存,避免尺寸关联)
const itemCacheRef = useRef<Map<number, HTMLCanvasElement>>(new Map()); // 缓存每个项的Canvas
const dataRef = useRef(generateItems(TOTAL_COUNT));
const scrollTopRef = useRef(0);
// 缓存容器宽度,避免频繁获取DOM
const containerWidthRef = useRef(0);
// 滚动监听(防抖+立即绘制)
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
scrollTopRef.current = container.scrollTop;
// 取消旧的绘制请求,避免重复绘制导致闪烁
cancelAnimationFrame(Number(container.dataset.rafId));
const rafId = requestAnimationFrame(renderFrame);
container.dataset.rafId = rafId.toString();
}, [renderFrame]);
// 滚动事件绑定
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
// 取消未执行的绘制请求
cancelAnimationFrame(Number(container.dataset.rafId));
};
}, [handleScroll]);
// 初始化主Canvas(解决布局+尺寸问题)
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrapper = canvasWrapperRef.current;
if (!canvas || !wrapper) return;
// 缓存容器宽度(后续绘制用)
containerWidthRef.current = wrapper.clientWidth;
const width = containerWidthRef.current;
const height = LIST_HEIGHT;
const dpr = window.devicePixelRatio || 1;
// 主Canvas核心配置(解决闪烁+尺寸)
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.style.display = "block"; // 消除inline布局的像素偏差
canvas.style.imageRendering = "pixelated"; // 避免模糊
// 初始化主上下文(只缩放一次)
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
}
// 清空旧缓存(容器尺寸变了,重新生成项Canvas)
itemCacheRef.current.clear();
}, []);
// 核心渲染(无闪烁+精准绘制)
const renderFrame = useCallback(() => {
const canvas = canvasRef.current;
const wrapper = canvasWrapperRef.current;
const container = scrollContainerRef.current;
if (!canvas || !wrapper || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = containerWidthRef.current;
const height = LIST_HEIGHT;
const scrollTop = scrollTopRef.current;
// 1. 单次清空(彻底解决闪烁):只清一次可视区域
ctx.clearRect(0, 0, width, height);
// 2. 精准计算可视范围(无边界错误)
const firstIdx = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT));
const lastIdx = Math.min(
firstIdx + Math.ceil(height / ITEM_HEIGHT) + 1, // 只多渲染1项,减少绘制量
TOTAL_COUNT - 1
);
// 3. 逐项绘制(从独立离屏Canvas复制,无拉伸)
for (let i = firstIdx; i <= lastIdx; i++) {
const itemCanvas = createItemCanvas(i);
const y = i * ITEM_HEIGHT - scrollTop; // 精准Y坐标
// 无缩放复制:源尺寸=目标尺寸,彻底解决拉伸
ctx.drawImage(
itemCanvas,
0,
0,
width,
ITEM_HEIGHT,
0,
y,
width,
ITEM_HEIGHT
);
}
}, [createItemCanvas]);
// 初始化单个项的离屏Canvas(独立尺寸,避免拉伸)
const createItemCanvas = useCallback((index: number) => {
if (itemCacheRef.current.has(index)) {
return itemCacheRef.current.get(index)!;
}
const canvas = document.createElement("canvas");
const dpr = window.devicePixelRatio || 1;
const width = containerWidthRef.current || 400; // 用缓存的容器宽度
// 关键:项Canvas尺寸 = 容器宽度 * DPR + 项高度 * DPR(不缩放)
canvas.width = width * dpr;
canvas.height = ITEM_HEIGHT * dpr;
const ctx = canvas.getContext("2d");
if (!ctx) return canvas;
// 绘制项内容(背景+文字,尺寸完全固定)
ctx.fillStyle = index % 2 ? "#f5f5f5" : "#ffffff";
ctx.fillRect(0, 0, width, ITEM_HEIGHT);
ctx.fillStyle = "#333";
ctx.font = "14px Arial"; // 固定14px,不会放大
ctx.textBaseline = "middle";
ctx.fillText(dataRef.current[index], 10, ITEM_HEIGHT / 2);
itemCacheRef.current.set(index, canvas);
return canvas;
}, []);
这种技术常用于地图、长列表的平滑滚动,尤其适合列表项非常复杂的场景。
4️⃣瓦片化渲染(Tile)
还记得地图是怎么做到无限缩放的吗?瓦片(Tile)!我们可以把列表也切成固定大小的“瓦片”,每个瓦片包含多行数据,预先渲染并缓存起来。滚动时只加载和绘制视口内的瓦片。
实现思路:
- 定义瓦片高度(如512px),每片包含若干行。
- 维护一个瓦片缓存:
Map<tileIndex, canvas>。 - 滚动时计算当前视口需要哪些瓦片索引。
- 如果缓存中没有,就创建离屏Canvas绘制该瓦片,存入缓存。
- 将所有需要的瓦片绘制到主Canvas的对应位置。
- 优点:缓存复用,重复区域不必重绘;适合无限滚动 + 复杂项。
- 缺点:需要管理缓存淘汰,实现稍复杂。
5️⃣WebGL/GPU 加速
当数据量达到百万级,即使Canvas的绘制也可能成为瓶颈(因为绘制指令还是CPU发出的)。这时候就要请出GPU了!用WebGL直接操作GPU,每个列表项作为一个“图元”或“纹理”,利用并行计算疯狂输出。
常用库:
- PixiJS:基于WebGL的2D渲染引擎,API友好,可以轻松创建数千个Sprite并流畅交互。
- Three.js:主要用于3D,但也可以做2D。
- regl:函数式WebGL,极致性能,但学习曲线陡。
用 PixiJS 渲染大列表(示例)
import { useEffect, useRef } from 'react';
import * as PIXI from 'pixi.js';
const PixiList = ({ items, itemHeight = 30 }) => {
const containerRef = useRef();
useEffect(() => {
// 创建Pixi应用
const app = new PIXI.Application({
width: 800,
height: 600,
backgroundColor: 0xf5f5f5,
resolution: window.devicePixelRatio || 1,
});
containerRef.current.appendChild(app.view);
// 创建文本样式
const style = new PIXI.TextStyle({ fontSize: 16, fill: '#333' });
// 批量创建文本对象(Pixi内部会优化渲染)
for (let i = 0; i < items.length; i++) {
const text = new PIXI.Text(items[i], style);
text.x = 10;
text.y = i * itemHeight;
app.stage.addChild(text);
}
// 添加一个遮罩,实现视口裁剪
const mask = new PIXI.Graphics();
mask.beginFill(0xffffff);
mask.drawRect(0, 0, 800, 600);
mask.endFill();
app.stage.mask = mask;
// 添加滚动事件(需要自己实现)
// ...
return () => app.destroy(true);
}, [items]);
return <div ref={containerRef} />;
};
体验:百万条数据滚动依然60帧!而且可以轻松绑定交互事件(Pixi支持点击检测)。但要注意,如果创建太多Pixi对象,内存也可能爆掉,所以还是要结合虚拟化(只创建可见区域的对象)。
方案对比与选型指南 📊
| 方案 | 数据量级 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 虚拟滚动 | 1k - 10w | 简单、支持交互、CSS样式 | DOM仍有开销,复杂项可能卡 | 大部分业务列表 |
| Canvas 即时绘制 | 10w - 百万 | 无DOM限制,绘制快 | 交互难,文本不可选 | 纯展示型数据,如日志 |
| 离屏Canvas | 10w - 百万 | 滚动更平滑,适合复杂项 | 内存占用稍大 | 需要流畅滚动的复杂列表 |
| 瓦片化渲染 | 百万+ | 缓存复用,性能极佳 | 实现复杂,需管理缓存 | 地图式列表、无限滚动 |
| WebGL (Pixi) | 百万+ | GPU加速,可交互 | 学习成本高,内存管理重要 | 超大规模可视化、游戏 |