Van-Analysis 图表引擎设计分析

AI 摘要顺着 store、数据重建和大数据控制器看一遍,Van-Analysis 最费功夫的地方其实都发生在落笔画图之前。

文章目录5 节
  1. 一张图先成为 store,最后才成为画布
  2. reconstruct 才是 BI 味最重的模块
  3. 大数据先决定留什么,再谈怎么画
  4. 性能改造为什么能一段段接上去
  5. 这套做法留下的账

打开 van-analysis 的目录,最先冒出来的问题是:一套图表引擎,为什么会同时放着 reconstructstoreinteractionbigdataexportrequest?顺着初始化流程读完,答案倒很一致。它承接的是 BI 运行时,不是网页里一张独立的小图。服务端协议、设计态、移动端、联动、导出和大数据都得在同一个实例里活着。

它选择的主干很像一套 Redux 应用。spec、数据、布局、hover、框选、地图缩放和筛选条件都进入 store;鼠标、触摸和外部容器不能直接改图元,只能 dispatch action。然后由 state 派生几何,再经虚拟树 diff 到 canvas。

Van-Analysis:spec 与 BI 数据经数据重建灌进唯一的 store;store 经记忆化派生、vdom diff、zrender 画布渲染成图元;交互只能通过 dispatch 把变化送回 store,形成闭环;大数据先采样再分片渲染

这张图里最容易被一笔带过的是「大数据先采样」。采样不是从头拿若干条,它要看图形的语义。下面用 1,200 个浏览器内生成的演示点,对比「直接截断」和「按图形采样」。它不是下文百万点实验的跑分复现,只负责把失真的方式画出来。

采样实验 · Canvas 实时绘制

同样只留 N 个点,哪一幅还像原数据?

折线用 LTTB 保轮廓;散点用按密度分配的网格采样。

直接截断只画前 72 个点
LTTB 采样横跨完整时间域
原始 1,200 点 保留 6% 横轴覆盖 100%

这套结构最初不是一次画好的。仓库跨过多条版本线;2024 年 9 月的 106ffd7e5 直接增加了一套新的图表数据结构,同时改动 reconstructchart.jsbigdata。大数据、框选、移动端手势也能在提交记录里看到连续修补。把它当成一台长期运行的 BI 内核,比把它当成某个时期设计出来的“框架作品”更容易理解。

从 5.0、6.0、6.1 到 7.0,仓库长期同时维护 dev、release、persist、final 和业务 feature 分支。框选报错、十字线、饼图漏斗图框选、Pad 迁移、移动端滑动这些看似零碎的提交,最后共同把 interaction 推成了一层。大数据也一样,先有能跑的版本,再暴露标记位、数据访问和切片稳定性问题。今天看到的模块边界,很多是线上问题把代码推过去的。

一张图先成为 store,最后才成为画布

这里确实用了 redux,store 由 createStore 创建,并挂上 thunk。图表初始化时,spec 和数据被包装成 init action;之后,这份 state 就是组件重算的入口。

src/chart/chart.js 里的顺序值得展开一下:先用 normalizeChartOption 规整调用参数,建立 store 和对外的 EventEmitter;数据回来后创建 DataController,再进 _updateState → reconstruct → dispatch(init);到最后才调用 render。Chart 实例更像一段运行时编排,画图只是其中一步。

state 到画面分两段。第一段由 computedSelect 完成:把字段、数据和交互状态换成柱高、坐标轴、图例位置等渲染所需结果。选择器按图做了记忆化,hover 变化时,不必把与数据有关的布局全部再算一遍。

第二段很像前端组件渲染:render(<VanChart store={...} />)。组件用 preact 写,但目标不是 DOM。中间的 visible 把 preact 风格的虚拟树接到 zrender 图元,diff 后只更新有变化的部分,最终落到 canvas。zrender 是 vendor 进来的,项目也用 $override 打过几处补丁。

真正的 Chart 组件反而很薄,主要把 FacetPlotCordonLegendHoverTooltip 组起来。大量业务判断没有塞进组件树,而是在 store、selector 和 reconstruct 里先算好。看代码时如果只追 Plot,很容易错过真正重的部分。

从源码分工看,可以拆出六层:组件层负责 Chart、geometry、坐标轴和图例;visible + zrender 负责渲染;Redux store 和 computedSelect 管状态与派生;reconstruct 和数据集处理 BI 数据;Mouse / Touch 把交互翻译成 action;地图则由 Leaflet 提供底图,再同步坐标。实际代码没有为了“六层架构”刻意排目录,但职责大致落在这些位置。

所有变化走 dispatch,解决的是交互互相踩状态的问题。hover、框选、缩放、resize、图表联动都按同一种方式进 reducer。hoverStage 甚至做成了位掩码,NORMAL、SELECT、BOX_SELECT 可以按位叠加,“悬停”和“正在框选”不需要争抢一个枚举值。

拿 hover 走一遍就很清楚:Mouse / Touch 先把坐标和命中对象翻成 action,reducer 更新 hoverStage 和对应索引,selector 只重算受 hover 影响的视觉结果,连接 store 的组件再把差异交给 visible。框选走的还是这条路,只是状态位和选中集合不同。两种交互能同时存在,是因为 reducer 组合状态,而不是两个 handler 轮流改同一个图元样式。

resize 和地图漫游也没有另开命令式旁路。容器尺寸、地图中心和 zoom 进入 state 后,坐标轴、图元和覆盖层从同一份数据重新对齐。Leaflet 负责地图瓦片与漫游,图表层负责把自己的坐标系贴上去;这块同步如果散落到各 geom,地图图表很快会变成另一套引擎。

这条路比直接改一个图元更长,所以配套优化不能少。连续 dispatch 会合批,避免每改一次就完整渲染;新 state 带一个 operateTimestamp,给下游做廉价的变更标记;记忆化选择器则尽量缩小重算范围。

store middleware 还负责把内部 action 翻译成宿主能听懂的事件,例如 initStateChangedscrollBoundsChangeinteractionModeChangelegend:togglelegend:rangeChange。一张 BI 图从来不是孤立组件:图例操作可能触发其他图刷新,地图缩放要回写初始化状态,筛选还要交给外层容器。这个事件桥把 Redux 内部状态和平台工作流接了起来。

Mouse 和 Touch 没有硬合成一套 Pointer Event。两端差的不只是事件名:移动端可能用 native tooltip,有自己的滚动、空白点击、选择和缩放规则,请求环境也不同。拆两套实现显得重复,反而比在每条路径里塞 if (mobile) 更容易确认边界。

不过“画面完全是 state 的纯函数”也不能说得太满。交互层有一处很务实的例外:命中测试、tooltip 定位和高亮对象选择,会直接读取已经生成的 zrender 图元。仅从原始 data 重新推导最近点和包围盒太贵,渲染树本身就是现成的空间索引。这个折中让架构不那么纯,却省掉了 hover 路径上的大量重复计算。

reconstruct 才是 BI 味最重的模块

服务端传来的数据不是普通数组,也不是一份前端可以直接消费的 option。它是多维、嵌套的 BI 协议,里面还混着样式、格式、脱敏结果、地图初始状态、联动和过滤信息。

reconstruct 负责把这份协议翻译成内部结构。它会处理 facet、geoms、legend、mapConfig、tagFiltersliderFilterresetFilterState,也会补齐 fontScaleadaptivedarkThemeenableMap 等运行时标记。dataSet 里的 Frame 再把数据整理成适合计算几何的形状,最后才 dispatch 到 store。

样式分发、字段格式、脱敏结果和 domain 映射也在这一步处理,图例、tooltip、坐标轴各取自己需要的视图。Frame 提供了接近 DataFrame 的访问方式,让后面的几何计算不必反复理解嵌套协议。

入口其实有两条:已经是内部 state 的数据,只补 computedState;带 resultType 的 BI 协议则走 transformBIOption。这层区分很有用,导出、回放或内部调用可以绕过一部分协议重建,平台接口仍然走完整转换。

单独留这层的价值很实际:Plot、Legend 或 Tooltip 不必认识服务端协议。业务兼容集中在入口,后面的组件只处理统一 state。坏处也很确定,所有特殊口径最后都会找到 transformGeomstransformFacettransformLegend 一类函数,转换层会越来越厚。

它的图形抽象也不是“柱状图组件”“折线图组件”一路堆下去。core.js 注册的是 point、line、interval、heatMap、pie、treeMap、gauge、sankey 等 geom,以及 plot、facet、legend、hover 等 guide。geom 表达画什么,guide 处理分面、解释和辅助交互。这种 grammar 对 BI 里的多指标、多图元叠加很合适,也让 TileChart 能复用同一份状态和图元体系去做平铺导出。

一张 BI 图经常同时有多个 measure、分面、图例过滤、cordon、crossline 和 marker。按“一个图表类型一个巨型组件”实现,组合数很快失控。geom + guide 把图形编码和辅助结构拆开,同一个 Legend / Tooltip 才能横跨柱、线、点复用。它没有让业务复杂度消失,只是给复杂度安排了比较稳定的落点。

导出不是简单截一张当前 canvas。TileChart 继承 Chart,但会换视口、平铺图块,并提供自己的图元检索和命中信息。页面交互和大幅面导出能共用 state / geom 体系,说明渲染结果从一开始就没有绑死在单个浏览器 viewport 上。

另一头的 Request 只定义壳:getSettingrequestDatagetCompleteUrl,以及 refresh、filter、tooltipInfo 等 service,再由 bi-pc.jsbi-mobile.js 接具体宿主。这样图表可以嵌入平台,也带来全局 BI、全局 Van 和运行期注入的耦合;脱离宿主做单测,不会像普通 npm 图表包那么轻松。

大数据先决定留什么,再谈怎么画

如果几十万个点全部进 store,再走派生、vdom diff 和 canvas,这套 Redux 结构会被自己的层次压垮。van-analysis 的处理顺序是先在数据入口控制规模,再分片把留下的内容画完。

这块是从失败里迭代出来的。旧方案把绘图计算放在后端 V8,数据一大,轻则前端长时间空白,重则后端被计算拖垮。提交历史里也留着早期问题:b1938cedb 修过 bigData 标记位设计,随后才引入 DataVisitor,继续补最后一个切片稳定、超时检测和散点图模式。当前数据控制器把模式分成 normalsimpleLTTBslice,已经是多轮修补后的统一入口。

这三种模式没有强行统一成一个 bigData: true。普通数据继续走 normal;服务端已经提供 shared.bigDataSlice.sample 时,前端可以直接进入 simpleLTTB;需要随视口滑动的数据再交给 slice。采样解决传输和总量,切片解决当前帧和当前视口,两者的职责并不相同。

把计算从后端搬回浏览器,最大的收益是缩小故障域。老 V8 路径里,一张图就可能独占资源,影响同一节点上的其他请求;浏览器侧先采样和分片以后,最差情况主要表现为当前页面变慢。对 BI 平台来说,这比单次跑分更重要。

采样要减少点数,同时尽量保住数据原来的形状和密度。原来有三个聚类,采完不能变成两个;各聚类的点数比例也不该被抹平。为此,代码按图的语义选择方法。

  • 时序折线一类看趋势的图用 LTTB,优先保住拐点和整体轮廓。
  • 两个轴都是指标的散点图用网格采样。每格固定拿一个点会拉平密度,所以代码让高密度网格获得更高抽中概率,在固定样本量下继续保留“哪里密、哪里稀”。
  • 两个轴都是维度时,先按第一次出现的次序把维度转成数值,再复用网格采样。
  • 一侧堆着多个维度和指标的情况用系统采样,按等差位置抽取。它不精巧,但比早期直接截断稳定。
  • 箱形图可以按上下边缘差值跑 LTTB。这样保住的是箱体变化幅度,不是均值;代码接受了这个偏差。

网格采样这里有一个容易忽略的细节。如果每个网格拿一个点,稀疏格和密集格权重完全一样,既不容易控制最终数量,也会把分布拉平。按格子内点数分配抽样概率以后,样本总数能固定,高密度区域仍然会留下更多点。这个选择比“用了什么算法名”更影响散点图是否可信。

维度转数值也不是在给类别制造大小关系,只是借用网格算法。按首次出现位置编码后,相同维度落到同一坐标,采样结束仍然还原原类别。这样省掉一套几乎相同的离散网格实现,代价是必须保证编码顺序在一次计算里稳定。

采样后仍可能超出一帧的预算,于是再做 slice rendering。控制器根据视口、滚动方向和单元尺寸决定当前窗口,只在支持的图元和 PC 场景启用,并给每段渲染设置时间预算,避免一次任务长时间占住主线程。它和列表虚拟化很像,只是虚拟化对象换成了图表数据。

当前切片只接 line、area、interval、point、forcePoint 等特定图元,并要求图表只有一个滚动方向。首屏按视口估出约两屏数据,滚动后把窗口扩到当前位置附近的约三倍,减少边缘来回抖动;debounce 和最大等待时间则在“别露空白”和“别连续重算”之间找平衡。移动端被明确排除在这个模式外。

有些图不能采。数万条线的折线图删哪条都改变语义;多维多指标散点删一个点可能同时丢掉一组关联数据;饼图每个扇区都参与百分比。早期统一截断引出过不少客户问题,后面这些场景宁可明确提示截断,也不制造一张看似完整、实际失真的图。

这里的提示对应一条语义边界。采样算法能保趋势或分布,不能凭空知道一条业务系列是否可删。遇到无法给出这种保证的图形,明确告诉用户只展示了多少数据,比返回一张没有警告的“漂亮图”可靠。

一个很能说明效果的例子是 100 万条身高—频次数据。同行产品超过 5000 条直接截断,正态分布的形状随之消失;按分布采样后,轮廓仍然看得出来。这块经过大约半年迭代,耗时从约 22 秒降到 2 秒出头,接近 9 倍提升。性能数字固然好看,我更在意的是它没有用失真去换这个数字。

性能改造为什么能一段段接上去

state 到几何、几何到图元原本就是两段相对独立的工作,这给后来的几次性能改造留下了位置。

这些优化没有要求调用方换一套图表 API,原因不神秘:计算和渲染之间原本就靠数据隔开了。替换执行位置,会改性能特征,却不必把交互和业务协议一起重写。

Worker 适合的是 state 到几何这段纯计算,主线程仍然负责图元和输入事件;Fiber 式调度改的是 diff 的执行节奏,不改变最终图元树;GPU 则针对大量点执行相同操作的并行计算。三件事切的位置不同,能同时存在,是因为主链路很早就把数据重建、几何派生和画布提交分开了。

这也给渲染后端留下了余地。zrender 今天承担 canvas 场景树,未来即使换成另一种图形后端,reconstruct、store、action 和大部分几何派生都不必跟着作废。当然,interaction 直接读取 zrender 图元这一处需要先解耦,否则“可替换”只停留在架构图上。

这套做法留下的账

Redux 带来的可预测性很有用。某一刻图表为什么是这个样子,通常能从 state 和 action 追到。但所有变化都得经过 reducer、派生和 diff,单点更新也不能例外。记忆化、合批和分片都在偿还这层抽象的运行成本。

依赖也一样。redux、preact、zrender、Leaflet 省下了从零造轮子的时间,vendor 和 patch 又意味着升级要自己接。再算上 grammar、visible、按场景拆开的采样和宿主 Request 适配,项目事实上维护着一套自己的小型运行时。老版本的 webpack 4、preact 8、Babel 组合以及全局对象注入,也让类型、构建和独立测试越来越费力。

如果继续整理,我会先收紧 reconstruct 的输入输出类型。服务端协议、内部 state 和 computedState 现在靠运行时约定接在一起,先把三者分成明确类型,收益会比重写 Plot 更早出现。大数据控制器里对原始对象的修改也值得纯化;以后接 Worker、缓存或回放时,纯输入输出更容易复用。

第二件是把“命中索引”从 zrender 实例里显式抽出来。直接读 render tree 是合理的性能折中,但不该成为交互层对具体渲染后端的隐含依赖。独立索引可以同时服务离屏渲染、导出和 Worker,也给以后替换 canvas 后端留空间。

至于 Mouse / Touch,我反而不会急着合并:这里很多差异是产品交互差异,不只是事件名不同。先升级构建、测试、类型和模块边界,通常比追求事件 API 的形式统一更有收益。

van-analysis 最有用的经验并不复杂:BI 图表的麻烦集中在数据进入画布前后的状态、协议和规模控制。它为这些事情付出了不少框架成本,但至少成本花在了真实存在的问题上。