给 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 }[] |
| GraphChart | nodes: { 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 试了四个图表:
| 组件 | 传入 | 实测产物 |
|---|---|---|
| LineChart | value: "abc" | d="M40,NaN L40,NaN" |
| BarChart | value: "abc" | <rect y="NaN" height="NaN"> |
| HeatmapChart | value: "abc" | fill-opacity="NaN" |
| ScatterChart | x: "a" | cx="NaN" |
| PieChart | value: "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;
});
三个决定:
- 不是抛错,是跳过。一个脏数据不该让整张图白屏;跳过并告知,用户能立刻发现并修。
- 只在 DEV 警告。生产环境刷控制台没有意义,还会污染日志。
- 把出错的那一条打出来。只说「有 3 个点非法」等于让用户自己找;打印样本才能定位。
加完之后,传错结构的结果是「空图 + 一条明确的警告」,而不是「一张画不出点的图和满屏 NaN」。
这条警告不会进生产包。 import.meta.env.DEV 在构建时被替换成 false,整段 if 会被摇掉——我 grep 了产物 dist/ruanno-ui.js,搜不到警告里的任何中文片段。所以它只开发时提醒,线上不占体积、也不刷日志。
类型检查为什么没拦住
一个合理的疑问:TypeScript 不是有类型吗?
三个漏洞:
- 示例代码是字符串。组件文档的示例存在
meta.json的code字段里,是一段字符串,构建时被塞进 demo 编译器——它从不经过 tsc。所以示例里写错数据形状,类型系统看不见。 - 测试里我自己加了
as never。为了让「故意传错」的用例能编译,我亲手绕过了类型检查。真实用户如果用 JS 项目,也等于没有这层。 - 接口返回是
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」。这两件事在业务上是完全不同的意思。
关系图的孤立节点:给了 4 个节点、只有 2 条连线,第 4 个节点(测试)没有任何边。实测渲染 4 个 node + 2 条 link——孤立节点照常显示,不会被布局算法丢弃。
带权重的连线下,value 会影响边的粗细:
结论
这一轮学到两件事,都跟「我以为」有关:
第一,NaN 出现时先怀疑数据,再怀疑组件。 我第一反应是「组件除零了」,实际是「我传的字段它没有」。类型系统本该拦住这个,但示例数据是我在测试里手写的,恰好绕过了 TS。
第二,一致性不是目标,可预期才是。 六个图表四种数据结构,只要每种都有明确的类型、运行时校验和边界示例,用户就不会踩坑。反过来,强行统一成一种形状,反而会把二维和图形结构压扁成不适合的模型。
现在 ScatterChart 传错数据会得到一条明确的警告,加上 25 个边界用例兜底——这比「所有图表长得一样」更有价值。