前端巨型列表渲染

0 阅读6分钟

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;
image.png
  • 优点:简单、成熟、支持交互。
  • 缺点:每个项还是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>
    );
};
image.png
  • 效果:丝般顺滑!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)!我们可以把列表也切成固定大小的“瓦片”,每个瓦片包含多行数据,预先渲染并缓存起来。滚动时只加载和绘制视口内的瓦片。

实现思路

  1. 定义瓦片高度(如512px),每片包含若干行。
  2. 维护一个瓦片缓存:Map<tileIndex, canvas>
  3. 滚动时计算当前视口需要哪些瓦片索引。
  4. 如果缓存中没有,就创建离屏Canvas绘制该瓦片,存入缓存。
  5. 将所有需要的瓦片绘制到主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限制,绘制快交互难,文本不可选纯展示型数据,如日志
离屏Canvas10w - 百万滚动更平滑,适合复杂项内存占用稍大需要流畅滚动的复杂列表
瓦片化渲染百万+缓存复用,性能极佳实现复杂,需管理缓存地图式列表、无限滚动
WebGL (Pixi)百万+GPU加速,可交互学习成本高,内存管理重要超大规模可视化、游戏