【本章目标】
学完这一章,你能:



【原理先行】
一、同一张图,七种喂法
先约定一件小事:本章所有实测都用同一份 12 行 × 3 列的月表——2025 年逐月的销售额、杯数、订单数(01 月 / 3126085.4 / 110552 / 69039……)。这份表由 data/sales-daily.json 现场聚合出来,聚合代码在本章工程里,你运行一次就能打印全部 12 行。
同一张「柱 + 折线」的图,可以把数据写成七种样子:
// 写法 1:series.data 纯数字数组(类目另写给 xAxis.data)
xAxis: { type: 'category', data: monthly.map((r) => r.month) },
series: [{ type: 'bar', data: monthly.map((r) => r.sales) },
{ type: 'line', data: monthly.map((r) => r.cups) }]
// 写法 4:dataset.source 对象数组,不写 encode
dataset: { source: monthly }, // [{month, sales, cups}, …]
series: [{ type: 'bar' }, { type: 'line' }]
// 写法 5:dataset.source 二维数组带表头
dataset: { source: [['month', 'sales', 'cups'], …] },
series: [{ type: 'bar', encode: { x: 'month', y: 'sales' } },
{ type: 'line', encode: { x: 'month', y: 'cups' } }]
// 写法 6:dataset.source 按列对象
dataset: { source: { month: […], sales: […], cups: […] } }
实测结果(lib/echarts.min.js 6.1.0,SSR 渲染,680×380):
| 1 series.data 纯数字数组 | 14,395 | 39 条 d + 20 段文字,共 1,681 字符 | — |
| 2 series.data 二维数组 | 14,436 | 1,681 | 是 |
| 3 series.data 对象数组 | 14,126 | 1,647 | 否 |
| 4 dataset.source 对象数组 | 14,436 | 1,681 | 是 |
| 5 dataset.source 二维带表头 | 14,436 | 1,681 | 是 |
| 6 dataset.source 按列对象 | 14,476 | 1,681 | 是 |
| 7 dataset.source 二维无表头 + dimensions | 14,507 | 1,681 | 是 |
两点要解释。
为什么不用字节数判断? 字节数会漂。zrender 给每个图形挂的 class 名是全局递增计数器(zr0-cls-3、zr1-cls-8……),同一张图渲染两次就可能差出 39 字节。可靠的办法是把 SVG 里所有 path 的 d 属性和所有文字按出现顺序串起来,得到一个「图形指纹」——形状没变,指纹就不变。这张表里七个指纹中六个完全相同:这些写法画出来的是同一张图。
写法 3 为什么不一样? 对象数组喂进 series.data 时,ECharts 不认识你的字段名,类目轴只能按下标编号:实测 x 轴文字变成了 0~11 而不是 01 月~12 月。补上 xAxis.data 之后,写法 3b 的图形又和写法 1 逐字符相同了。结论:series.data 用对象数组必须自己给类目轴补 data;dataset 不用,因为表里第一列就是类目。
这七种写法不是「都能用,随便挑」。它们的区别在第 3、4、6 节里会一条条暴露出来:能不能一份数据喂多个系列、能不能改名、能不能整形。选型的完整口诀放在参数速查表五。
二、ECharts 内部只认一张表
不管你从哪扇门进去,数据最后都被摊成「一张表 + 一份列名」。发布包源码 node_modules/echarts/lib/util/types.js 第 50–55 行把这几种来源形状写死成六个常量:
| original | 一切 series.data | ["x","y"](generic) |
| arrayRows | 二维数组,有无表头都算它 | 有表头 → ["date","sales","cups"];无表头且不声明 → ["x","y","value"] |
| objectRows | 对象数组 | 表头取自对象的键 |
| keyedColumns | 按列对象 | 键名即列名 |
| typedArray | Int32Array 之类的定型数组 | 只在源码判断里出现 |
| unknown | 兜底 | — |
typedArray 和 unknown 本章不演示:前者由 sourceManager.js 第 210 行的 isTypedArray(data) 判定,后者是认不出来时的兜底。读者要记住的是前三行加 keyedColumns——四个能用的门,其中 series.data 那一扇门后面没有列名可言。
这三个字「列名」很重要,因为它决定了第三节那件事:谁是维度。
三、维度、系列、通道——三个词必须分清
维度(dimension)= 表的一列。 一份 dataset.source 有 3 列,就是 3 个维度。系列(series)= series 数组的一项。 通道(channel)= 某个图表类型需要的「输入槽位」:柱状图和折线图要 x 和 y,散点图要 x/y,饼图要 itemName 和 value。
月表(dataset.source) 系列与通道
┌────────┬───────────┬────────┐
│ month │ sales │ cups │ ← 3 个维度(列)
├────────┼───────────┼────────┤
│ 01 月 │ 3126085.4 │ 110552 │
│ 02 月 │ 3099878.3 │ 109998 │
│ … │ … │ … │
└────────┴───────────┴────────┘
│ │ │
x 通道 y 通道 y 通道
↓ ↓ ↓
series[0] 柱状图 series[1] 折线图
你不写 encode 时,ECharts 会替你排队。 规则是实测出来的,一条一条列清楚:
| 不写 encode,模型层实际读到的 y 列 | sales | cups | orders | sales | sales |
- 第一列(month)被当作类目,不进队列;剩下三列数值排队发给各系列。
- 队列表里有三个数值列,但你可以写无限多个系列:第四个系列重新从队头拿 sales,第五个还是 sales(实测读数见上表)。系列数超过数值列数之后是重复,不是报错。
- 队列按「列在表里的顺序」发,不按列名、不按数值大小。
排队有两个当场可见的副作用,都在本章页面的模式①上:
模式① 图例文字 = ["sales","cups"] ← 列名直接成了系列名
模式① y 轴刻度 = 0 ~ 5,000,000 ← 被最大那一列(销售额)撑满
模式② 图例文字 = ["杯数","订单数"] ← 写了 name/encode 之后
模式② y 轴刻度 = 0 ~ 180,000
第二条最能害人的是:你想看杯数趋势,折线却和销售额共用一把 0~5,000,000 的尺子,杯数那 11 万点上去就是一根贴地的直线。这就是「不写 encode 也能跑」这句话的代价。
不写 encode:ECharts 替你选 写 encode:你钉死
┌──────────┐ ┌────────┐ ┌──────────┐ 指定 ┌────────┐
│ 系列 bar │◄──┤ sales │ │ 系列 bar │◄──────┤ cups │
│ 系列 line│◄──┤ cups │ │ 系列 line│◄──────┤ orders │
│ (队列第1)│ ├────────┤ └──────────┘ 钉住 └────────┘
│ │ │ orders │ ▲ 谁都不再去排队
└──────────┘ └────────┘
四、encode:把列钉到通道上
encode 写在系列上(不是写在 dataset 上),值是「通道名 → 维度」的映射:
series: [{ type: 'bar', encode: { x: 'month', y: 'cups' } }]
维度可以用列名,也可以用下标,实测等价:
| encode: { x: 'date', y: 'avgTicket' } | avgTicket | 0 ~ 60 | 6,119 |
| encode: { x: 0, y: 4 }(avgTicket 是第 4 列) | avgTicket | 0 ~ 60 | 同上 |
| encode: { x: 'date', y: 'orders' } | orders | 0 ~ 1,500 | 5,833 |
| encode: { x: 0, y: 3 } | orders | 0 ~ 1,500 | 5,833 |
除了 x/y,还有三个非几何通道,它们决定「显示什么数字」而不是「画多高」:
encode: { x: 'date', y: 'sales', label: ['cups', 'orders'], tooltip: 'avgTicket' }
实测规则(都用同一份 8 列日表,12 行):
饼图的通道叫 itemName 和 value(第 16 章细讲):
dataset: { source: [{ cat: '甲', amt: 12 }, { cat: '乙', amt: 8 }] },
series: [{ type: 'pie', encode: { itemName: 'cat', value: 'amt' } }] // 实测正常,1,905 字节
五、给维度起名字:dimensions 与 displayName
列名会直接变成图例文字和默认系列名(第三节实测 ["sales","cups"]),所以人人都想改名。有两种写法,只有一种是安全的:
// 写法 A:重新定义列名(危险)
dataset: { source: monthly, dimensions: ['月份', '销售额', '杯数', '订单数'] }
// 写法 B:保留列名,只加展示名(安全)
dataset: { source: monthly, dimensions: [
{ name: 'month', displayName: '月份' },
{ name: 'sales', displayName: '销售额' }
] }
实测(同一个对象数组源、一个柱状图系列):
| A:对象数组 + dimensions 改成中文名 | 空图,446 字节,<text> 数量 0,柱子 0 根,不报错 |
| A’:同上,但 encode 改用下标 {x:0, y:1} | 同样空图,446 字节 |
| B:{name, displayName} | 正常 4,916 字节;图例显示 销售额/杯数 |
| C:二维数组带表头 + dimensions 只给 2 个名字(源有 3 列) | 正常,4,916 字节 |
| D:按列对象 + dimensions: ['日期','销售额'](键名本来就是中文) | 正常 |
差异的根因是「dimensions 是重新定义列名,不是贴标签」:对象数组的取值靠列名去 row[列名] 里拿,你把列名改成 销售额 之后,源里没有这个键,每一列都取不到值——连改用下标编码都救不回来(A’ 那行)。而二维数组和按列对象上 dimensions 是安全的,因为前者本来就没有自带列名(表头被覆盖是应该的),后者的键名就是你写的名字。
displayName 只影响「显示」,取值仍用 name。还有一个不对称的细节值得记:
// 轴标签和图例用的是 displayName,但回调参数里给的是原始 name
tooltip: { formatter: (p) => p.dimensionNames.join(',') } // 实测打印 "date,sales",不是 "日期,销售额"
所以 dimensions 数组请按源里的键名原样抄一遍再改名,或者直接用它更安全的形式 {name, displayName}——本书后面所有章节都只用后者。
六、seriesLayoutBy:把表转置过来读
默认读法是「一列一个系列」('column')。Excel 里更常见的表是「一行一个指标」:
01 月 02 月 03 月 … 12 月
销售额 3126085 3099878 3091234 … 4068117
杯数 110552 109998 109590 … 141106
订单数 69039 69696 69374 … 88282
这种表只要给 dataset 加一行 seriesLayoutBy: 'row':
dataset: { source: transposed, seriesLayoutBy: 'row' }, // transposed 是上面那张表的二维数组
series: [{ type: 'bar' }, { type: 'line' }]
实测(本章页面模式④):图例文字是 销售额 和 杯数——系列名自动取了每行的第一个格子;类目轴仍是 01 月~12 月。也就是说行布局下「第一行 = 类目名、第一列 = 系列名」,和列布局正好交换。
三条边界:
- series: [](一个系列都不写)在行布局下不会自动展开成三行:实测系列数 0,图上没有任何数据图形。行布局只改变读法,不替你造系列。
- 对象数组上写 seriesLayoutBy: 'row' 没有任何作用:实测 undefined/'column'/'row' 三种写法得到逐字节相同的 6,468 字节 SVG。对象数组没有「行/列」可转,这条规则写在源码的读表逻辑里,不在配置层。
- 这个字段 dataset 上和 series 上都能写,写在系列上表示「只有这个系列按行读」(第 33 章会用到混合布局)。
七、一份数据喂多个系列:datasetIndex、链式引用与 transform
dataset 写成数组就有了「多份表」,这时必须讲清楚谁吃哪一份:
dataset: [
{ id: 'raw', source: monthly }, // 第 0 份
{ id: 'big', fromDatasetId: 'raw', transform: [ // 第 1 份:吃第 0 份,先过滤再排序
{ type: 'filter', config: { dimension: 'sales', '>': 4000000 } },
{ type: 'sort', config: { dimension: 'sales', order: 'desc' } }
] }
],
series: [{ type: 'bar', datasetId: 'big', encode: { x: 'month', y: 'sales' } }]
四条实测规则:
dataset: [
{ source: src }, // 4 行
{ transform: { type: 'filter', config: { dimension: 'sales', '>': 40000 } } }, // 应剩 2 行
{ transform: { type: 'sort', config: { dimension: 'sales', order: 'desc' } } } // ← 输入是谁?
]
// series: [{ type: 'bar', datasetIndex: 2 }]
实测:系列读到 4 行(66005, 46883, 39529, 32359,全表降序)——第 2 层根本没吃到过滤结果,因为没声明输入时默认从第 0 份取。加上 fromDatasetIndex: 1 之后立刻变成 2 行(66005, 46883),写成 fromDatasetId 也一样。
所以:能用一层管道就别拆成多层。 一个 dataset 里写 transform: [filter, sort] 是数组顺序执行的(实测同样得 2 行、同样的顺序);反过来写 [sort, filter] 得到的是「先排序再挑」的结果,顺序变了输出行的次序就变(实测 6 行月份从 01 07 06 08 11 10 变成 10 11 08 06 07 01)。transform 数组就是流水线,书写顺序即执行顺序。
dataset: [{ source: monthly },
{ transform: [{ type: 'filter', config: { dimension: 'sales', '>': 4000000 } }] }],
series: [{ type: 'bar', datasetIndex: 1, encode: { x: 'month', y: 'sales' } },
{ type: 'line', datasetIndex: 1, encode: { x: 'month', y: 'cups' } }]
实测两个系列各 6 行,过滤只做了一次。写三份链、让两个系列分别认领第 1 层和第 2 层也合法,但那两个系列的类目顺序不同,会共用一根类目轴——实测轴上冒出 12 个类目文字(前 6 个原序、后 6 个降序)。同一张图上的多个系列应当吃同一份结果。
filter 与 sort 是 6.1.0 唯一的两个内置整形器(源码 node_modules/echarts/lib/component/transform/filterTransform.js 第 48 行、同目录的 sortTransform.js 第 54 行)。它们的注册名带 echarts: 前缀,但配置里必须写不带前缀的名字:
| type: 'filter' / type: 'sort' | 正常 |
| type: 'echarts:filter' | 抛错。生产版 Error:(消息是空的),开发版 Can not find transform on type "echarts:filter". |
| type: 'aggregate'、type: 'print' | 同样抛错——聚合不是内置 transform,得自己写(第 33 章) |
filter 的配置是一个「关系条件对象」,运算符有 >、>=、<、<=、=、!=、<>、value、reg 这些(实测 >、>=、=、reg 都可用),复合条件用 and/or/not 嵌套:
transform: { type: 'filter', config: {
or: [{ dimension: 'sales', '>': 6000000 }, { not: { dimension: 'openStores', '>=': 5 } }]
} }
sort 只要 {dimension, order: 'asc'|'desc'},也可以给一个数组按多列排。sort 只吃二维数组和对象数组:源是按列对象时实测抛错(开发版消息 Can not find dimension info via: sales. Existing dimensions: [{"index":0},{"index":1}])。
八、缺失值与脏值:ECharts 怎么判空
这是本章最后一块,也是主线数据故意埋的雷。data/sales-daily.json 里有四个显式保留的缺失日(2025-03-14、2025-03-15、2025-07-22、2025-11-03),它们的数值字段是 null。
2025-03 逐日销售额(31 天,第 14、15 日是 null)
┌──┬──┬──┬──┬──┬──┬──┬──┬──┐
│13│ │ │ │16│17│18│19│20│ ← 实测 29 根柱子,不是 31 根
└──┴──┴──┴──┴──┴──┴──┴──┴──┘
▲ ▲
null null 空位不画,但类目还在(轴上仍占一格)
折线断在哪里,connectNulls 改的又是什么:
connectNulls 不写 / false connectNulls: true
M110.2 156.4 L… M110.2 156.4 L…
…L603.8 201 …L603.8 201
┌─ M 出现 2 次 ─┐ ┌─ M 出现 1 次 ─┐
一条 path,两段线,中间断开放大 一条 path,一段线,直接跨过去
实测细节(d 属性长度两种情况都是 331 字符,SVG 都是 13,227 字节):M(移到某点)和 L(画到某点)都是一个字母,断点只多一个 M,字节数完全一样。 所以「看字节有没有变」判断不了断线,得数 M 的个数——本章页面右下角那个读数就是在数它。
完整的脏值对照表(值放在 series.data 里和放在 dataset 的一列里,行为完全相同;4 个数据点 10, <脏值>, 30, 40,数 fill="#5070dd" 的 path 得柱数):
| 正常数字 20 | 20 | 4 | — |
| null | NaN | 3 | 不画,也不报错 |
| undefined | NaN | 3 | 同上 |
| 字符串 '-' | NaN | 3 | 官方文档推荐的「无数据」写法,等价于 null |
| 空字符串 '' | NaN | 3 | |
| 字符串数字 '20' | 20 | 4 | 会被转成数字,CSV 直接喂进来能用 |
| 字符串小数 '22.75' | 22.75 | 4 | 同上 |
| 带单位 '20 元' | NaN | 3 | 单位必须自己剥(第 33 章) |
| NaN | NaN | 3 | |
| 0 | 0 | 4 | 0 是有效值,画出一根高度为 0 的柱(实测 d 里是 l0 0) |
| false | 0 | 4 | 被当 0 |
| true | 1 | 4 | 被当 1 |
| 空对象 {} | NaN | 3 | |
| 字符串日期 '2023-01-01' | NaN | 3 | 数值轴不吃日期字符串,轴请用 type: 'time' |
类目轴上的脏值是另一回事——它们不丢数据,只是变成难看的标签文字:['a', null, 'c', 'd'] 实测轴上印出 a null c d(null 被转成了字符串),['a', '-', 'c', 'd'] 印 -,['a', '', 'c', 'd'] 那一格什么都不印。
饼图对这些值更不宽容(每项写成 {name, value}):
| 12, 8 | 2 | 项0、项1 |
| 12, null / 12, '-' | 1 | 只有项0 |
| 12, 0 | 1 | 项0、项1(0 值项有标签但没有角度) |
| 12, -5 | 1 | 只有项0 |
| 12, '8' | 2 | 项0、项1(字符串数字照算) |
九、一维数据与二维数据:图形适配规则
同一个源,喂给不同的图表类型需要的维数不一样。实测适配表:
| 一维数字数组(series.data) | ✅ 按下标当类目 | ⚠️ 只有 x 有值,y 是 null(实测符号数 0) | ✅ 值即角度,无名字 |
| 二维数组 [[12],[8],[20]] | ⚠️ 类目轴上 0 根柱(实测 949 字节) | ✅ 两个数当 x/y | ✅ 3 个扇形 |
| 二维数组 [['甲',12],['乙',8]] | ✅ 第一维当类目 | ✅ | ❌ 有元素无几何(见坑 4) |
| 对象数组 | ✅ 需要 encode 或排队 | ✅ 两个数值维自动成 x/y | ✅ 字段名是 name/value 时自动,否则要 encode |
| dataset 只有一列 | ❌ 0 根柱(958 字节) | ❌ | — |
| 二维无表头且不写 dimensions | ✅ 但列名退化成 x/y/value | ✅ | — |
两条需要记住的机制:
单列表会被补一个空的 y 维度。 实测 dataset: { source: { v: [1,2,3] } } 的模型层读数是 dims: ["v","y"]——ECharts 给缺的那个槽位造了个名叫 y 的维度,但里面一个值都没有,于是柱高/线高全部取不到,图上只有轴和文字(958 字节、0 根柱)。折线也一样:1,503 字节、0 条线。 修法就是「补一列,或者把同一列同时编码给 y」。
二维数组喂给类目轴时,第一维会被当成 x 值。 [[12],[8],[20]] 里的 12 会被拿去类目表里找「类目 12」,找不到就一根柱都不画(实测系列读数 count: 3 但 y 全为 null)。类目轴 + 二维数据,第一维必须是类目名。
【最小可跑】
本章的工程是一张「喂法对比台」:上面一张图配五个模式按钮,中间两张小图专管缺失值,最下面一段文字把 data/sales-daily.json 的体检结果和聚合表打印出来。建 ch10 文件夹,两个文件。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>第 10 章 数据怎么喂进去</title>
<style>
body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; }
h1 { font-size: 20px; margin: 0 0 6px; }
h2 { font-size: 15px; margin: 26px 0 6px; border-left: 4px solid #8a5a2b; padding-left: 8px; }
#status { background: #eef5fb; border-left: 4px solid #3d7ab8; padding: 8px 12px; margin: 10px 0 14px; }
#modeInfo { background: #fff8ea; border-left: 4px solid #b6902f; padding: 8px 12px; margin: 8px 0 0;
font-size: 13px; min-height: 3.4em; }
#report { background: #f6f4f1; border: 1px solid #d9d2c7; padding: 12px; margin: 0;
white-space: pre; overflow: auto; font: 12px/1.7 Consolas, monospace; }
button { padding: 6px 12px; margin: 0 8px 8px 0; cursor: pointer; }
p.tip { font-size: 12px; color: #6f675c; margin: 6px 0 0; }
</style>
</head>
<body>
<h1>同一份月表,五种喂法——看清楚 ECharts 替你选了哪一列</h1>
<p id="status">正在读 data/sales-daily.json ……(本页用了 fetch,必须用 node tools/serve.mjs 打开)</p>
<h2>一、喂法对比台</h2>
<div id="chart" style="width:680px; height:380px;"></div>
<p>
<button id="btnAuto">① 自动排队(不写 encode)</button>
<button id="btnEncode">② encode 钉住(杯数 / 订单数)</button>
<button id="btnFilter">③ transform(filter + sort 链)</button>
<button id="btnRow">④ 行布局(表转置)</button>
<button id="btnName">⑤ displayName 改名</button>
</p>
<p id="modeInfo"></p>
<h2>二、缺失日:31 天里有两天是 null</h2>
<div id="dirty" style="width:680px; height:300px;"></div>
<p class="tip" id="dirtyInfo"></p>
<h2>三、断点:折线在 null 处怎么断</h2>
<div id="gap" style="width:680px; height:300px;"></div>
<p><button id="btnGap">切换 connectNulls</button></p>
<p class="tip" id="gapInfo"></p>
<h2>四、体检报告与聚合表</h2>
<pre id="report"></pre>
<script src="../../lib/echarts.min.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>
// 第 10 章:主线数据登场。一份 1096 条的逐日表,先聚合成月表,再用五种喂法画同一张图。
async function loadDaily() {
const resp = await fetch('../../data/sales-daily.json');
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
return resp.json();
}
// 聚合:先求和再四舍五入到分。本章页面上打印的月表、正文里引用的每个数字都出自这两行。
const sum = (rows, key) => rows.reduce((a, r) => a + (r[key] || 0), 0);
const round2 = (v) => Math.round(v * 100) / 100;
function toMonthly(rows, year) {
const out = [];
for (let m = 1; m <= 12; m++) {
const tag = year + '-' + String(m).padStart(2, '0');
const sub = rows.filter((r) => r.date.startsWith(tag));
out.push({ month: tag.slice(5) + ' 月', sales: round2(sum(sub, 'sales')),
cups: sum(sub, 'cups'), orders: sum(sub, 'orders') });
}
return out;
}
function grouped(v, digits) {
const parts = Math.abs(v).toFixed(digits || 0).split('.');
parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
return (v < 0 ? '-' : '') + parts.join('.');
}
const chart = echarts.init(document.getElementById('chart'), null, { renderer: 'svg' });
const dirtyChart = echarts.init(document.getElementById('dirty'), null, { renderer: 'svg' });
const gapChart = echarts.init(document.getElementById('gap'), null, { renderer: 'svg' });
const modeInfo = document.getElementById('modeInfo');
// 数一数图上真画出了几个图形。SVG 渲染器才有 DOM 可数,canvas 渲染下返回 null。
function countBars(el) {
const svg = el.querySelector('svg');
return svg ? svg.querySelectorAll('path[fill="#5070dd"]').length : null;
}
// 折线断开这件事只有 M 的个数能证明:一条 path 里出现两次 M 就是断开了
function readLine(el) {
const svg = el.querySelector('svg');
if (!svg) return null;
const paths = Array.from(svg.querySelectorAll('path')).filter(
(p) => p.getAttribute('stroke') === '#5070dd' && p.getAttribute('stroke-width') === '2'
);
return {
paths: paths.length,
moves: paths.map((p) => ((p.getAttribute('d') || '').match(/M/g) || []).length),
symbols: Array.from(svg.querySelectorAll('path')).filter(
(p) => (p.getAttribute('d') || '').indexOf('M1 0A1') === 0
).length
};
}
let monthly = [];
let transposed = [];
let marchDays = [];
let marchSales = [];
let marchSource = [];
let connectNulls = false;
function showAuto() {
chart.setOption({
title: { text: '模式① 自动排队:两个系列都不写 encode', subtext: '图例是列名,刻度被最大那一列撑满' },
legend: {},
tooltip: {},
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: { source: monthly },
series: [{ type: 'bar' }, { type: 'line' }]
}, { replaceMerge: ['dataset', 'series'] });
modeInfo.textContent = '看图例:sales / cups——这就是列名当系列名的样子。看纵轴:0~5,000,000,'
+ '杯数(十万级)被销售额(百万级)压成贴地的直线。不写 encode,ECharts 就替你排队选了前两个数值列。';
}
function showEncode() {
chart.setOption({
title: { text: '模式② encode 钉住:柱=杯数、线=订单数', subtext: '同一份 dataset,只多了 encode 和 name' },
legend: {},
tooltip: {},
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: { source: monthly },
series: [
{ name: '杯数', type: 'bar', encode: { x: 'month', y: 'cups' } },
{ name: '订单数', type: 'line', encode: { x: 'month', y: 'orders' } }
]
}, { replaceMerge: ['dataset', 'series'] });
modeInfo.textContent = '同一份表,图例变成杯数 / 订单数,纵轴回到 0~180,000。'
+ 'encode 不改变数据,只改变「哪一列接到哪个通道」——这就是钉住的价值。';
}
function showFilter() {
chart.setOption({
title: { text: '模式③ transform:先筛出月销售额 > 400 万,再降序', subtext: '第 1 份 dataset 吃第 0 份,系列用 datasetId 认领' },
tooltip: {},
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: [
{ id: 'raw', source: monthly },
{ id: 'big', fromDatasetId: 'raw', transform: [
{ type: 'filter', config: { dimension: 'sales', '>': 4000000 } },
{ type: 'sort', config: { dimension: 'sales', order: 'desc' } }
] }
],
series: [{ type: 'bar', datasetId: 'big', encode: { x: 'month', y: 'sales' } }]
}, { replaceMerge: ['dataset', 'series'] });
const n = monthly.filter((r) => r.sales > 4000000).length;
const hit = countBars(document.getElementById('chart'));
modeInfo.textContent = '月销售额超过 400 万的有 ' + n + ' 个月,图上画了 ' + (hit === null ? '(请用浏览器打开)' : hit) +
' 根柱子,顺序是降序。注意 series 上的 datasetId:不认领的话系列仍然吃第 0 份、过滤等于白做。';
}
function showRowLayout() {
chart.setOption({
title: { text: '模式④ 行布局:一行一个指标', subtext: 'seriesLayoutBy: row,系列名自动取每行第一个格子' },
legend: {},
tooltip: {},
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: { source: transposed, seriesLayoutBy: 'row' },
series: [{ type: 'bar' }, { type: 'line' }]
}, { replaceMerge: ['dataset', 'series'] });
modeInfo.textContent = '这张表被转置成「月份当第一行」,seriesLayoutBy: row 让 ECharts 横着读。'
+ '图例是 销售额 / 杯数——它们来自每行的第一个格子,不是你以为的 displayName。';
}
function showDisplayName() {
chart.setOption({
title: { text: '模式⑤ displayName:列名保持英文,显示用中文', subtext: 'dimensions: [{name, displayName}]' },
legend: {},
tooltip: {},
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: {
source: monthly,
dimensions: [
{ name: 'month', displayName: '月份' },
{ name: 'sales', displayName: '销售额' },
{ name: 'cups', displayName: '杯数' },
{ name: 'orders', displayName: '订单数' }
]
},
series: [{ type: 'bar' }, { type: 'line' }]
}, { replaceMerge: ['dataset', 'series'] });
modeInfo.textContent = '和模式①同一份数据、同样不写 encode,图例却是中文——因为 displayName 只改显示、不改取值用的键名。'
+ '把 dimensions 直接写成中文名会得到一张空图(本章坑 2)。';
}
function drawMarch() {
dirtyChart.setOption({
title: { text: '2025 年 3 月逐日销售额(dataset + encode)', subtext: '第 14、15 日是 null' },
tooltip: { trigger: 'axis' },
grid: { left: 74, right: 20, top: 60, bottom: 60 },
xAxis: { type: 'category' },
yAxis: { type: 'value' },
dataset: { source: marchSource },
series: [{ type: 'bar', encode: { x: 'day', y: 'sales' } }]
});
gapChart.setOption({
title: { text: '同一份数据画折线', subtext: 'connectNulls: false' },
tooltip: { trigger: 'axis' },
grid: { left: 74, right: 20, top: 60, bottom: 60 },
xAxis: { type: 'category', data: marchDays },
yAxis: { type: 'value' },
series: [{ type: 'line', data: marchSales }]
});
const hit = countBars(document.getElementById('dirty'));
document.getElementById('dirtyInfo').textContent =
'31 天里画出的柱子:' + (hit === null ? '(请用浏览器打开这一页才数得到)' : hit) +
' 根。null 的那两天类目格子还在,只是没有柱——所以轴上仍然占位。';
readGapInfo();
}
function readGapInfo() {
const r = readLine(document.getElementById('gap'));
if (!r) {
document.getElementById('gapInfo').textContent = '(这一段要在浏览器里看:canvas 渲染或没有 DOM 时数不出来。)';
return;
}
document.getElementById('gapInfo').textContent =
'connectNulls=' + connectNulls + ' → 线条 path 数 ' + r.paths + ',每条里的 M 个数 ' + JSON.stringify(r.moves) +
',符号(小圆点)数 ' + r.symbols + '。M 出现两次就是断成了两段;把字节数拿去对比是看不出区别的。';
}
document.getElementById('btnGap').addEventListener('click', () => {
connectNulls = !connectNulls;
gapChart.setOption({
title: { text: '同一份数据画折线', subtext: 'connectNulls: ' + connectNulls },
series: [{ type: 'line', data: marchSales, connectNulls: connectNulls }]
});
readGapInfo();
});
document.getElementById('btnAuto').addEventListener('click', showAuto);
document.getElementById('btnEncode').addEventListener('click', showEncode);
document.getElementById('btnFilter').addEventListener('click', showFilter);
document.getElementById('btnRow').addEventListener('click', showRowLayout);
document.getElementById('btnName').addEventListener('click', showDisplayName);
function report(rows) {
const missing = rows.filter((r) => r.missing).map((r) => r.date);
const promoDays = rows.filter((r) => r.promotion).length;
const promoNames = new Set(rows.map((r) => r.promotion).filter(Boolean));
const keys = Object.keys(rows[0]);
const nulls = keys.map((k) => k + ' ' + rows.filter((r) => r[k] === null || r[k] === undefined).length);
const keySets = new Set(rows.map((r) => Object.keys(r).join(',')));
const lines = [];
lines.push('条数 ' + rows.length + ',起 ' + rows[0].date + ',止 ' + rows[rows.length – 1].date +
',覆盖月份 ' + new Set(rows.map((r) => r.date.slice(0, 7))).size + ' 个');
lines.push('显式缺失日 ' + missing.length + ' 个:' + missing.join('、'));
lines.push('促销日 ' + promoDays + ' 个,促销名 ' + promoNames.size + ' 种');
lines.push('各列空值(null/undefined)数:' + nulls.join(','));
lines.push('键集合种类 ' + keySets.size + ' 种(缺失日多了 missing 键,所以不统一)');
lines.push('');
lines.push('2025 逐月聚合(本页正文引用的月表)');
lines.push('月份 销售额 杯数 订单数');
for (const r of monthly) {
lines.push(r.month + ' ' + grouped(r.sales, 2).padStart(14) + ' ' +
grouped(r.cups).padStart(8) + ' ' + grouped(r.orders).padStart(8));
}
lines.push('');
lines.push('月销售额 > 4,000,000 的月份:' + monthly.filter((r) => r.sales > 4000000).map((r) => r.month).join('、'));
document.getElementById('report').textContent = lines.join('\\n');
}
try {
const rows = await loadDaily();
monthly = toMonthly(rows, '2025');
transposed = [
['月份', …monthly.map((r) => r.month)],
['销售额', …monthly.map((r) => r.sales)],
['杯数', …monthly.map((r) => r.cups)],
['订单数', …monthly.map((r) => r.orders)]
];
const march = rows.filter((r) => r.date.startsWith('2025-03'));
marchDays = march.map((r) => r.date.slice(8) + ' 日');
marchSales = march.map((r) => r.sales);
marchSource = marchDays.map((d, i) => ({ day: d, sales: marchSales[i] }));
const cities = rows.length;
document.getElementById('status').textContent =
'读到了 ' + cities + ' 条逐日记录,聚合成 2025 年的 12 行月表,本章下面五张图都来自它。';
showAuto();
drawMarch();
report(rows);
} catch (err) {
document.getElementById('status').textContent = '加载失败:' + err.message +
'。请确认已经运行 node tools/serve.mjs,并用 http://127.0.0.1:18000/code/ch10/index.html 打开本页。';
}
window.addEventListener('resize', () => {
chart.resize();
dirtyChart.resize();
gapChart.resize();
});
跑起来应当看到(这台机器上刷出来的数字,你跑一遍应当一致):
- 状态条:读到了 1096 条逐日记录,聚合成 2025 年的 12 行月表,本章下面五张图都来自它。
- 模式①:图例 sales / cups,纵轴最高 5,000,000,折线贴地。
- 2025-03 那张柱状图:29 根柱子(31 天里画出的柱子:29 根),第 14、15 日的格子空着。
- #gapInfo 初始:connectNulls=false → 线条 path 数 1,每条里的 M 个数 [2],符号(小圆点)数 29;点一次按钮变成 [1],符号数仍是 29。
- 报告区的月表 12 行,第一行 01 月 3,126,085.40 110,552 69,039,最后一行 12 月 4,068,117.30 141,106 88,282。
【逐步加料】
第一步:先读体检报告,再画图
node tools/serve.mjs 之后打开 code/ch10/index.html,先把目光放在最下面那段报告上——它是本章后面所有数字的出处:
条数 1096,起 2023-01-01,止 2025-12-31,覆盖月份 36 个
显式缺失日 4 个:2025-03-14、2025-03-15、2025-07-22、2025-11-03
促销日 48 个,促销名 11 种
各列空值(null/undefined)数:date 0,sales 4,cups 4,orders 4,avgTicket 4,newMembers 4,openStores 0,promotion 1048
键集合种类 2 种(缺失日多了 missing 键,所以不统一)
最后一行值得停一下:这份 JSON 里所有对象的键并不完全一致——正常记录 8 个键,四个缺失日多一个 missing: true。这对 dataset 无害(列名取自第一条记录,后面多出来的键被忽略),但如果你的代码写成 rows.map((r) => r.missing),就会得到一堆 undefined。data/README.md 里对这件事有登记。
openStores 那一列的空值是 0——因为四个缺失日那天它的值是数字 0(没营业),不是 null。这两个「零」在图上长得一模一样,但含义完全不同(第 13 章讲怎么分开处理)。
第二步:按模式①,看清排队
打开页面时就是模式①。看图例(sales / cups),看纵轴上限(5,000,000)。现在做一件有破坏性的事:把 series 里那两个系列改成三个({type:'bar'}、{type:'line'}、{type:'bar'}),刷新。实测第三个系列拿到 orders,图例变成 sales / cups / orders 三项——因为月表里正好还有第三个数值列。再加第四个({type:'line'}),它重新拿 sales。
这就是原理先行第三节那张队列表。加完记得删掉——本章后面的对比都建立在「两个系列」上。
第三步:按模式②,把列钉住
对比模式①和②的两处变化:图例从列名变成 杯数 / 订单数(这是 name 给的,不是 encode 给的);纵轴从 5,000,000 变成 180,000(这是 encode 给的)。两件事各自独立:只写 name 不写 encode,刻度还是被销售额撑满;只写 encode 不写 name,图例显示的是你钉住的那个列名。
打开 Console 抄一句进去,能直接看到模型层的读数(getModel() 是内部 API,生产版包里也在):
const sm = chart.getModel().getSeries()[0];
sm.getSource().sourceFormat; // 'objectRows'
sm.getData().mapDimension('x'); // 'month'
sm.getData().mapDimension('y'); // 'sales'(模式①)/ 'cups'(模式②)
sm.getData().mapDimensionsAll('defaultedLabel'); // ['sales'] —— 默认标签也听 y 的
第四步:按模式③,看 transform 与认领
模式③画 6 根柱子,顺序 08 月、10 月、07 月、12 月、06 月、09 月(销售额降序)。报告区最后一行列出的月份就是这 6 个,两者必须一致。
现在把 series 里的 datasetId: 'big' 删掉,刷新:柱子立刻变成 12 根、月份恢复原序——系列退回吃第 0 份,transform 全白做。这就是原理先行第七节第一条。想按顺序验证链式引用,再把 fromDatasetId: 'raw' 也删掉,第 1 份 dataset 的输入会缺,实测抛 Error:(生产版消息为空)。
第五步:按模式④,摸一下行布局
模式④的图和模式②几乎一样,但数据形状换了:transposed 是一个「月份在第一行、每行一个指标」的二维数组,靠 dataset.seriesLayoutBy: 'row' 横着读。图例 销售额 / 杯数 来自每行的第一个格子。
做个实验:把 series 改成 [](一个系列都不写)。实测图上没有任何数据图形、系列数 0——行布局不会把「有几行」自动变成「有几个系列」,系列仍要你自己写出来。
第六步:按模式⑤,比较两种改名
模式⑤和模式①是同一份数据、同样的两个不写 encode 的系列,唯一区别是多了 dimensions: [{name, displayName}]。图例从英文变中文,排队规则一点没变(柱仍是 sales、线仍是 cups)。
想验证「直接改列名会空图」,把这段替进去刷新:
dataset: { source: monthly, dimensions: ['月份', '销售额', '杯数', '订单数'] },
series: [{ type: 'bar' }]
实测 446 字节、一根柱子都没有、Console 里一条警告都没有。把 encode 改成下标 {x: 0, y: 1} 也一样是空图——因为取不到值不是编码找不到列,是列本身已经和源里的键脱钩了。
第七步:把缺失日单独拎出来看
#dirty 用的是 dataset + encode,#gap 用的是 series.data——同一个 marchSales 数组、同样两个 null,两条路都画出 29 根柱子。null 的行为不随喂法改变。
数一下轴上的格子:01 日 到 31 日 全在,14 日 15 日 两格只是没有柱。「数据丢了」在图上表现为空格子,不是少两个格子——这就是主线数据为什么保留 null 行而不直接删掉它们。
第八步:数 M,把 connectNulls 钉死
点 切换 connectNulls 按钮,#gapInfo 会打印:
connectNulls=false → 线条 path 数 1,每条里的 M 个数 [2],符号(小圆点)数 29
connectNulls=true → 线条 path 数 1,每条里的 M 个数 [1],符号(小圆点)数 29
M 的个数是判据(实测两种情况的 SVG 字节数都是 13,227、d 字符串长度都是 331,看不出区别)。符号数一直是 29——connectNulls 只连线,不会补点:跨过去的那一段两头有线连着,但中间那两个空位仍然没有圆点。
【参数速查】
表一:dataset 的键
| dataset.source | 数据本体,四种形状都吃 | 写成数组(dataset: rows)当场抛 TypeError: Bind must be called on a function |
| dataset.id | 给这份表起名 | 配合 series.datasetId / dataset.fromDatasetId |
| dataset.sourceHeader | 二维数组首行是不是表头 | 'auto'(≡true≡1)/false(≡0)/2 表示前两行都是表头 |
| dataset.dimensions | 定义列名 | 对象数组上改名会让取值全部落空;安全写法是 {name, displayName} |
| dataset.seriesLayoutBy | 'column'(默认)/'row' | 对对象数组无作用 |
| dataset.transform | 整形器,对象或数组 | 数组按书写顺序执行;写在 series 上会被忽略 |
| dataset.fromDatasetIndex | 这一份的输入是第几份 | 缺省是 0,不是上一层 |
| dataset.fromDatasetId | 同上,按名字 | 和 fromDatasetIndex 二选一 |
| dataset.name / dataset.type | 组件通用字段 | 一般不写 |
表二:系列侧吃数据的键
| series.data | 直接给这个系列的数据 | 一旦写了它,dataset 对这个系列就不起作用(实测读到 3 行而非表里的 8 行) |
| series.encode | 通道 → 维度 | 列名不存在时静默回退(坑 6) |
| series.datasetIndex | 认领第几份 dataset | 缺省 0 |
| series.datasetId | 按名字认领 | 实测生效 |
| series.seriesLayoutBy | 只有这个系列按行读 | 覆盖 dataset 上的同名设置 |
| series.sourceHeader | 系列级表头设置 | 同上,粒度和 seriesLayoutBy 一样 |
| series.dimensions | 系列级列名 | 极少用,第 33 章 |
(series.source 不是合法字段:实测写了等于没写,系列读到 0 行、448 字节空图。)
表三:通道与 encode 取值
| series.encode.x | 直角坐标系的横轴 | 列名或下标都可 |
| series.encode.y | 纵轴 | 写数组时几何取第一列 |
| series.encode.label | 柱顶/点上标签 | 多列用空格连接(2358 1324) |
| series.encode.tooltip | 提示框 | 与 label 各管各的,互不影响 |
| series.encode.value | 饼/环、热力等 | 饼图必须它或字段名恰好是 value |
| series.encode.itemName | 饼/环的扇区名 | 配合 value 使用 |
| series.encode.latitude / longitude | 地理散点 | 第 25 章 |
不写 encode 时的默认:defaultedLabel 与 defaultedTooltip 都等于 y 那一维(实测 ["sales"])。
表四:内置 transform
| 'filter' | {dimension: 'sales', '>': 4000000} | 运算符 > >= < <= = != <> value reg |
| 'filter'(复合) | {and: […]} / {or: […]} / {not: {…}} | 可嵌套 |
| 'sort' | {dimension: 'sales', order: 'desc'} | 也可为数组按多列排;incomparable 取 'min'/'max' |
两个必须写的边界:type 前面不要加 echarts:(实测加了抛错,生产版消息是空的 Error:);filter 少写 dimension、sort 少写 order 都抛错,开发版分别给 Relation condition must has prop "dimension" specified. 和 Sort transform config must has "order" specified.。
表五:四种喂法的选型口诀
| 就三四行、写在代码里 | series.data 纯数字数组 | 最短,一眼看完 |
| 每个点要单独配色/样式 | series.data 对象数组 | 只有它能写 {value, itemStyle} |
| 多个系列共用一张表 | dataset.source 对象数组 + encode | 一份源、N 个系列,只维护一张表 |
| 后端直接返回 CSV 转的二维数组 | dataset.source 二维数组 + sourceHeader | 不用自己 map |
| 需要过滤/排序后再喂 | dataset + transform | 整形跟着 option 走,切换筛选不用重算数据 |
表六:data/ 的分层地图(8 个文件,本章起全书共用)
| 主数据 | stores.json | 12 条 / 2.7 KB | storeId name city province tier lat lng area seats openDate | 地图(第 25 章)、条形排行 |
| 主数据 | categories.json | 8 条 / 2.1 KB | categoryId name group price season | 饼/环(第 16 章)、桑基、树图 |
| 明细层 | sales-daily-store.json | 4,234 条 / 944.1 KB | date storeId city sales cups orders avgTicket newMembers weather promotion | 日历热力(第 30 章)、箱线图、第 33 章的数据整形 |
| 汇总层 | sales-daily.json | 1,096 条 / 199.0 KB | 上面 8 列,见本章体检 | 折线趋势(第 13 章)、dataZoom(第 20 章) |
| 汇总层 | sales-store-month.json | 332 条 / 59.4 KB | month storeId sales cups orders avgTicket newMembers openDays | 堆叠面积、热力图、visualMap |
| 汇总层 | sales-category-month.json | 288 条 / 28.2 KB | month categoryId sales units | 桑基、雷达的时间维度 |
| 指标层 | store-metrics.json | 12 条 / 4.4 KB | 16 个门店年度指标列 | 雷达(第 17 章)、平行坐标(第 31 章) |
| 独立 | demo-stock.json | 261 条 / 53.6 KB | date open close lowest highest volume ohlc | K 线(第 30 章) |
分层思路是真实数仓的思路:明细层能聚合出汇总层,汇总层能算出指标层,任何一层对得上账。本书后面每章取数都从这张表里选,不再临时造数据。
【常见坑】
坑 1:不写 encode,ECharts 替你选错了列
现象:图能出来,但折线贴地、图例显示英文列名、纵轴刻度比预想大十倍。
根因:实测排队规则——第一列当类目,其余数值列按列序发给各系列,列用完了从头重复。你想看的杯数被排在第二个,而纵轴是按最大值(销售额)定的。
修法:所有进入正式项目的 dataset 都显式写 encode,把「哪列接哪个通道」这件事从库里夺回来。顺带写 name,图例才会显示你要的字。
坑 2:在对象数组上把 dimensions 改成中文列名,画出一张空图
现象:不报错、不警告,图上只有坐标轴(实测 446 字节、0 个 <text>、0 根柱)。把 encode 改成按下标写也一样空。
根因:dimensions 数组是重新定义列名。对象数组取值靠 row[列名],列名一改,源里的 sales/cups 再也取不到,读到的每一列都是空的——这和编码方式无关,所以按下标也救不回来。
修法:改名一律用 dimensions: [{ name: 'sales', displayName: '销售额' }](实测正常,图例显示中文而取值仍用 sales)。要写 dimensions 数组,就得把源里的键名原样抄一遍,或者改用二维数组喂法。
坑 3:只给一列数据,图画不出来
现象:dataset 里只有一列({source: {v: [1,2,3]}} 或 [{sales: 1}, {sales: 2}]),柱状图 0 根柱(958 字节)、折线 0 条线(1,503 字节),轴和文字都正常。
根因:ECharts 发现缺维度,会给它补一个名叫 y 的空维度(实测模型读数 dims: ["v","y"]、y 列全为 null)。补上来的这列没有任何值。
修法:类目 + 数值,至少两列。真只有一列数据,就自己补上类目列,或者改走 series.data: [1,2,3](那会让下标当类目)。
坑 4:饼图不吃 [['甲', 12], ['乙', 8]]
现象:容器高度正常、Console 干净,但饼图是一片空白。
根因:实测这种形状给饼图得到 684 字节的 SVG,里面有三个 <path d="" fill="#000">——元素建了,几何是空的。模型层读数 dims: ["value","value0"]、defaultedLabel: []。同样的数组喂柱状图则会把 甲 当 x 值、12 当 y 值。
修法:饼图的数据用 {name, value} 对象数组,或者用 dataset + encode: {itemName, value}。上面两种实测都是 1,905 字节、两个扇形、标签正常。
坑 5:dataset 写成数组、transform 写到系列上
现象:dataset: rows(直接把数组给它)当场抛 TypeError: Bind must be called on a function;dataset: {} 抛同一个错。
根因:dataset 只能是「一个带 source 的对象」或「这样的对象组成的数组」。裸数组被当成多份 dataset,于是每一项都没有 source。
修法:写成 dataset: { source: rows }。另一例:transform 写在系列上是合法键名、无效写法(实测 12 行全在、一条都没过滤)——它只属于 dataset。
坑 6:encode 写了不存在的维度名,静默用默认列
现象:图照画,但显示的数字不是你指定的那列。什么错都不报。
根因:实测 encode.tooltip: ['不存在'] 之后 defaultedTooltip 回退成 ["sales"],输出和不写一模一样。
修法:用第三步那句 Console 诊断——sm.getData().mapDimension('y') 直接告诉你这个系列真正用了哪一列。这一行比盯着 option 数一遍可靠。
坑 7:多层 transform 链不写输入,过滤白做
现象:dataset 数组里第 1 份过滤、第 2 份排序,系列吃第 2 份,结果排序生效了但过滤没生效(实测 4 行全在而不是 2 行)。
根因:没写 fromDatasetIndex/fromDatasetId 时,每份 dataset 的输入默认都是第 0 份,不是它的上一层。
修法:要么每层都显式声明输入,要么干脆合并成一层 transform: [filter, sort](实测结果相同,且不会漏)。更狠的一种组合是过滤条件写错导致 0 行,然后再链一层 sort:实测抛 Error:(开发版 Can not find dimension info via: sales. Existing dimensions: [])——空表上没有维度可排。
坑 8:两份 dataset 都不认领,或者换了筛选条件旧系列还赖着
现象:两个系列画的是同一份数据;或者点按钮换了筛选条件之后,图上系列数没变少。
根因:两条独立规则。前者是「系列默认吃 datasetIndex 0」;后者是第 9 章讲过的默认合并不删数组成员。实测:模式①(2 个系列、1 份 dataset)用默认合并切到模式③(1 个系列、2 份 dataset),结果是 seriesCount 仍是 2、datasetCount 变成 2,图上还是 12 个月。
修法:换数据集时同时写认领关系(series.datasetId),并且用 chart.setOption(option, { replaceMerge: ['dataset', 'series'] })。本章页面五个模式按钮全都这么写。
坑 9:提示框模板里的 {c} 在 dataset 上打印 [object Object]
现象:tooltip.formatter: '{a} | {b} | {c}' 打印出 销售额 | 2023-01-01 | [object Object]。
根因:对象数组源里,一个数据点就是整行对象,{c}(第三个参数=值)打不出标量。换成二维数组源,同一句模板打印的是整行 2023-01-01,66005,2358。
修法:吃 dataset 时用 encode 指定通道,模板改用 p.value 回调(实测 p.value.sales/p.value.cups 可直接取,输出 66005.33/2358),或者用 @ 语法按维度下标取(实测 {@[1]} 打印 66005.33)。series.data 纯数字数组才适合 {a}{b}{c}(实测输出 销售额 | 甲 | 100)。
【工程文件】
ch10/
├── index.html ← 五按钮对比台 + 两张缺失值小图 + 体检报告区
└── app.js ← fetch → 聚合 → 五种喂法 → 图形计数,约 220 行
跑法(本章必须有服务器,判据见第 6 章):
node tools/extract.mjs –write # 把本章代码重建成 code/ch10/
node tools/serve.mjs # 打开 http://127.0.0.1:18000/code/ch10/index.html
双击 file:// 打开会停在状态条那句「加载失败」上:fetch 在 file:// 下被浏览器拦掉,这是第 6 章实验过的行为,不是本章代码坏了。
app.js 里四个值得单独指出来的写法:
- toMonthly() 是全书月表的唯一来源。正文里每个「3,126,085.40」都是它跑出来的;sum 里的 r[key] || 0 顺手把四个 null 当 0 加进去了——这对月表无害(缺失月的其它 29 天仍在),但第 13 章讲日均值时必须换成「跳过并计数」。
- countBars() / readLine() 数的是 DOM 里的真图形,所以只在 renderer: 'svg' 下有结果;canvas 渲染下这两个函数返回 null,页面上会打印「请用浏览器打开」。这是有意的:别用 getOption() 判断图画没画出来(第 9 章),要判断图形就数形状。
- 五个模式函数都以 setOption(…, { replaceMerge: ['dataset', 'series'] }) 收尾,因为模式间系列数和 dataset 份数都会变(坑 8)。
- option 字面量全部直接写在 setOption( 的括号里。先存进 const 再传,type: 'bar' 会被 TypeScript 拓宽成 string,第 39 章会报 TS2769。
【练习】
练习 1:把默认标签改成杯数
只允许动 series[0] 一个地方,让模式②的柱子顶上显示杯数而不是销售额。改完用 Console 验证 defaultedLabel。
答案:`series: [{ name: '杯数', type: 'bar', encode: { x: 'month', y: 'cups', label: 'cups' } }, …]`——其实只写 `encode: {x:'month', y:'cups'}` 就够了,因为实测 `defaultedLabel` 恒等于 y 那一维(模式②钉住 `cups` 之后 `defaultedLabel` 自己就变成 `["cups"]`)。要显示两列才需要显式写 `label: ['cups','orders']`,实测标签打印 `110552 69039`(空格分隔)。别忘了 `label: { show: true }`:标签默认不显示,`encode.label` 只决定显示哪一维(实测不写 `show` 时 11,175 字节、写 `show` 后 11,214 字节)。
练习 2:把月表换成日表,看看 openStores 那一列
把 toMonthly(rows, '2025') 换成直接喂 rows 的前 40 条(rows.slice(0, 40)),模式①与模式②各自跑一遍,然后把 encode.y 指向 openStores。
答案:模式①会先给 `sales`、再给 `cups`(第一列 `date` 当类目,与月表同规则);`encode.y: 'openStores'` 之后折线会变成一条几乎水平的带子——`openStores` 只有 5~12 这一点点范围,而纵轴仍按其它列的数据范围算刻度吗?不:实测**只画 `openStores` 时纵轴上限是 12**(`0, 300, 600, 900, 1200, 1500` 那类刻度是 `orders` 时的)。这题真正要练的是发现「刻度跟着实际编码进来的列走」,所以刻度突变说明编码改对了。四个缺失日会把线断成几段(`connectNulls` 第八步),这是同一个坑的另一副面孔。
练习 3:写一个「只筛促销日」的按钮
加第六个按钮:图上只画 2025 年有促销的那几天(data/sales-daily.json 里 promotion 非 null 的记录),类目轴显示日期,柱高显示 sales,并在 #modeInfo 里打印出记录条数。不许在 JS 里 filter。
答案:要点有三条。① 整形必须交给 `transform`:`dataset: [{ id: 'raw', source: daily2025 }, { id: 'hit', fromDatasetId: 'raw', transform: { type: 'filter', config: { dimension: 'promotion', value: (v) => !!v } } }]`——`value` 运算符收一个谓词函数,这是六种比较符里唯一能写逻辑的那个。② 系列要认领:`{ type: 'bar', datasetId: 'hit', encode: { x: 'date', y: 'sales' } }`,不写就是坑 8。③ 收尾用 `replaceMerge: ['dataset', 'series']`,否则上一个模式的系列赖着不走。条数用 JS 数一遍即可(2025 全年的促销日在报告区那 48 个里,自己按 `date.startsWith('2025')` 筛一遍)。进阶:把 `value` 换成 `reg`——`{dimension: 'promotion', reg: '.'}` 实测也能选出非空项,但 `null` 参与正则匹配的行为不如谓词清楚。
【小结】
- 数据只有两扇门:series.data(内部形状 original,没有列名可言)和 dataset.source(arrayRows / objectRows / keyedColumns)。七种写法里六种画出逐字符相同的图(图形指纹 1,681 字符),唯一例外是 series.data 的对象数组——它必须额外补 xAxis.data。
- 三个词分清就不乱:维度是列,系列是 series[i],通道是图表类型要的槽位。不写 encode 就是让库里替你排队:第一列当类目,其余数值列按列序发,用完就重复。
- encode 是本章性价比最高的一行配置:它不改数据,只改「哪列接哪条通道」,顺带把纵轴刻度和默认标签/提示框的内容一起定下来。label 和 tooltip 是两条互不相干的通道,写错列名不会报错——用 sm.getData().mapDimension('y') 验。
- 改名请用 displayName,不要用 dimensions 直接改列名(对象数组上会得到一张 446 字节的空图)。
- dataset 的价值在一份表喂多个系列与 transform 整形。记四条:系列默认只吃第 0 份;transform 只属于 dataset;多层链的输入必须用 fromDatasetIndex/fromDatasetId 显式声明(缺省是第 0 份,不是上一层);transform 数组按书写顺序执行。内置整形器只有 filter 和 sort,写 type 时不要加 echarts: 前缀。
- 脏值有一条统一规则和三个例外:规则是「转不出数字的一律当空,不画、不报错」(null/undefined/'-'/空串/带单位字符串全一样,且 series.data 与 dataset 行为一致)。例外是:字符串数字会被当真数字用;0/false/true 会被画出来(0、0、1);饼图对空值的容忍度更低。折线断没断只看 d 里 M 的个数,字节数一点也不会变。
- 主线数据从本章起正式上岗。data/ 的 8 个文件按主数据/明细层/汇总层/指标层分好,sales-daily.json 那 1,096 条是全书最常用的那份——它的四个缺失日、48 个促销日和 openStores = 0 的故障日,第 13 章和第 33 章还会回来。
数据能喂进去了,但每次 fetch 回来都要 init 一个新实例吗?一个页面放四张图要不要各管各的销毁?下一章处理实例本身:echarts.init 的参数、重复 init 的警告、resize 的正确姿势,以及单页应用里最容易出事的那条——忘记 dispose()。
网硕互联帮助中心

评论前必须登录!
注册