在 React 中通过绘制渲染百万行数据

2025-06-09

在 React 中通过绘制渲染百万行数据

介绍

几周前,我偶然发现了一项名为“十亿行挑战”的挑战。我发现它从两个角度来看很有趣:

  1. 如果我在前端尝试这个挑战,会发生什么?
  2. 我能完成它吗?

我不相信我们能在一张表上渲染数十亿行数据,但我认为我们可以处理一百万行数据。没错。在了解了上述挑战之后,我开始了一个小型项目,用 React 渲染一百万行数据。

让我向你详细介绍一下发生了什么、如何发生以及为什么发生 🙂

先决条件

我建议大家阅读以下主题,以便更好地掌握该主题:

什么?

我们正在尝试构建一个组件,帮助我们在 ReactJs 应用中渲染一百万行数据。我们利用了一些其他产品(例如Google SheetsGlide 数据网格应用)正在使用的技术来实现它。

为什么?

我在上面的介绍部分解释了为什么这样做,但还有更多原因。

我想大家都可能遇到过这样的情况:使用虚拟化技术只渲染表视口中的行。这是一个很常见的用例,例如通过这种技术渲染大量数据。

但是,当你将窗口大小设置得很大(例如需要一次性查看 150 到 250 行数据)时,这种技术可能会变得有点棘手/危险。这会告诉虚拟化算法针对这些行执行以下操作:

  • 删除视口中的所有 DOM 元素
  • 然后添加下一组 150 多个 DOM 元素。

在滚动时执行此操作可能会非常昂贵,并且会使主线程陷入困境,从而导致用户体验不佳。

除此之外,我还探索了其他工具和库,例如:

他们已经出色地完成了一百万行的渲染工作。

现在您知道了这些原因,那么让我们了解解决这个常见问题的不寻常方法。

💡 注意:本博客旨在解释构建此项目所需的所有关键概念。您可以在此存储库中查看完整代码

怎么办?

为了实现这一点,我们将采用绘制表格而不是渲染表格的方法。我们将借助 Canvas 元素绘制每一行数据。

Canvas HTML 元素是任何绘图操作的首选元素。它的上下文 API提供了许多函数,可帮助您绘制任何您喜欢的形状。

现在我们知道要使用什么了,接下来让我们了解一下实现步骤。实现步骤分为三个简单的部分:

  • 加载数据
  • 初始化画布
  • 在卷轴上绘制数据

我们将深入讲解每个步骤并理解它们。为了保持本博客的简洁,我将尝试以直观的方式解释上述所有步骤,并尽量减少代码编写部分。

我希望你们能够浏览代码并从上面的注释部分查看它。

初始化项目

我使用了 React.js 项目入门工具包:Vite.js。它将帮助你为你的项目创建脚手架

我已经使用了typescript模板,要执行相同的操作,请按照以下教程操作:https://vitejs.dev/guide/#scaffolding-your-first-vite-project

接下来,请参考包含项目完整代码的存储库以熟悉它。

加载数据

加载数据用户界面
用于加载数据的 UI

此步骤涉及创建几个按钮,用于从远程源下载数据。因此,在 UI 上,我们有 4 个按钮,分别用于下载相同的数据,但行数不同,即 100 行、0.5M 行、1M 行和 2M 行。

只需单击按钮,即可在papa-parse的帮助下下载数据并将其解析为对象数组。

理解 DOM 结构

在我们进入下一步之前,我想先退一步解释一下我们要绘制的表格的 DOM 结构。

它看起来就像一个普通的表格,如下所示:

表格用户界面
表格用户界面

它看起来就像一个带有标题、行和滚动条的普通表格。

该表的图像分为以下几个部分,每个部分代表项目中的 DOM 元素:

  • header-canvas- 它是我们绘制表格标题的画布元素。
  • target-canvas- 它是绘制表格实际行的画布元素
  • scrollbar-container- 它是一个 div 元素,为main-container
  • main-container- 一个包裹header-canvastarget-canvas和 的div 元素scrollbar-container

为了准确概述这些元素,这里有一个 gif:

DOM 结构拆解
DOM 结构拆解

您可以在此处从代码的角度查看 DOM 结构

初始化画布

现在我们已经了解了它们的含义,让我们深入了解初始化两个画布的步骤。初始化步骤如下:

  1. 每当安装组件时,我们都会初始化一个 Web 工作者,如下所示:

    /**
         * On component mount, initialze the worker.
         */
        useEffect(() => {
            if (window.Worker) {
                // Refer to the Vite's Query Suffix syntax for loading your custom worker: https://vitejs.dev/guide/features.html#import-with-query-suffixes
                const worker = new CustomWorker();
                workerRef.current = worker;
            }
        }, []);
    
  2. 接下来,当 CSV 数据可用时,我们运行更新的效果header-canvas,然后将传递target-canvaswebworker

    /**
         * This effect runs when the downloaded data becomes available.
         * It has the following purpose:
         * 1. Draw the table header on #header-canvas
         * 2. Transfer the control to the worker
         */
        useEffect(() => {
            const canvas = canvasRef.current;
            const headerCanvas = headerCanvasRef.current;
    
            if (headerCanvas) {
                const headerContext = headerCanvas.getContext("2d");
                const { width, height } = DEFAULT_CELL_DIMS;
                const colNames = CustomerDataColumns;
    
                if (headerContext) {
                    headerContext.strokeStyle = "white";
                    headerContext.font = "bold 18px serif";
    
                    for (let i = 0; i < DEFAULT_COLUMN_LENGTH; i++) {
                        headerContext.fillStyle = "#242424";
                        headerContext.fillRect(i * width, 0, width, height);
                        headerContext.fillStyle = "white";
                        headerContext.strokeRect(i * width, 0, width, height);
                        headerContext.fillText(colNames[i], i * width + 20, height - 10);
                    }
                }
            }
    
            /**
             * We transfer two things here:
             * 1. We convert our #canvas that draws the actual table to an offscreen canvas
             * 2. We use the transfer the above canvas to the worker via postMessage
             */
            if (workerRef.current && csvData && canvas) {
                const mainOffscreenCanvas = canvas.transferControlToOffscreen();
                workerRef.current.postMessage(
                    {
                        type: "generate-data-draw",
                        targetCanvas: mainOffscreenCanvas,
                        csvData,
                    },
                    [mainOffscreenCanvas]
                );
            }
        }, [csvData]);
    

这里需要注意的是,我们将 转换target-canvasoffscreencanvas。offscreencanvas 类似于 canvas 元素,但它与 DOM 解耦。您甚至可以使用new关键字创建画布并将其传递给 worker。

离屏画布的有趣之处在于,它也可以在 Worker 的 context 中使用。这样,它也允许在 Worker 中使用画布的 context API。

在我们的例子中,我们借助函数(参见此处)将 转换target-canvas为画布。这样,如果我尝试使用来自 worker 的 context API 函数绘制一个矩形,那么它就会出现在 DOM 中的主画布上。offscreentransferControlToOffscreenfillRect

要了解有关屏幕外画布 API 的更多信息,请阅读此处

我们在此步骤中所做工作的图形摘要。
我们在此步骤中所做工作的图形摘要。

大脑时间

系好安全带,伙计们!因为在本节中,我们将深入理解和掌握大量的概念,以便您能够理解项目中的代码库。

本节将讨论在target-canvas滚动时绘制数据的整个机制。

了解Scrollbar-container

因此首先让我们了解我们的特殊容器,即scrollbar-container我们在上一节中讨论的容器。

因此,如果一个普通元素的and属性设置为px (即静态值),并且其子元素的高度超过其父元素,div那么它将具有滚动条widthheightxheight

但是让我问你们一个问题:你们有没有遇到过这样的情况:里面没有任何溢出的内容div但仍然想要滚动条?

有几种解决方案,例如:使用自定义滚动条库,例如simplebarOverlayScrollbars因此,即使您使用这些库,仍然存在一种情况,即您希望将容器滚动到多少高度,即为 div 设置自定义可滚动高度。

但是我们如何实现这样的功能呢?其实比你想象的要简单得多。我在探索拥有 50 万行数据的 Google 表格时学到了这个技巧。

在 Google 表格中,它们有一个div宽度等于 的元素,1px但这个 div 的高度等于rows*rowHeight。在 Google 表格中,它div被放置在其父容器内。这有助于他们实现自定义可滚动高度,并且内容会溢出。

所以我用了同样的方法,用了这个虚拟的 div,以及width = 1pxheight = rows*rowHeight。这就是 的scrollbar-container组成。

以下是滚动条容器的图形表示:

滚动条容器
滚动条容器

需要注意的是,这个div是一个隐藏的div,即visibility: hidden

您可以在此处的scrollbar-container代码库中查看这一点

理解绘图机制

现在我们来到了主要部分,也就是绘制机制。在深入探讨之前,我想先说明一下,所有绘制操作都target-canvas发生在工作线程中。还记得我们讨论过在组件挂载时初始化一个工作线程吗?这个工作线程就是我们讨论的那个。你可以在代码库中找到正在初始化的工作线程

整个绘图机制(又称工作者代码)可以在这里找到。

现在我们明白了,绘制行是工人的职责,target-canvas因此让我们深入研究它的机制。

我想借助三种不同的方法来解释这种机制:

方法 1

在这种方法中,一旦将整个数据加载到内存中,我们就会直接将其绘制到我们的上target-canvas

方法 1
方法 1

在这种情况下会发生什么?你猜怎么着?

你猜对了,所有 1M 行都会被绘制到target-canvas之前绘制的像素上,并给我们如下所示的覆盖图像:

方法 1
方法 1

因此,正如您所见,画布会自行重绘,从而导致图像扭曲。因此,这种方法很糟糕,不应采用。

方法 2

在这种方法中,我们不是一次性绘制所有数据,而是将一块数据绘制到画布上target-canvas。我们可以将块大小设置为画布上可容纳的行数。

这是在画布上绘制一个块的直观表示:

方法 2
方法 2

我们在向下滚动数据并绘制每一行时都会这样做。但在采用这种方法之前,需要考虑以下几点:

  • 要绘制一行,您需要绘制其中所有与列数相等的单元格。
  • 此外,要绘制每个单元格,您需要:
    • 首先,使用 clearRect 清除画布上的单元格区域。
    • 然后使用[strokeRect](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeRect)函数来绘制单元格
    • 最后,我们使用fillRect函数来填充数据。
  • 所有这些步骤都会发生在我们绘制的每个单元格上,当您向下滚动数据时,它会发生在每一行和每个块上。
  • 这是一项非常昂贵的手术。

我使用了另一种方法来实现这一点,并且不会降低性能。让我们来研究一下。

方法 3

在这个方法中,我们利用了offscreen canvasAPI。因此算法如下:

  • 每个屏幕外画布将由从 CSV 数据中绘制的 100 行组成。
  • 这些画布是根据滚动位置创建的。因此scrollTop,我们根据滚动位置计算出已滚动的行数。
  • 根据这个数字,我们计算出需要在画布上绘制的行数范围。例如,如果scrollTop = 150,那么我们选择从 100 到 200 的行,并将它们绘制到新创建的屏幕外画布上。
  • 我们还生成了另一个屏幕外画布,用于绘制接下来的 100 行数据。从上面的示例来看,下一个画布将包含从 200 行到 300 行的数据。
  • 我们将滚动过程中的所有这些画布以全局状态存储在工作者中,您可以在此处查看
  • 画布准备就绪后,我们只需取出当前屏幕外画布的一块,并将其作为图像绘制到 上即可target-canvas。这里的块大小等于 内可容纳的行数target-canvas
  • 当我说我们从当前的屏幕外画布中取出一块区域时,我的意思是我们将这块区域从屏幕外画布中复制为一张图片,并target-canvas借助drawImage函数将其绘制到画布上。此操作称为位块传送。您可以在此处找到更多相关信息

为了直观地理解这一点,这里有一个小动画可以使事情变得更清楚:

方法 3
方法 3

您在此处看到的蓝色动画是滚动条位置在两个屏幕外画布之间相交的场景。例如,如果您已经完成了整个第一个屏幕外画布的绘制,那么target-canvas对于剩余的部分,target-canvas您需要在下一个屏幕外画布上以相同的行数进行绘制。

这创造了滚动时连续数据可见性的体验,从而提供了一致的体验。

因此,通过这种方法 3,您将能够使用鼠标滚轮/触摸板正常滚动,或者将滚动条拖到底部。

概括

好了,各位,就到这里。在这篇博文中,我们了解到:

  • 像在画布上绘图这样不常见的解决方案如何解决最常见的问题。
  • 理解无限滚动容器的机制。
  • 我们看到了画布是如何初始化的。
  • 我们还看到了组件的 DOM 结构
  • 我们了解了组件如何初始化以及控制权如何转移到工作者。
  • 最后,我们看到了快速滚动时在画布上绘图的不同方法。

该项目的完整代码库可以在这里找到:https://github.com/keyurparalkar/render-million-rows

感谢您的阅读!

在twitter、  github和 linkedIn上关注我 

鏂囩珷鏉ユ簮锛�https://dev.to/keyurparalkar/rendering-a-million-rows-in-react-by-drawing-1a39
PREV
作为招聘人员,您希望在 GitHub 个人资料自述文件中看到什么?
NEXT
在 React 中从头开始创建密码组件