替换还是保留:DataLens 如何从 Highcharts 迁移

大家好,我叫 Евгений Алаев,是 Yandex DataLens 团队的前端开发工程师。DataLens 是一款用于数据分析和仪表板搭建的云端 BI 工具,其中的图表不仅仅是「功能之一」,更是产品的核心。用户打开仪表板,首先看到的就是可视化图表——它们直接回答了「我的数据发生了什么?」这个问题。

DataLens 有两个部署版本——面向 Yandex 内部和面向外部用户。迄今为止,共创建了超过 1830 万张图表。每一张图表都是我们即将讨论的这个可视化库的成果。

长期以来,DataLens 的图表基于 Highcharts 构建。起初这是个合理的选择:快速上手、丰富的图表类型、庞大的社区。但随着 BI 工具不断演进,非标准的行为需求日益增多,设计系统也需要保持统一的风格。在某个时刻,Highcharts 开始成为阻碍,而不再是助力。

本文将介绍我们为何以及如何决定自己开发一个开源可视化库——@gravity‑ui/charts。我和我的同事是这个库的核心贡献者,所以我会详细讲述 Highcharts 令我们不满意的地方、我们评估过的替代方案、架构设计,以及迁移过程中遇到的具体技术挑战。


从便利到局限

许可证与供应商锁定

Highcharts 是一款商业库。非商业用途可免费使用,但一旦产品开始盈利,就需要购买许可证。这本身并不是问题——许多团队都在使用付费工具。问题在于当使用模式变得更加复杂时。

DataLens 有三种发布形式:云端、开源和私有化部署。依赖中存在非自由许可证——哪怕是可选的——都会使每种形式的交付变得更复杂。

除了许可证之外,还存在对外部 roadmap 的严格依赖。想要新的图表类型?等待供应商实现它。发现了 tooltip 行为中的 bug?提交工单并希望它能进入优先队列。需要点击图例时的非标准行为?在文档或 Stack Overflow 中寻找变通方案。当产品快速迭代、对可视化的需求又非常复杂时,这种依赖关系就开始拖慢节奏。

还有一个单独的问题——主版本升级。我们使用的是 Highcharts 第八个主版本中的最新版本。升级到下一个主版本本身就代价不菲,但 DataLens 的一个特殊之处使其更加困难:我们有 Editor——一个允许用户编写 JavaScript 代码来配置图表的模式。用户实际上是直接操作 Highcharts,通过代码生成配置。这类用户代码无法自动迁移:无法编写一个 codemod,安全地将任意 JS 从一个接口迁移到另一个接口。

行为控制

在 BI 工具中,用户会频繁与图表交互:点击图例来隐藏系列、悬停在数据点上、固定 tooltip。在 Highcharts 中,这些场景中的每一个都需要通过回调和事件进行精细调整——而这些回调和事件并不总是有文档记录,也不总是在版本之间保持稳定。

以下是一些典型示例:

  • 固定 tooltip。标准的 Highcharts tooltip 在鼠标移出时会消失。第 8 个主版本中没有原生的「粘性」tooltip 支持——它直到第 12 版才出现。我们通过覆盖内部方法来实现这一功能:拦截鼠标事件,替换隐藏逻辑。代码可以运行,但很脆弱。

  • 自定义 tooltip 渲染。Formatter 返回 HTML 字符串,这意味着需要通过字符串拼接手动构建标记。没有 React,没有组件。最终,tooltip 渲染演变成了一个拥有自己模板逻辑的独立层。

  • 点击图例。标准行为会切换系列的可见性。要完全覆盖它而不破坏其他功能并不简单:处理器必须通过内部事件挂载,并小心地取消默认行为。

  • 主题化。Highcharts 中的颜色、字体和间距通过 JavaScript 配置设置,并作为行内样式应用。在此之上,我们维护了一个单独的 SCSS 文件用于覆盖基础样式,每次 Gravity UI 调色板更新时都需要手动同步。

每一条这样的变通路径都不是孤立的问题,而是在累积技术债务。一个 hack 可以独立存在,两个还能记在脑子里,但到了第十个,它们开始相互影响:修了这个,坏了那个。维护复杂度呈非线性增长,每次 Highcharts 更新都变成一次审计:我们的哪些变通方案这次悄悄失效了。

替代方案评估

在自研之前,我们认真考察了现有的解决方案。主要标准如下:

  • 开放许可证——MIT 或兼容许可,不含付费层级和 OEM 协议。

  • 完整的渲染和样式自定义能力——能够控制任何元素的外观,无需变通方案。

  • HTML 嵌入——tooltip、图表标签、坐标轴标签必须渲染为完整的 HTML 标记,而不是 SVG 文本或字符串模板。

我们评估了 ECharts、Recharts、Plotly、Chart.js 和 D3。

Chart.js 和 Plotly 很快被排除:默认的 Canvas 渲染关闭了通过 CSS 自定义样式的可能性,而在图表元素中嵌入任意 HTML 也不在支持范围内。

Recharts——React 优先,SVG,MIT 许可。适合简单仪表板,但对形状和行为的深度自定义很快就会碰到内部 API 的限制。

ECharts 是最接近的选项:丰富的图表类型、灵活的配置、支持自定义渲染器。但仔细研究后发现,它不能满足一个关键标准:在图表的任意部分完整嵌入 HTML。对我们的使用场景来说,这一点至关重要。此外,供应商锁定的问题依然存在:MIT 许可解决了法律问题,但对外部 roadmap 和更新周期的依赖仍在。

D3 不是图表库,而是一套基础工具:比例尺、形状生成器、数据变换。它本身不能解决问题,但为在其之上构建自己的解决方案提供了理想的基础。

结论水到渠成:以 D3 为基础,在其上构建自己的库——这既给了我们自由和控制权,也彻底消除了对任何供应商的依赖。

为什么是 D3,而不是其他

D3 不绘制图表。它是一套用于处理数据和 DOM 的工具:比例尺、几何形状生成器、数据变换。这正是我们所需要的——可以用来组装任意可视化的基础元素,而不受库作者预设范围的限制。

D3 提供 scaleLinear、scaleBand、scaleUtc 用于坐标轴,d3.line ()、d3.arc ()、d3.area () 用于图形,d3.extent () 和 d3.group () 用于数据变换。其余的都由我们自己掌控。

@gravity‑ui/charts 的架构

入口点——配置对象

用户向组件传入一个包含数据和设置的对象:系列、坐标轴、标题、图例、tooltip。这是有意为之的声明式设计选择——大部分配置易于序列化、记录日志,并在系统间传递。

在声明式描述不足以表达的地方,配置接受函数:自定义 tooltip 渲染器、点击处理器、坐标轴标签格式化器。这不是矛盾,而是刻意划定的边界——数据结构保持可预测,扩展点保持显式。

数据流

在库内部,配置经过几个处理阶段:

  • 规范化。将输入数据转换为统一的内部格式。在此阶段设置默认值、消除歧义,并对每个系列的数据进行类型化。

  • 系列准备。每种图表类型(折线、柱状、饼图等)由其专属模块处理。分配颜色,生成图例的元数据。

  • 构建比例尺和坐标轴。基于数据,D3 构建比例尺:线性、对数、时间、类目。比例尺决定了数据值如何映射为像素坐标。
  • 形状渲染。每种系列类型绘制其 SVG 元素:折线、矩形、弧形、点。D3 提供形状生成器,React 管理元素的生命周期。

SVG + HTML:混合渲染

主渲染基于 SVG。这带来了清晰的边界、无损缩放以及对定位的完全控制。

但 SVG 无法自动换行文本,也不支持元素内部的富 HTML 标记。对于带换行的数据标签、自定义 tooltip 以及图表上方的交互元素,使用了单独的 HTML 层——一个绝对定位的 div,叠加在 SVG 上并与其坐标同步。

事件系统

用户与图表的交互——悬停、点击、鼠标移动——通过基于 d3.dispatch 的集中式事件总线处理。这使得界面的不同部分(tooltip、crosshair、图例)可以独立且协调地响应同一事件,组件之间无需直接依赖。

但这种方式还有另一个重要的优势——性能。鼠标在图表上移动会高频产生事件。如果每个事件都触发 React 重新渲染,就会导致整个组件树的高代价重新计算。为此,部分更新——例如图形悬停、查找最近数据点——通过 D3 直接应用,绕过 React。组件不会重新渲染:D3 只是直接更新所需的 DOM 属性。React 只在真正必要时才介入——当影响整个图形的结构或状态发生变化时。

不停产品的迁移之路

数据从源头到图表的路径

在谈论切换库之前,有必要先了解 DataLens 中图表渲染的整体架构——正是这个架构决定了我们能够如何迁移。

当用户打开图表(或该图表在仪表板上渲染)时,系统会向我们的 Node.js 后端发送数据请求。Node.js 后端随后向 Python 服务请求原始数据。但数据本身还不是图表。在 Node 端会进行准备工作:确定可视化类型,检查是否超出该类型的数据限制,如果一切正常,则选择特定于该图表类型的数据准备函数。

关键点在于:每种可视化类型都有自己的数据准备函数。折线图是一个,柱状图是另一个,面积图又是另一个。这个函数的结果就是发往客户端的配置对象。在客户端,它不是直接传给 @gravity‑ui/charts,而是传给 @gravity‑ui/chartkit——一个我们用来同时处理多个可视化库的独立包。它动态加载特定图表类型所需的库,并为所有库提供统一接口——客户端代码不需要知道也不需要关心哪个库在渲染具体的类型。

除了路由功能之外,chartkit 还包含库之上的封装逻辑:那些不属于具体库范畴、但在 DataLens 中又必需的逻辑。例如,移动端点击时显示 tooltip——这种行为对 Highcharts 和 @gravity‑ui/charts 都同样需要,因此在 chartkit 层实现。

这些功能不仅对 DataLens 有价值,对外部也可能有用。因此,@gravity‑ui/chartkit 的详细介绍值得单独写一篇文章。

渐进式迁移策略

这个架构使我们能够分阶段迁移,而无需一次性重写所有内容。

我们在可视化类型层级引入了特性开关。逻辑很简单:如果特定可视化的开关设为 true——Node 以 @gravity‑ui/charts 格式准备数据,客户端使用新库。如果开关未设置——数据以 Highcharts 格式准备,渲染旧代码。

这意味着在某个阶段,DataLens 中两个库同时运行——各自负责自己的图表类型集合。这种方式带来了几个优势:

  • 风险隔离。新版条形图中的 bug 不会影响仍在使用 Highcharts 的柱状图。

  • 渐进式验证。每种类型切换后都会在生产环境中经历一段观察期,然后再迁移下一个。

  • 回滚能力。如果出现问题,关闭开关即可。

迁移分三波进行,顺序经过深思熟虑——从简单到复杂。

第一波:pie 和 treemap。最合理的起点——无坐标轴的可视化。没有比例尺,没有 crosshair,没有复杂的坐标系。这让我们能够打磨基础集成、建立数据准备流水线,并在不冒险使用高负载类型的情况下验证迁移基础设施的正确性。

第二波:bar‑y、bar‑y normalized、scatter。下一步——有坐标轴的图表,但有一个重要限制:这些类型不在 split 模式下使用(多个图表垂直排列共享 X 轴),也不参与组合图表。这大幅减少了边缘情况,使迁移变得可预测。

第三波:area、area normalized、bar‑x、bar‑x normalized、line 及其组合。最复杂的阶段。正是这些类型在 DataLens 中最为常用:大多数仪表板都会用到它们。

此外,这里还涉及组合图表(例如,折线图和 bar‑x 在同一图表中)、split 模式以及所有非常规的交互场景。每种可视化在测试时都需要特别关注,而特性开关则在生产环境出现问题时提供了回滚的能力。

技术解决方案

配置对象:熟悉,但更好。我们有意选择了配置对象作为图表描述方式。DataLens 已经使用了这种方式,突然改变范式只会增加不必要的障碍。配置结构在很大程度上与开发者熟悉的模式一致:系列、坐标轴、标题、tooltip——各就其位。但在 Highcharts 接口令我们不满意的地方,我们做出了自己的决定。这不是为了标新立异,而是因为我们在实际使用中看到了具体的问题和不便之处。

最简示例——带时间轴的折线图:

import {Chart} from '@gravity-ui/charts';

<Chart
    data={{
        series: {
            data: [
                {
                    type: 'line',
                    name: '营收',
                    data: [
                        {x: new Date('2024-01-01').getTime(), y: 120},
                        {x: new Date('2024-02-01').getTime(), y: 145},
                        {x: new Date('2024-03-01').getTime(), y: 132},
                        {x: new Date('2024-04-01').getTime(), y: 178},
                    ],
                },
            ],
        },
        xAxis: {type: 'datetime'},
        yAxis: [{title: {text: '千元'}}],
    }}
/>

效果如下:

当然,折线图也可以做得更复杂:

合理的默认值与按需自定义。经过多年 BI 工具开发,我们对图表库在此类产品中应有的行为形成了清晰的认识。典型场景应该开箱即用——无需配置,没有意外。在默认值不够用的地方,有明确的自定义扩展点:传入自定义 React 组件作为 tooltip 渲染器,覆盖坐标轴标签格式化器,设置线宽。所有这些都通过同一个配置对象完成,无需深入库的内部。

与 Gravity UI 的原生集成。DataLens 构建在 Gravity UI 之上——这是一个包含组件、图标和 CSS 主题的设计系统。主题通过 @gravity‑ui/uikit 的 CSS 变量工作:接入主题后,坐标轴、网格、标签的所有颜色都会自动适应浅色或深色模式。图表界面中的 tooltip 和按钮是标准的 uikit 组件,继承了设计系统的无障碍访问、键盘导航和事件处理能力。

规模扩展而不失控。当图表类型繁多时,架构开始决定成败。添加新类型不应该需要修改核心代码或破坏已有功能。我们通过为每种类型建立统一结构来解决这个问题:各自的数据准备、各自的渲染组件、各自的类型定义——全部封装在独立模块中。库的核心只通过通用契约了解类型的存在,而不依赖具体实现。

视觉测试。可视化首先是用户看到的东西,因此单元测试在这里是不够的。每种图表类型都有基于 Playwright 的截图测试,并在 Docker 中运行以保证可重现性。一个模块的变更不会悄悄破坏另一个模块的渲染:快照失败会立即发现问题。

总结与结论

迁移耗费了时间,需要大量投入。但结果不仅仅是「用一个库替换了另一个库」。以下是我们的收获:

  • 对代码的完全掌控。当某些功能出现异常时——我们可以进入任何地方,找到原因并修复。没有黑盒,没有基于他人代码的变通方案,不需要等待供应商的修复。

  • 开源。任何人都可以使用这个库——不仅限于 DataLens 团队。这意味着外部贡献、公开的 issue、透明的变更历史。外部用户报告的问题有助于改善所有人的产品体验。

  • 统一的视觉语言。图表成为 DataLens 界面的有机组成部分,而不是来自第三方供应商的嵌入元素。主题化、排版、交互组件——全部来自同一个设计系统。

自研图表库不是一个应当轻易做出的决定。这是大量的工作,需要维护、文档化并持续开发另一个产品。

但如果你的工具围绕数据可视化而构建,如果你对行为有非标准的需求,如果你希望对外观有完全的控制并与设计系统深度集成——对外部库的供应商锁定在某个时刻就会成为天花板。我们碰到了这个天花板,并决定将其移除。

下一步计划

这个库正在积极开发中,我们目前正在推进几个方向。

框架无关的核心。目前 @gravity‑ui/charts 是一个 React 库。我们计划将核心部分——数据准备逻辑、比例尺构建、坐标计算——提取到一个不依赖任何框架的独立包中。这将开辟两条路径:在不依赖 React 的纯 JavaScript 中使用,以及为 Vue、Angular 或其他框架编写轻量封装,而无需重复核心逻辑。

类目轴的区间滑块。区间滑块组件目前适用于数值轴和时间轴——它允许选择一个范围并「放大」数据的特定区段。但类目轴(例如国家或姓名列表)对 BI 分析同样重要。我们正在改进滑块,使其也能处理类目:选择一个值的子集并将其传入数据过滤。

贡献者文档。目前添加新图表类型对了解库内部架构的人来说是个清晰的流程。但对外部贡献者而言并不那么显而易见。我们希望创建详细的指南:什么是类型模块,它需要满足什么契约,如何编写测试,如何将新的可视化接入整体系统。目标是让一个从未接触过这个代码库的人,能够仅凭文档独立添加新的图表类型。


@gravity‑ui/charts 库采用 MIT 许可证开源——欢迎在您的项目中尝试使用。

欢迎在 GitHub 上给我们点一颗星 ⭐

替换还是保留:DataLens 如何从 Highcharts 迁移

Sign in to save this post