图可视化库:我们如何解决 Canvas vs HTML 的困境

你好!我叫安德烈,是 Yandex 基础设施服务 User Experience 团队的界面开发工程师。 我们在开发 Gravity UI——一个开源设计系统与 React 组件库, 公司内外有数十个产品在使用。 今天我会讲讲:我们如何遇到复杂图的可视化任务, 为什么现有方案无法满足需求, 以及最终 @gravity‑ui/graph 这套库是如何诞生的——并且我们决定将它开源给社区。

这个故事始于一个很现实的问题:我们需要渲染包含 10,000+ 元素的图, 且节点里还要有可交互组件。 在 Yandex 有很多项目,用户会创建复杂的数据处理流水线—— 从简单的 ETL 过程到机器学习。 当这些流水线以程序方式生成时,块(block)数量可能达到数万。

现有方案不令人满意:

  • HTML/SVG 库 外观漂亮、开发体验好,但在几百个元素时就开始卡顿。
  • Canvas 方案 性能没问题,但要做复杂 UI 元素需要写大量代码。

在 Canvas 里画一个圆角渐变按钮并不难。 但当你需要自定义复杂控件或布局时问题就出现了—— 你得写几十行低层绘制指令。 每个界面元素都得从零实现——从点击处理到动画。 而我们需要的是完整的 UI 组件:按钮、选择器、输入框、拖拽(drag‑and‑drop)。

我们决定不在 Canvas 与 HTML 之间二选一,而是同时利用两者的优势。 思路很简单:根据用户观察图的“距离”(缩放级别)自动切换渲染模式。

Full screen image

任务从何而来

Nirvana 与它的图

在 Yandex,我们有一个叫 Nirvana 的服务,用于创建与执行数据处理图 (我们在 2018 年就 写过)。 这是一个很大、很流行、存在已久的服务。

有些用户手工创建图——用鼠标拖拽、添加块、连接它们。 这种图没有问题:块不多,一切运行良好。 但也有项目会以程序方式生成图。 困难就从这里开始:他们可以在一个图里放入多达 10,000 个操作。 大概是这样:

Full screen image
还有这样:

image
image
image
image
image

这种规模的图,常规的 HTML + SVG 组合根本扛不住。 浏览器开始卡顿、内存泄漏,用户体验很糟。 我们尝试过硬优化 HTML 渲染,但迟早会碰到物理极限—— DOM 并不是为“同时可见的上千个浮动交互元素”而设计的。

需要另一种方案,而在浏览器里我们只剩 Canvas。 只有它能提供所需性能。

第一反应是找现成方案。当时是 2017–2018 年, 我们翻遍了流行的 Canvas 或图渲染库, 但所有方案都卡在同一个问题上: 要么用 Canvas + 原始元素,要么用 HTML/SVG 并牺牲性能。

如果不做选择呢?

细节层级(Level of Details):来自游戏开发的灵感

在游戏开发与制图领域有一个很棒的概念——细节层级(LOD)。 这项技术源于一个现实需求:如何展示一个巨大的世界而不把性能拖垮?

关键点很简单:同一个对象可以有多个细节级别,取决于观察距离。 在游戏里尤其明显:

  • 远处看到的是山——简单多边形 + 基础贴图。
  • 走近一些——出现细节:草、石头、阴影。
  • 更近——能看到树叶。

当玩家站在山顶眺望远方时,没有人会去渲染草地的百万级多边形。

地图也是同样的原理——每个缩放级别都有自己的数据与细节:

  • 大洲尺度——只显示国家。
  • 放大到城市——出现街道与城区。
  • 再近——门牌号、咖啡馆、公交站。

我们意识到:在 10,000 个块的图的远景尺度下, 用户并不需要可交互按钮——他们看不见,也无法操作。

更重要的是,尝试同时渲染 10,000 个 HTML 元素会让浏览器冻结。 但当用户放大到某个区域时,可见块数量会急剧下降—— 从 10,000 变成比如 50。 这时就能腾出资源,用于具备丰富交互性的 HTML 组件。

我们的 LOD 方案:三层细节

Minimalistic(缩放 0.1–0.3)——Canvas + 简单图元

在此模式下,用户看到系统的整体架构: 主要块组在哪里、彼此如何连接。 每个块都是一个带基础颜色编码的简单矩形。 没有文本、按钮或细节图标。 但可以舒适地渲染成千上万的元素。 在这一层,用户选择要进一步查看的区域。

Full screen image

Schematic(缩放 0.3–0.7)——Canvas + 细节

出现块名称、状态图标、连接锚点。 文本由 Canvas API 绘制——速度快,但样式能力有限。 块之间的连接也更有信息量:可以显示数据流方向、连接状态。 这是一个过渡模式:Canvas 的性能 + 基础的信息表达。

Full screen image

Detailed(缩放 0.7+)——HTML + 完整交互

在这里,块会变成完整的界面组件: 带控制按钮、参数输入框、进度条、选择器。 你可以使用 HTML/CSS 的全部能力并接入任何 UI 库。 在此模式下,viewport 通常只容纳 20–50 个块, 非常适合精细操作。

Full screen image

如果用 FPS 来选择细节级别会怎样?

我们尝试过基于 FPS 选择细节级别的方法。 但很快发现这会带来不稳定:性能上升时系统切换到更细节的模式, 反而降低 FPS,可能又触发切回——如此循环。

我们如何走到解决方案

LOD 的想法很棒。但实现需要 Canvas 来保证性能—— 这带来了新的麻烦。在 Canvas 上绘制并不难, 难的是交互。

问题:如何知道用户点到了哪里?

在 HTML 里很简单:点击按钮,事件直接到元素上。 在 Canvas 里更难:点击的是画布——接下来呢? 我们必须自己确定用户点击了哪个元素。

基本有三种做法:

  • Pixel Testing(颜色拾取 / color picking),
  • Geometric approach(简单遍历所有元素),
  • Spatial Indexing(空间索引)。

Pixel Testing(颜色拾取 / color picking)

思路很简单:创建第二个不可见 canvas,把场景复制过去, 但每个元素用唯一颜色填充,该颜色作为对象 ID。 点击时通过 getImageData 读取鼠标指针下像素的颜色,即可得到元素 ID。

优点

缺点

  • 只需几十行代码即可实现

  • 不需要额外的数据结构

  • Canvas 的抗锯齿会混色——点击图形边缘可能得到“无效”ID

  • 2D Canvas 无法关闭 anti‑aliasing

  • 第二张画布会复制内存并使渲染流程翻倍

小场景可用,但在 10,000+ 元素时错误率不可接受——因此我们放弃 Pixel Testing。

Geometric approach(简单遍历所有元素)

思路也很简单:遍历所有元素并检查点击点是否在元素内部。

优点

缺点

  • 只需几十行代码即可实现

  • 不需要额外的数据结构

  • 元素很多时非常慢

  • 不适合大型场景

Spatial Indexing(空间索引)

这是几何法的进化版。几何法卡在元素数量上。 空间索引算法会把相近元素分组(通常用树结构), 从而把复杂度降低到 log n。

空间索引算法很多,我们选择了 R‑Tree 数据结构, 使用库 rbush

R‑Tree 顾名思义是一棵树:每个对象先放入最小包围矩形(MBR), 再把这些矩形聚合成更大的矩形。 这样形成一棵树:每个矩形都包含其他矩形。

Full screen image

图片来自维基百科 R‑tree

在 RTree 里搜索时,我们沿着树向下(深入矩形)直到命中具体元素。 路径通过检测搜索矩形与各节点 MBR 的相交来决定。 所有 bounding‑box 连搜索矩形都不碰的分支会立刻被丢弃—— 因此遍历深度通常限制在 3–5 层,即使在数万元素上搜索也只需微秒级。

这种方法虽然比 pixel testing 慢一些(最佳 O (log n)、最坏 O (n)), 但更准确,且对内存要求更低。

事件模型

基于 RTree,我们就能构建事件模型。 当用户点击时,我们启动命中测试(hit‑test): 在光标坐标处构造一个 1×1 像素的矩形,并在 R‑Tree 中查找与其相交的元素。 找到命中元素后,把事件委派给该元素。 若元素没有停止事件,则事件传递给其父元素,一直向上到 root。 该模型的行为类似浏览器的事件模型:可以拦截、prevent 或停止冒泡。

正如前面提到的,我们在 hit‑test 时构造的是 1×1 像素矩形—— 这意味着我们也可以构造任意尺寸的矩形。 这将帮助我们实现另一个非常重要的优化:Spatial Culling。

Spatial Culling(空间裁剪)

Spatial Culling 是一种渲染优化技术,目标是“不画不可见的东西”。 例如:不画在相机视野之外的对象,或被其他元素遮挡的对象。 由于我们的图是在 2D 空间中绘制,因此只要不绘制 viewport(可视区域)之外的对象就够了。

工作方式:

  • 相机每次移动或缩放时,我们构造一个与当前 viewport 相同的矩形;
  • 在 R‑Tree 中查找与其相交的元素;
  • 得到真正可见的元素列表;
  • 只渲染它们,其他全部跳过。

该技巧让性能几乎不再依赖元素总数: 如果画面里只容纳 40 个块——库就只画 40 个,而不是画屏幕外的数万。 在远景尺度下,viewport 内元素较多,因此我们绘制轻量的 Canvas 图元; 当放大时,元素数量下降,释放的资源允许切到 HTML 模式获得完整细节。

综上,我们得到一个简单的体系:

  • Canvas 负责速度,
  • HTML 负责交互性,
  • R‑Tree 与 Spatial Culling 在背后把它们无缝整合为一个系统,从而快速判断哪些元素可以画在 HTML 层。

相机移动时,小 viewport 只向 R‑Tree 查询当前画面里真正存在的对象。 这种方式让我们能够绘制真正的大型图, 或至少在用户不限制 viewport 之前保持足够的性能余量。

因此,库的核心包含:

  • 使用简单图元的 Canvas 模式;
  • 具备完整细节的 HTML 模式;
  • 用于性能优化的 R‑Tree 与 Spatial Culling;
  • 熟悉的事件模型。

但要用于生产环境,这还不够:我们必须能扩展库并按需定制。

定制化

库提供两种相辅相成的扩展/改造方式:

  • 重写基础组件:修改标准 Block、Anchor、Connection 的逻辑。
  • 通过层(Layers)扩展:在现有场景的上层/下层添加本质上全新的功能。

重写组件

当需要修改现有元素的外观或行为时,我们从基类继承并重写关键方法。 然后用自定义名称注册组件。

定制块(Block)

例如,如果你需要在块上显示进度条的图—— 比如用来展示流水线里任务的执行状态—— 你可以很容易地定制标准块:

import { CanvasBlock } from "@gravity‑ui/graph";

class ProgressBlock extends CanvasBlock {
  // 带圆角的块基础形状
  public override renderBody(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = "#ddd";
    ctx.beginPath();
    ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 12);
    ctx.fill();
    ctx.closePath();
  }

  public renderSchematicView(ctx: CanvasRenderingContext2D): void {
    const progress = this.state.meta?.progress || 0;

    // 绘制块的底形
    this.renderBody(ctx);

    // 带颜色指示的进度条
    const progressWidth = (this.state.width - 20) * (progress / 100);
    ctx.fillStyle = progress < 50 ? "#ff6b6b" : progress < 80 ? "#feca57" : "#48cae4";
    ctx.fillRect(this.state.x + 10, this.state.y + this.state.height - 15, progressWidth, 8);

    // 进度条边框
    ctx.strokeStyle = "#ddd";
    ctx.lineWidth = 1;
    ctx.strokeRect(this.state.x + 10, this.state.y + this.state.height - 15, this.state.width - 20, 8);

    // 百分比与名称文本
    ctx.fillStyle = "#2d3436";
    ctx.font = "12px Arial";
    ctx.textAlign = "center";
    ctx.fillText(`${Math.round(progress)}%`, this.state.x + this.state.width / 2, this.state.y + 20);
    ctx.fillText(this.state.name, this.state.x + this.state.width / 2, this.state.y + 40);
  }
}

定制连接(Connection)

同理,如果你需要改变连线的行为与外观—— 例如显示块之间数据流的强度——你可以创建自定义连接:

import { BlockConnection } from "@gravity-ui/graph";

class DataFlowConnection extends BlockConnection {
  public override style(ctx: CanvasRenderingContext2D) {
    // 从相连的块中获取流量数据
    const sourceBlock = this.sourceBlock;
    const targetBlock = this.targetBlock;

    const sourceProgress = sourceBlock?.state.meta?.progress || 0;
    const targetProgress = targetBlock?.state.meta?.progress || 0;

    // 根据块的进度计算流量强度
    const flowRate = Math.min(sourceProgress, targetProgress);
    const isActive = flowRate > 10; // 进度 > 10% 视为流处于活跃状态

    if (isActive) {
      // 活跃流 —— 粗的绿色线
      ctx.strokeStyle = "#00b894";
      ctx.lineWidth = Math.max(2, Math.min(6, flowRate / 20));
    } else {
      // 非活跃流 —— 灰色虚线
      ctx.strokeStyle = "#ddd";
      ctx.lineWidth = this.context.camera.getCameraScale();
      ctx.setLineDash([5, 5]);
    }

    return { type: "stroke" };
  }
}

使用自定义组件

在图的配置中注册创建的组件:

const customGraph = new Graph({
  blocks: [
    {
      id: "task1",
      is: "progress",
      x: 100,
      y: 100,
      width: 200,
      height: 80,
      name: "Data Processing",
      meta: { progress: 75 },
    },
    {
      id: "task2",
      is: "progress",
      x: 400,
      y: 100,
      width: 200,
      height: 80,
      name: "Analysis",
      meta: { progress: 30 },
    },
    {
      id: "task3",
      is: "progress",
      x: 700,
      y: 100,
      width: 200,
      height: 80,
      name: "Output",
      meta: { progress: 5 },
    },
  ],
  connections: [
    { sourceBlockId: "task1", targetBlockId: "task2" },
    { sourceBlockId: "task2", targetBlockId: "task3" },
  ],
  settings: {
    // 注册自定义块
    blockComponents: {
      'progress': ProgressBlock,
    },
    // 为所有连线注册自定义连接
    connection: DataFlowConnection,
    useBezierConnections: true,
  },
});

customGraph.setEntities({
  blocks: [
    {
    is: 'progress',
    id: '1',
    name: "progress block',
    x: 10, 
    y: 10, 
    width: 10, 
    height: 10,
    anchors: [],
    selected: false,
    }
  ]
})

customGraph.start();

结果

最终你会得到一张图,其中:

  • 块以颜色提示显示当前进度;
  • 连线可视化数据流:活跃流为绿色且更粗,不活跃流为灰色虚线;
  • 缩放时,块会自动切换到具备完整交互的 HTML 模式。

通过层(Layers)扩展

层(Layer)是额外的 Canvas 或 HTML 元素,会被插入到图的“空间”中。 本质上,每一层都是独立的渲染通道: 可以包含自己的 canvas(用于快速图形),或 HTML 容器(用于复杂交互元素)。

顺便说一句:我们库的 React 集成正是通过层来工作的—— React 组件通过 React Portal 渲染进 HTML 层。

层的架构

层是解决 Canvas vs HTML 两难的另一个关键机制。 层会同步 Canvas 与 HTML 元素的位置,确保它们正确叠加。 这样就能在同一坐标空间内无缝切换 Canvas 与 HTML。 图由相互独立、彼此叠加的层构成:

Full screen image

层可以在两种坐标系下工作:

  • 绑定到图(transformByCameraPosition: true):

    • 元素随相机一起移动,
    • 块、连接、图元素。
  • 固定在屏幕(transformByCameraPosition: false):

    • 平移时保持原位,
    • 工具栏、图例、UI 控件。

React 集成如何实现

具备 React 集成的层很好地展示了“层”的含义。 先看一个组件:它会高亮相机可视区域内的块列表。 为此我们要订阅相机变化,并在每次变化后检查相机 viewport 与元素 hitbox 的相交。

import { Graph } from "@gravity-ui/graph";

const BlocksList = ({ graph, renderBlock }: { graph: Graph, renderBlock: (graph: Graph, block: TBlock) => React.JSX.Element }) => {
  const [blocks, setBlocks] = useState([]);

  const updateVisibleList = useCallback(() => {
    const cameraState = graph.cameraService.getCameraState();
    const CAMERA_VIEWPORT_TRESHOLD = 0.5;
    const x = -cameraState.relativeX - cameraState.relativeWidth * CAMERA_VIEWPORT_TRESHOLD;
    const y = -cameraState.relativeY - cameraState.relativeHeight * CAMERA_VIEWPORT_TRESHOLD;
    const width = -cameraState.relativeX + cameraState.relativeWidth * (1 + CAMERA_VIEWPORT_TRESHOLD) - x;
    const height = -cameraState.relativeY + cameraState.relativeHeight * (1 + CAMERA_VIEWPORT_TRESHOLD) - y;
    
    const blocks = graph
      .getElementsOverRect(
        {
          x,
          y,
          width,
          height,
        }, // 定义在哪个区域内查找块列表
        [CanvasBlock] // 定义在相机可视范围内要查找的元素类型
      ).map((component) => component.connectedState); // 获取块模型列表

      setBlocks(blocks);
  });

    useGraphEvent(graph, "camera-change", ({ scale }) => {
      if (scale >= 0.7) {
        // 若缩放比例大于 0.7,则更新块列表
        updateVisibleList()
        return;
      }
      setBlocks([]);
    });

    return blocks.map(block => <React.Fragment key={block.id}>{renderBlock(graphObject, block)}</React.Fragment>)
}

接下来看看将使用该组件的层的定义。

import { Layer } from '@gravity-ui/graph';

class ReactLayer extends Layer {
  constructor(props: TReactLayerProps) {
    super({
      html: {
        zIndex: 3, // 将该层置于其他层之上
        classNames: ["no-user-select"], // 添加一个 class 以禁用文本选择
        transformByCameraPosition: true, // 该层绑定相机:会随相机一起移动
      },
      ...props,
    });
  }

  public renderPortal(renderBlock: <T extends TBlock>(block: T) => React.JSX.Element) {
    if (!this.getHTML()) {
      return null;
    }

    const htmlLayer = this.getHTML() as HTMLDivElement;

    return createPortal(
      React.createElement(BlocksList, {
        graph: this.context.graph,
        renderBlock: renderBlock,
      }),
      htmlLayer,
    );
  }
}

现在我们可以在应用中使用该层。

import { Flex } from "@gravity-ui/uikit";

const graph = useMemo(() => new Graph());
const containerRef = useRef<HTMLDivElement>();

useEffect(() => {
    if (containerRef.current) {
      graph.attach(containerRef.current);
    }

    return () => {
      graph.detach();
    };
  }, [graph, containerRef]);


const reactLayer = useLayer(graph, ReactLayer, {});

const renderBlock = useCallback((graph, block) => <Block graph={graph} block={block}>{block.name}</Block>)

  return (
    <div>
      <div style={{ position: "absolute", overflow: "hidden", width: "100%", height: "100%" }} ref={containerRef}>
        {graph && reactLayer && reactLayer.renderPortal(renderBlock)}
      </div>
    </div>
  );

总体来说一切都很简单。上面描述的内容不需要你自己实现—— 都已经写好并可直接使用。

我们的图(Graph)库:优势与使用方法

当我们开始做这套库时,最核心的问题是: 如何让开发者不必在性能与开发体验之间做选择? 答案是把这种选择自动化。

优势

性能 + 便利

@gravity‑ui/graph 会根据缩放自动在 Canvas 与 HTML 之间切换。 这意味着你可以获得:

  • 在包含数千元素的图上稳定 60 FPS。
  • 在细节查看时使用具备丰富交互的完整 HTML 组件。
  • 与渲染方式无关的统一事件模型——click、mouseenter 在 Canvas 与 HTML 上表现一致。

与 UI 库兼容

一个重要优势是:它兼容任意 UI 库。 如果你的团队使用:

  • Gravity UI,
  • Material‑UI,
  • Ant Design,
  • 自定义组件。

……你无需放弃它们!当放大图时会自动切到 HTML 模式, 在那里你熟悉的 ButtonSelectDatePicker, 以你需要的主题色正常工作,就像在普通 React 应用中一样。

与框架无关(Framework agnostic)

虽然我们用 React 实现了基础 HTML renderer, 但我们尽量让库保持“与框架无关”。 这意味着在必要时,你可以相对容易地实现一个层,用于集成你喜欢的框架。

有没有类似方案?

目前市面上用于绘制图的方案很多:从付费方案如 yFilesJointJS,到开源方案 Foblex FlowbaklavajsjsPlumb。不过我们对比时主要关注 @antv/g6React Flow 这两个最流行的工具。它们各有特点。

React Flow 是一款不错的库,专注于构建 node‑based 接口。它功能很强,但由于使用 SVG 和 HTML,性能相对一般。适合你能确定图不会超过 100–200 个块的场景。

@antv/g6 则功能很多,支持 Canvas,尤其是 WebGL。直接拿 @antv/g6 和 @gravity‑ui/graph 做对比可能不太合适:他们更偏向图与图表(diagram/chart)构建,不过也支持 node‑based UI。因此如果你不仅要 node‑based 界面,还要画图表,antv/g6 会是合适选择。

虽然 @antv/g6 既支持 canvas/webgl 又支持 html/svg,但切换规则需要你手动管理,而且必须做对。它的性能远快于 React Flow,但仍有一些问题。尽管声称支持 WebGL,但看他们的 压力测试 可以发现:在 60k 节点时库无法保持动态表现——在 MacBook M3 上渲染一帧用了 4 秒。对比我们的 压力测试:同一台 Macbook M3 上 111k 节点与 109k 连线,渲染整个场景仅 ~60ms,约为 ~15–20FPS。这个 FPS 不算高,但借助 Spatial Culling 可以限制 viewport,从而提升交互响应。尽管维护者曾 表示 希望实现 100k 节点 30 FPS 的渲染,但显然目前还未达到。

另外一个 @gravity‑ui/graph 的优势是 bundle 体积。

Bundle size Minified

Bundle size Minified + Gzipped

@antv/g6 bundlephobia

1.1 MB

324.5 kB

react flow bundlephobia

181.2 kB

56.4 kB

@gravity-ui/graph bundlephobia

2.2 kB

672 B

尽管这两款库在性能或集成体验上都很强,但 @gravity‑ui/graph 仍有一系列优势——它能在真正大型的图上提供性能,同时保持良好的 UI/UX,并简化开发。

未来计划

目前库在性能上已经有足够余量来覆盖大多数任务。 因此在近期,我们会把更多精力放在围绕库的生态建设上—— 开发层(插件)、为其他库与框架提供集成(Angular/Vue/Svelte 等), 增加对触控设备的支持、适配移动端浏览器,并整体提升 UX/DX。

试用并加入我们

仓库 中 你可以找到一套完全可用的库:

  • 基于 Canvas + R‑Tree 的核心(≈ 30K 行代码),
  • React 集成,
  • 带示例的 Storybook。

一行即可安装:

npm install @gravity-ui/graph


相当长一段时间里,现在名为 @gravity‑ui/graph 的库 一直是 Nirvana 内部工具,而这套方案在实践中表现很好。 现在我们希望分享这些成果,帮助外部开发者更简单、更快速、更高性能地绘制自己的图。

我们希望在开源社区里推动复杂图展示方式的标准化—— 太多团队在重复造轮子,或被不合适的工具折磨。

因此我们非常需要你的反馈:不同项目会带来不同的边界场景(edge cases), 这能推动库持续演进。 这将帮助我们完善库,并更快壮大 Gravity UI 的生态。

图可视化库:我们如何解决 Canvas vs HTML 的困境

Sign in to save this post