图形化React代码库优化指南——d3-zoom与dnd-kit性能优化实战

本文详细介绍了如何优化基于React的图形化代码库,特别是针对d3-zoom的平移缩放和dnd-kit拖拽功能的性能提升。通过创新的组件重构和渲染优化策略,将万级元素的拖拽性能提升20倍。

如何优化图形化React代码库——优化d3-zoom和dnd-kit代码

Miro和Figma是在疫情期间变得非常流行的在线协作画布工具。与在物理墙上使用便利贴不同,您可以将虚拟帖子——以及一系列其他东西——添加到虚拟画布上。这让团队能够以从物理世界熟悉的方式进行虚拟协作。

我之前写过一篇文章,展示如何在React和TypeScript中创建Figma/Miro克隆。文章中的代码设计得尽可能易于理解,而在本文中,我们将对其进行优化。该代码使用DndKit进行拖放,使用D3 Zoom进行平移和缩放。有四个组件(App、Canvas、Draggable和Addable),大约250行代码。您不需要阅读原始文章就能理解本文。

标准的优化(如useCallback、memo等)使拖拽速度提高了约两倍,但对平移和缩放没有影响。更具创造性/密集型的优化在大多数情况下使其速度提高了约十倍。

您可以在GitHub上查看优化后的代码,并在GitHub页面上有一个实时演示,可以测试10万张卡片的性能。

目录

  • 如何测量React应用性能
  • 如何调查性能
  • 如何优化画布的平移和缩放
  • 如何优化在画布上拖拽卡片
  • 结果
  • 总结

如何测量React应用性能

测量React应用性能有三种常见方法:

  • React Dev Tools分析器
  • Chrome Dev Tools分析器,特别是使用自定义轨道
  • Profiler组件

这些工具都很棒,但在这种情况下都不太合适。在大多数代码库中,执行JavaScript代码(包括我们的代码和React框架的代码)所花费的时间是主要问题。然而,在您的所有代码运行完毕且React更新了DOM之后,浏览器仍然有很多工作要做:

在这种情况下,浏览器的布局和渲染时间很显著,并且React性能分析没有计入这部分时间。

您可以在Chrome开发工具分析器中使用自定义轨道,但使用起来非常麻烦。

对我们来说,JavaScript性能API是最佳选择,它提供的结果更接近用户体验,并且相对容易使用。

首先,我们在启动操作的处理器中调用performance.mark,用一个字符串描述时间点。例如,在开始缩放或平移操作时:

1
2
3
zoomBehavior.on("start", () => {
    performance.mark('zoomingOrPanningStart');
}

然后,在useEffect钩子中,我们再次调用performance.mark,并调用performance.measure来计算两点之间的时间:

1
2
3
4
useEffect(() => {
    performance.mark('zoomingOrPanningEnd');
    performance.measure('zoomingOrPanning', 'zoomingOrPanningStart', 'zoomingOrPanningEnd');
});

React文档指出,useEffect通常在浏览器绘制更新后的屏幕后触发,这正是我们想要的。

这并不完美,并且会根据机器规格和机器当时正在进行的其他工作而变化,但足以验证哪些优化效果最好。如果需要,您可以更进一步。例如,使用Cypress自动化和分析场景,可能运行多次以获得良好的平均值,或使用Browserstack在各种设备上进行测试。

如何调查性能

大部分调查涉及使用React Dev Tools分析器记录用户交互的性能分析。

性能数据显示了分析中有多少次提交,以及每次提交花费的时间,这是查看是否有太多提交的好方法。

每次提交都会显示一个火焰图,显示哪些组件被渲染以及它们重新渲染的原因。这使得找到避免重新渲染的方法并检查记忆化策略是否按预期工作变得更加容易。但这也有一些注意事项。它经常说“父组件渲染了”,这是当它不理解发生了什么时的误导性默认文本(通常是由于父上下文的变化)。它还会说“钩子9改变了”,这使得找出具体是哪个钩子改变非常耗时。

火焰图还显示每个组件渲染所花费的时间。这有助于定位我们需要关注的问题组件。

如何优化画布的平移和缩放

原始的Canvas元素使用CSS变换translate3d(x, y, k)来平移和缩放画布。这可行,但它不缩放子元素,因此当缩放改变时,画布上的所有卡片都必须使用新的缩放级别(scale(${canvasTransform.k}))的新CSS变换重新渲染。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<div
    ...
    className="canvas"
    style={{
        transform: `translate3d(${transform.x}px, ${transform.y}px, ${transform.k}px)`,
        ...
    }}>
    ...
</div>
<div
    className="card"
    style={{
        ...
        transform: `scale(${canvasTransform.k})`,
    }}>
    ...
</div>

我将其改为使用translateX(x) translateY(y) scale(k),效果相同,但会缩放子元素。这样,当缩放改变时,没有卡片会被重新渲染(卡片组件的样式不再使用canvasTransform.k)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<div
    ...
    className="canvas"
    style={{
        transform: `translateX(${transform.x}px) translateY(${transform.y}px) scale(${transform.k})`,
        ...
    }}>
    ...
</div>
<div
    className="card"
    ...
</div>

当平移或缩放改变时,Canvas仍然需要重新渲染,并且可以使用useRef来防止这种情况,并在d3-zoom事件处理器中通过直接的JavaScript DOM操作更新CSS变换。但这并没有显著提高性能,而且绝对是一种 hack,因此这种权衡不值得。

当画布缩放到非常远并且有(更多)卡片在屏幕上可见时,由于浏览器必须渲染所有卡片,缩放和平移都会变得有点慢。但在10万张卡片的情况下仍然可用。您可以对此采取措施。一个简单的选项是限制最大缩放范围。这是一个功能性的改变,因此可能不符合要求,但在d3-zoom中使用scaleExtent很容易做到:

1
zoom<HTMLDivElement>().scaleExtent([0.1, 100])

另一个选项是为非常低的缩放级别创建位图,并将其作为单个元素渲染。这可能很困难,但这意味着功能不会有任何变化。

如何优化在画布上拖拽卡片

开始拖拽

来自DndContext的useDraggable钩子在开始拖拽操作时会导致一些重新渲染。

可以通过更改Draggable组件来改进,使其仅包含此钩子(以及使用它的东西),并为其他所有内容(在memo内部)创建一个DraggableInner组件。这在减少重新渲染方面效果很好,DraggableInner几乎永远不会被重新渲染,并提高了开始拖拽操作的速度。然而,它仍然相当慢,并且所有时间都在DndContext下。

一个更好的选择是创建一个新的NonDraggable组件,它看起来与Draggable组件完全相同,但不与DndContext连接。这些卡片显示在Canvas上,并有一个onMouseEnter事件,用于为活动卡片换入Draggable组件,以便拖拽继续工作。

1
2
3
const onMouseEnter = useCallback(() => {
    setHoverCard(card);
}, []);

这很有效,并显著提高了开始拖拽操作的速度,但在卡片数量很大时仍然相当慢。几乎没有任何东西被重新渲染,但在使用memo时仍然有时间成本,因为它需要检查组件是否已更改。

为了解决这个问题,我们创建一个AllCards组件,其中包含画布上的所有卡片作为NonDraggable组件。因为它总是渲染所有卡片,所以几乎不需要重新渲染,并且它与memo一起使用。因此,现在只有一个组件使用memo(具有相关的时间成本),而不是每个单独的卡片都使用memo。为了使拖拽仍然有效,活动的Draggable组件被渲染在顶部,遮住了下面的NonDraggable组件。下面还有一个Cover组件,这样当Draggable组件被拖走时,下面的NonDraggable组件仍然被隐藏。

原始代码,每个卡片都是一个Draggable组件:

1
2
3
4
5
<DndContext ...>
    {cards.map((card) => (
        <Draggable card={card} key={card.id} canvasTransform={transform} />
    ))}
</DndContext>

优化后的代码,AllCards组件将所有卡片渲染为NonDraggable组件,然后为活动卡片渲染一个Cover和一个Draggable组件。

1
2
3
4
5
<AllCards cards={cards} setHoverCard={setHoverCard} />
<DndContext ...>
    <Cover card={hoverCard} />
    <Draggable card={hoverCard} canvasTransform={transform} />
</DndContext>

这非常有效。在卡片数量较少时,速度大致相同,但在卡片数量较多时,速度提高了约二十倍。

现在有一个新的潜在性能问题,即换入活动卡片的Draggable组件的onMouseEnter事件,但这只是向DOM添加了两个组件,即使在卡片数量很大时也非常快。

结束拖拽

结束拖拽操作很难优化,因为卡片的位置发生了变化,这确实需要重新渲染,这意味着AllCards组件也必须重新渲染。

您可以在下面看到原始代码。即使对Draggable组件使用memo,在10万张卡片的情况下,结束拖拽操作仍然需要2500毫秒,这主要是由于Draggable组件的复杂性及其与DndKit的集成。

1
2
3
4
5
<DndContext ...>
    {cards.map((card) => (
        <Draggable card={card} key={card.id} canvasTransform={transform} />
    ))}
</DndContext>

然而,我们现在使用NonDraggable组件,它们都成功记忆化,并且只有被拖拽的卡片被重新渲染。使用memo仍然有时间成本,并且这是解决方案中最慢的部分,但它将速度提高到10万张卡片仅需500毫秒。

1
2
3
4
5
6
7
8
9
const NonDraggable = memo(...)

const AllCards = memo((cards, setHoverCard) => {
    <>
        {cards.map((card) => {
            <NonDraggable card={card} key={card.id} setHoverCard={setHoverCard} />);
        })}
    </>;
});

结果

未优化的基础版本在1000到5000张卡片之间开始变慢。标准优化将其提高到大约10,000张卡片,而更深入的优化则将其提高到大约100,000张卡片。代价是代码变得明显更复杂,这使得理解和修改变得更加困难,特别是对于代码库的新手。

卡片数量 平移 (ms) 缩放 (ms) 开始拖拽 (ms) 结束拖拽 (ms) 卡片悬停 (ms)
1000卡片 基础 34 200 50 -
基础优化 23 200 30 -
深度优化 10 10 7 15
5000卡片 基础 20 150 450 200
基础优化 20 150 200 80
深度优化 10 10 25 40
10,000卡片 基础 50 300 900 400
基础优化 50 300 400 180
深度优化 25 25 50 50
50,000卡片 基础 1000 1500 4000 1800
基础优化 1000 1500 1900 900
深度优化 150 150 150 250
100,000卡片 基础 - - - -
基础优化 3000 4500 5000 2500
深度优化 150 250 300 500

总结

在标准的React应用中,在屏幕上显示10万或更多项目是不常见的,但在高度图形化的代码库中,这种情况变得很可能。

对于这样的数量,浏览器渲染引擎可能会花费大量时间,因此最好使用性能API来测量性能,而不是通常的React工具。

标准的React优化策略确实有效并改善了情况,但需要更进一步,通过找到避免渲染的方法,甚至避免过多的memo比较。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计