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

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

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

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

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

ts 复制代码
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 渲染出来是这样:

html 复制代码
<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:

ts 复制代码
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 条):

ts 复制代码
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 个边界用例兜底------这比「所有图表长得一样」更有价值。


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

相关推荐
FlyWIHTSKY19 小时前
vue2和vue3有什么区别,用法上有哪些区别
前端·javascript·vue.js
ruanno_top1 天前
能点,但 Tab 不到:两个组件把「可操作」做了一半
vue.js
爱吃烧烤的技术渣1 天前
别再手写 CRUD 了:我把 Element Plus 的工作量压缩成一份列 schema
前端·vue.js
徐小夕1 天前
万字长文!DeepSeek Super-agent × 企业知识库架构深度拆解与落地实践
前端·vue.js·github
ruanno_top2 天前
倒计时该用 deadline 还是每秒减一?我把两种写法都跑了一遍
vue.js
徐小夕2 天前
下载量超 30 万的开源智能体 Prime Agent:它把自己从 TypeScript 重写成了 Rust
前端·vue.js·github
toooooop82 天前
CRMEB 故障排查笔记:安全网关只放行 GET/POST 怎么办
前端·javascript·vue.js·thinkphp6·crmeb
软件技术NINI2 天前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
FRing033 天前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2