一个脏数据能让整条折线消失:图表组件的四种数据契约

0 阅读6分钟

给 6 个图表组件补边界示例之前,我先写了一个探测测试:每个图表喂 空数组 / 单点 / 全零 / 负值 四种数据,断言渲染出的 HTML 里不出现 NaN 或 Infinity。

第一次跑,ScatterChart 挂了三个用例,DOM 里赫然是 cx="NaN" cy="NaN"。我以为抓到了组件 bug——结果是我自己传错了数据结构。

先看这个「假 bug」长什么样

我的探测代码对六个图表用了同一份数据:

const cases = {
  empty: [],
  single: [{ name: "A", value: 10 }],
  allZero: [{ name: "A", value: 0 }, { name: "B", value: 0 }],
  negative: [{ name: "A", value: -5 }, { name: "B", value: 3 }],
};

对折线、柱状、饼图都成立。但 ScatterChart 渲染出来是这样:

<circle cx="NaN" cy="NaN" r="4" class="rn-chart__point"></circle>

因为散点要的是 { x, y },我传的是 { name, value }。Math.min(...data.map(d => d.x)) 拿到一堆 undefined,算出 NaN,再传给 SVG 属性——浏览器不报错,只是画不出来。

把数据换成正确结构后重跑:25 个用例全过(6 个图表 × 4 种边界,Scatter 多一个「y 全相同」)。

所以这不是组件 bug。但它暴露了一个更值得说的问题。

同一套图表,四种数据契约

我把六个图表的数据结构列了一下:

组件数据类型
LineChart / BarChart / PieChart{ name: string; value: number; series?: string }[]
ScatterChart{ x: number; y: number; size?: number; series?: string }[]
HeatmapChart{ x: string; y: string; value: number }[]
GraphChartnodes: { id, label?, value? }[] + links: { source, target, value? }[]

六个图表,四种形状:{name,value}、{x,y}、{x,y,value}、独立的 nodes + links。

这不是随手写的。它们各自对应不同的数据语义:

  • 折线/柱状/饼图是一维分类:一个名字对一个数值;
  • 散点是二维坐标:两个数值确定位置,名字只是附注;
  • 热力图是二维矩阵:两个分类确定一个格子的强度;
  • 关系图是图结构:节点和边在数学上就是两类实体。

如果强行统一会怎样

一个自然的想法是「都改成 {name, value}」。推演一下代价:

散点被迫降维。 {name: "A", value: 10} 只有一个数,画不出二维位置;要么把 name 当 x 轴(那就变成条形图了),要么再加一个字段——绕一圈还是 {x, y}。

热力图要多写两层壳。 现在是 {x: "周一", y: "早", value: 12},改成 {name: "周一/早", value: 12} 后,组件还得把 name 拆开,字符串拼接成了一个隐式协议,比现在的显式字段更脆。

关系图根本统一不了。 节点和边是两套实体,边的两端要引用节点 id。硬塞进一个数组,就得再造一个 type: "node" | "link" 的判别字段——这是把图结构降级成扁平表,反而更难用。

所以「不一致」本身是合理的。真正的问题不是不一致,而是不一致没有被告知:类型定义里有(TS 会报错),但运行时没有任何保护——传错了就是静默的 NaN。

顺着查下去:不止 Scatter 会输出 NaN

既然 {name,value} 传错能产生 NaN,那「value 本身不是数字」会怎样?我把字符串塞进 value 试了四个图表:

组件传入实测产物
LineChartvalue: "abc"d="M40,NaN L40,NaN"
BarChartvalue: "abc"<rect y="NaN" height="NaN">
HeatmapChartvalue: "abc"fill-opacity="NaN"
ScatterChartx: "a"cx="NaN"
PieChartvalue: "abc"无 NaN(内部做了归一化)

折线那条最严重:一个脏点会让整条 path 失效——浏览器遇到非法 d 属性会丢弃整个路径,不是只丢一个点。也就是说数组里混进一条 value: "12px"(从接口拿到的字符串),整张图就空了。

热力图那条更阴险:fill-opacity="NaN" 是非法值,浏览器直接忽略这个属性,回落到初始值 1——于是脏数据渲染成最深的一格,看起来像"这里最热"。错误数据伪装成极值,比显示不出来更危险。

所以我给 Line / Bar / Heatmap 也加了同一套守卫,规则一致:过滤非法点、DEV 下警告一次、打印首个错误样本。

补的第一层保护:运行时校验 + 警告

我给 ScatterChart 加了一个 validData:

const validData = computed(() => {
  const ok = props.data.filter((d) => Number.isFinite(d.x) && Number.isFinite(d.y));
  if (import.meta.env.DEV && ok.length !== props.data.length) {
    console.warn(
      `[ScatterChart] 忽略了 ${props.data.length - ok.length} 个坐标非法的数据点…`,
      props.data.find((d) => !Number.isFinite(d.x) || !Number.isFinite(d.y)),
    );
  }
  return ok;
});

三个决定:

  1. 不是抛错,是跳过。一个脏数据不该让整张图白屏;跳过并告知,用户能立刻发现并修。
  2. 只在 DEV 警告。生产环境刷控制台没有意义,还会污染日志。
  3. 把出错的那一条打出来。只说「有 3 个点非法」等于让用户自己找;打印样本才能定位。

加完之后,传错结构的结果是「空图 + 一条明确的警告」,而不是「一张画不出点的图和满屏 NaN」。

这条警告不会进生产包。 import.meta.env.DEV 在构建时被替换成 false,整段 if 会被摇掉——我 grep 了产物 dist/ruanno-ui.js,搜不到警告里的任何中文片段。所以它只开发时提醒,线上不占体积、也不刷日志。

类型检查为什么没拦住

一个合理的疑问:TypeScript 不是有类型吗?

三个漏洞:

  1. 示例代码是字符串。组件文档的示例存在 meta.json 的 code 字段里,是一段字符串,构建时被塞进 demo 编译器——它从不经过 tsc。所以示例里写错数据形状,类型系统看不见。
  2. 测试里我自己加了 as never。为了让「故意传错」的用例能编译,我亲手绕过了类型检查。真实用户如果用 JS 项目,也等于没有这层。
  3. 接口返回是 any。fetch 拿到的 JSON 断言成 { value: number }[] 只是口头承诺,"12px" 照样能进来。

这也解释了为什么运行时守卫不可省:类型只在编译期约束写代码的人,不约束数据。

第二层保护:25 个边界用例

探测脚本没有删,改成了正式测试 src/__tests__/chart-edge-data.spec.ts,并把「字符串值」也加进用例(共 30 条):

for (const [label, comp] of Object.entries(charts)) {
  for (const [caseName, data] of Object.entries(cases[label])) {
    it(`${label} / ${caseName} 不产生 NaN 或 Infinity`, () => {
      const wrapper = mount(comp, { props: { data } });
      expect(wrapper.html()).not.toMatch(/NaN|Infinity/);
    });
  }
}

断言只有一句,但它能抓住一整类问题:除法、取最大值、比例计算在边界数据下的塌陷。30 条用例覆盖 6 个组件,全部断言「HTML 里不出现 NaN 或 Infinity」,成本几乎为零。

一个统计口径的教训

按 ROADMAP 本该补「图表 6 个」,我照单全做了示例,后来核对才发现 BarChart 早就有 3 个示例(基础 / 分组堆叠 / 横向直方图),真正缺的只有 5 个。

这说明我一开始没有先跑一遍「谁真的少于 3 个」,而是照着计划里的措辞开工。计划里的数量是历史快照,动手前应该重新统计一次——这次多写的那个示例不算白费(边界数据本身有用),但流程上确实是浪费。

边界数据下的实测行为

补示例时顺便量了各图表在边界下的真实表现,有些细节比我预想的好:

折线图的多序列:同一个 name 配不同 series,会渲染成两条独立的线,图例自动从 series 去重生成。实测 2 条 path、2 个图例(今年 / 去年)。

折线图多序列:两条平滑曲线,图例自动生成「今年 / 去年」

对比单序列的基础形态:

折线图基础:单条曲线 + 面积填充

饼图空数据:实测渲染 0 个扇形,只显示「暂无数据」。这一点比想象中重要——饼图要按 value / sum 算角度,sum 为 0 时如果不特判就是 NaN 或 Infinity。

饼图空数据边界:不画任何扇形,只显示「暂无数据」文案

正常数据下的样子:

饼图基础:五个分类的扇形与图例

散点单点:只有一个点时,坐标域会按该点对称展开(niceDomain 里 lo === hi 的分支),而不是把点钉在角落。实测 cx = 333、cy = 90,都是有限值。

散点图单点边界:一个点也被放在坐标系中部,而不是贴边

散点图基础:多组散点分布

热力图零值:这是我最想确认的一个。零值不是用「不画」表达,而是用最低色阶——实测 fill-opacity 取值为 0.12(零值)、0.648(中值)、1(最大值)。

如果零值用 opacity: 0,格子在视觉上直接消失,用户会以为「这里没有数据」,而实际上是「这里数据是 0」。这两件事在业务上是完全不同的意思。

热力图零值边界:零值格子用最浅色阶(opacity 0.12)显示,而不是消失

热力图基础:三个时间段 × 三天的强度分布

关系图的孤立节点:给了 4 个节点、只有 2 条连线,第 4 个节点(测试)没有任何边。实测渲染 4 个 node + 2 条 link——孤立节点照常显示,不会被布局算法丢弃。

关系图孤立节点:四个节点中「测试」没有连线,仍然正常渲染

带权重的连线下,value 会影响边的粗细:

关系图带权重:六条边的粗细不同,对应 value 大小

结论

这一轮学到两件事,都跟「我以为」有关:

第一,NaN 出现时先怀疑数据,再怀疑组件。 我第一反应是「组件除零了」,实际是「我传的字段它没有」。类型系统本该拦住这个,但示例数据是我在测试里手写的,恰好绕过了 TS。

第二,一致性不是目标,可预期才是。 六个图表四种数据结构,只要每种都有明确的类型、运行时校验和边界示例,用户就不会踩坑。反过来,强行统一成一种形状,反而会把二维和图形结构压扁成不适合的模型。

现在 ScatterChart 传错数据会得到一条明确的警告,加上 25 个边界用例兜底——这比「所有图表长得一样」更有价值。


在线示例:ui.ruanno.top/components/… 源码:gitee.com/ruanno-top/…