☰
ECharts桑基图流量拆解:数据准备、配置与性能调优
2026/9/29 1:07:36 网站建设 项目流程

数据可视化做到一定阶段,早晚会碰到一类问题:普通的柱状图、折线图、饼图只能回答"谁多谁少",回答不了"这些东西是怎么从这一头流到那一头的"。比如一笔订单从下单到出库经过了几个环节、每个环节流失了多少;比如一个园区一个月的电力从几种来源分别流向了哪几个车间;再比如用户从首页出发,沿着哪些路径最终走到了支付页。这类"带着量在流转"的问题,用 ECharts 里的桑基图(Sankey)来表达,信息密度会瞬间拉开一个档次。

但桑基图也是 ECharts 里比较挑人的一类图表:它不像柱状图那样"数据给对就出图",它的矛盾点几乎全部集中在数据准备阶段——节点和边要能对得上、权重不能出现负数、流向不能成环、总量口径要自洽。很多人第一次做桑基图,卡住的地方不是配置写不出来,而是数据喂进去之后图要么不显示,要么显示得乱七八糟,还找不到原因。下面这篇就把桑基图从"数据怎么攒"到"图怎么调"整条链路捋一遍,代码都是可以直接复制进项目跑的,适合已经用过 ECharts 基础图表、想往高级图表方向走的同学。

1. 桑基图在流量拆解里的真实位置

1.1 它和漏斗图、关系图不是一回事

很多团队一开始想画"流转",第一反应是漏斗图。漏斗图确实能表达递减,但它有个硬约束:每一层必须是同一个维度的整体,而且只有"一个入口、逐级减少"。一旦出现分流再合流,比如 A 渠道的用户和 B 渠道的用户最终都进了同一个支付页,漏斗图就表达不了了。

关系图(graph)也不行。关系图强调的是"谁和谁有关系",边的粗细只是权重,它不保证每个节点的流入流出量是平衡的,看起来就是一张网。而桑基图背后的语义是流量守恒:节点的矩形高度等于它所有流入之和,也等于所有流出之和(起点和终点除外),边的宽度按 value 等比缩放。这个"守恒"才是桑基图区别于其他图的根本。

一句话概括适用边界:只要你的数据能被描述成"若干批总量,在不同环节之间被拆分和合并",桑基图就是对的选择;如果只是看占比或者排名,别硬上。

1.2 节点、边、权重在业务里各自对应什么

落到具体建模,桑基图只需要三个概念,但要映射准确:

图元素配置字段业务含义举例
节点data[].name渠道、仓库、工序、状态、地区
边links[].source/links[].target一次流转关系
权重links[].value订单数、人次、金额、电量

关键点在于同一层的节点必须能对同一批总量做互斥且完整的划分。举个反例:如果第一层放"华东、华南",第二层放"高价值、低价值",这两层就是交叉维度,强行画出来会出现同一个量在两个维度上重复计算,图看着挺漂亮,但每个节点的宽度都是错的,这种错误还特别难在图上肉眼发现。

我一般建议在设计阶段先画一张草稿:横向写清有几个阶段(stage),每个阶段下面列出节点,然后用箭头连一遍,确认每一列的量加起来能对得上。草稿对得上,代码基本不会出问题。

1.3 什么时候别硬上桑基图

桑基图信息密度高,但它的可读性衰减也快,有几条经验线可以参考:

  • 超过 4 个层级、单层节点超过 15 个,视觉上就开始糊成一团,交叉的连线会互相遮挡。
  • 数据本身不守恒、缺失比例超过 10%,画出来的图会让人误判。
  • 用户的需求是"精确读数",桑基图不合适,因为它只在 tooltip 里给数,视觉上只能比宽窄。
  • 移动端窄屏慎用,横向空间不够时节点标签会互相压字,退化成一堆色块。

真遇到这些情况,替代方案很简单:只需要看结构就用堆叠柱状图,只需要看流失就用漏斗图,需要看时序演化就用带时间轴的折线图。桑基图解决的是"流向和分流比例"这一件事,别让它承担太多。

2. 数据准备:nodes 与 links 的构造过程

2.1 最小可用数据结构

ECharts 的桑基图数据分两块,必须分开给:

const option = { series: [{ type: 'sankey', data: [ { name: '自然流量' }, { name: '渠道投放' }, { name: '首页' }, { name: '详情页' }, { name: '支付' } ], links: [ { source: '自然流量', target: '首页', value: 1200 }, { source: '渠道投放', target: '首页', value: 800 }, { source: '首页', target: '详情页', value: 1500 }, { source: '首页', target: '支付', value: 500 }, { source: '详情页', target: '支付', value: 900 } ] }] };

data里的name是节点的唯一标识,links里的source和target就是靠这个字符串去匹配节点。这里有一个容易被忽略的行为:如果某个name在links里出现过但没写进data,ECharts 通常不会直接报错,而是自动把它当成一个新节点补进去。问题是这个自动补出来的节点没有任何你自定义的属性,颜色、特殊标签全都会走默认值,而且它在层级里的位置也不受你控制。所以我的习惯是永远显式声明所有节点,哪怕是从 links 反推出来的。

2.2 从明细表聚合出 links

真实项目里数据几乎不会以"边"的形式存在,而是流水明细。所以中间一定有一层聚合。SQL 里就是一次 group by:

select src_stage as source, dst_stage as target, sum(cnt) as value from user_flow_detail where dt between '20240101' and '20240131' group by src_stage, dst_stage

前端的聚合用 Map 更快,也比对象字面量安全,因为业务里的环节名可能包含各种符号:

const map = new Map(); for (const row of rows) { const key = row.src + '\u0000' + row.dst; map.set(key, (map.get(key) || 0) + row.cnt); } const links = Array.from(map, ([key, value]) => { const idx = key.indexOf('\u0000'); return { source: key.slice(0, idx), target: key.slice(idx + 1), value }; });

这里用\u0000而不是常见的下划线或短横线做分隔符,是因为业务名称里出现-和_的概率实在太高,用它们拼接再 split 一定会踩到解析错位的坑。这类小细节属于踩过一次就再也不会忘的类型。

节点则从边反推,同时固定顺序:

const order = ['自然流量', '渠道投放', '首页', '详情页', '支付']; const nameSet = new Set(); links.forEach(l => { nameSet.add(l.source); nameSet.add(l.target); }); const data = order .filter(name => nameSet.has(name)) .map(name => ({ name }));

顺序是有意义的。ECharts 会把data里的节点顺序作为同一层内的初始排列依据,如果不给固定顺序,同一层节点的上下位置可能随数据变动而跳来跳去,大屏上看起来很不安定。

2.3 三类会让图直接画不出来的脏数据

第一类是环。桑基图要求底层是一张有向无环图,如果数据里出现 A 流向 B、B 又流回 A 的情况,ECharts 会在控制台打印类似Sankey is a DAG, the original data has cycle!的提示,然后图要么不渲染,要么层级错乱。业务上出现环通常意味着"状态定义有问题",比如把"退款"和"支付"放在同一层互相指向了。处理方式不是补数据,而是回到建模层重新划分阶段。

第二类是自环,即source和target是同一个值。这在用户路径埋点里很常见(用户在当前页刷新),聚合阶段最好直接过滤掉:

const links = rawLinks.filter(l => l.source !== l.target && l.value > 0);

第三类是非法 value。value必须是正数,出现null、undefined、NaN或者 0,对应那条边可能直接消失,节点宽度也会算错。我通常会在聚合末尾加一道保险:

const clean = links.filter(l => Number.isFinite(l.value) && l.value > 0 );

这三类问题在图上都不会给你明确的错误提示,只会表现为"图不对",所以建议在渲染前就把数据打印出来看一眼,比在浏览器里对着图猜要快得多。

3. 跑通第一张桑基图:完整配置拆解

3.1 容器准备与按需引入

容器必须显式给高度,这是 ECharts 全家的通病,桑基图也不例外:

<div id="sankey-box" style="width: 100%; height: 640px;"></div>
const container = document.getElementById('sankey-box'); const chart = echarts.init(container); chart.setOption(option);

如果项目用了按需引入,最容易漏的就是 SankeyChart 这个模块:

import * as echarts from 'echarts/core'; import { SankeyChart } from 'echarts/charts'; import { TooltipComponent, TitleComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([SankeyChart, TooltipComponent, TitleComponent, CanvasRenderer]);

漏掉的典型症状是:控制台没有红色报错,但页面一片空白,只有一个非常不起眼的Component series.sankey not exists之类的提示。遇到"图不显示但没报错",先回头检查这一行。

3.2 series 里的每个参数都在管什么

series: [{ type: 'sankey', left: '5%', right: '14%', top: 40, bottom: 24, nodeWidth: 18, nodeGap: 10, nodeAlign: 'justify', layoutIterations: 32, draggable: true, orient: 'horizontal', lineStyle: { color: 'gradient', curveness: 0.5, opacity: 0.35 }, label: { position: 'right', fontSize: 12, color: '#333' }, emphasis: { focus: 'adjacency', lineStyle: { opacity: 0.6 } }, data: nodes, links }]

逐项说清楚,这些参数改一个视觉效果就差很远:

  • nodeWidth是节点矩形的宽度,nodeGap是同一层相邻节点之间的垂直间距。两者共同决定了布局的横向和纵向松紧。节点特别多的时候,如果nodeGap还留在默认值,高度会被撑爆,可以适当减到 6 甚至 4。
  • nodeAlign控制层级对齐方式,left是每层都从左侧起排,justify会让最后一层强制贴到右边界,整体看上去更整齐。做"起点到终点"的流向分析,我一般用justify。
  • layoutIterations是布局迭代次数,官方默认 32。数值越大,节点位置越优化、交叉越少,但计算成本也越高。几百个节点的小图保持默认就行,上千节点建议降到 16 甚至更低,否则首屏会明显卡顿。
  • curveness是连线弯曲程度,取值 0 到 1。0 就是直来直去的折线,观感生硬;0.5 是比较舒服的默认弧度;调到 0.8 以上会变成夸张的大弧线,视觉上有张力但容易互相遮挡。
  • lineStyle.color有三个常用取值:source表示边的颜色跟随起点节点,target跟随终点节点,gradient则做起点到终点的渐变。渐变最好看,但渲染开销略高,节点和边特别多时可以换成source,性能会好一些。

3.3 用 levels 按层级统一配色

如果每一层的语义不同(比如"上游来源 / 中间加工 / 最终去向"),用levels按深度配颜色会比一个个节点去写itemStyle清爽得多:

levels: [ { depth: 0, itemStyle: { color: '#5B8FF9' }, lineStyle: { color: 'source', opacity: 0.35 } }, { depth: 1, itemStyle: { color: '#5AD8A6' }, lineStyle: { color: 'source', opacity: 0.35 } }, { depth: 2, itemStyle: { color: '#F6BD16' }, lineStyle: { color: 'source', opacity: 0.35 } } ]

depth从 0 开始计数,对应最左侧那一层。没有在levels里配置到的深度,会回退到series级别的样式,所以至少要把 series 底层的itemStyle.color也给一个合理的值,避免出现突兀的默认蓝。

还有一个顺序问题值得提醒:levels的优先级高于节点自身的itemStyle,也高于links的lineStyle。如果你给某个节点单独设了颜色却不生效,先看看是不是被levels覆盖了。

4. 视觉调优:让流向一眼就能读懂

4.1 配色策略的三种思路

桑基图的颜色不是随便挑几个好看的颜色就行,它承担的是"帮读者归类"的功能。常见三种策略,各有适用场景:

第一种是单色系,所有节点和边用同一个色系的不同深浅。适合读者只关心结构、不关心分类的场景,比如展示一条产线的物料流向。缺点是节点之间的区分度低。

第二种是按起点染色,也就是每条边都跟随它的源头节点颜色。这是最符合直觉的一种,读者能顺着颜色追踪"这批量从哪来",做渠道归因的时候基本都用这种。

第三种是按终点染色,边跟随目标节点。适合反向分析,比如排查"哪些来源最终都汇进了这个异常出口"。

无论用哪种,颜色数量都要控制。层级配色控制在 5 层以内,分类配色控制在 8 种以内。超出这个范围,人眼就分不清了,这时候应该做的是合并小类,而不是继续加颜色。深色大屏上,建议把边的透明度压到 0.3 左右、节点保持高饱和,形成"亮节点 + 淡连接"的层次。

4.2 标签截断与 tooltip 的两套模板

节点名称一长,标签就会横向溢出到画布外面或者压住旁边的节点。ECharts 5.3 之后可以靠overflow直接截断:

label: { position: 'right', fontSize: 12, width: 96, overflow: 'truncate', ellipsis: '...' }

overflow还支持break和breakAll两种换行模式,中文节点名建议用breakAll,按字符断行,不会出现半截词。

tooltip 则需要分别处理节点和边,因为它们的params结构不一样:

tooltip: { trigger: 'item', confine: true, extraCssText: 'white-space: normal; max-width: 280px;', formatter: (p) => { if (p.dataType === 'edge') { return `${p.data.source} → ${p.data.target}<br/>量:${p.data.value}`; } return `${p.name}<br/>流入流出合计:${p.value}`; } }

这里有个细节:边的params.name默认是起点 > 终点拼接出来的字符串,所以取数据时用p.data.source和p.data.target更可靠。另外长文本自动换行的问题,与其在formatter里手动插<br/>,不如直接给extraCssText加上white-space: normal和一个最大宽度,让它自然折行,这样不同长度的内容都能处理。

4.3 强调交互与点击事件

ECharts 5 在桑基图上的emphasis.focus支持两个很有用的值:adjacency高亮相邻的节点和边,trajectory则高亮整条贯通的路径。做用户路径分析时,trajectory的体验非常好,鼠标悬停在一条边上就能看到这条链路上的全部环节。

emphasis: { focus: 'trajectory', lineStyle: { opacity: 0.7 } }

这里有一个容易踩的坑:focus生效时会自动把非相关元素淡化,如果你的lineStyle.opacity本来就设得很低(比如 0.3),叠加之后几乎看不见了,整张图会呈现"灰掉一大片"的效果。建议开focus的时候把lineStyle.opacity提到 0.5 以上。

点击事件里同样要用dataType分流:

chart.on('click', (params) => { if (params.dataType === 'node') { // params.name 是节点名,可以在这里做下钻 } else { // params.data 里有 source / target / value } });

节点点击做下钻、边点击做明细弹窗,是桑基图在大屏上最常见的两种交互延伸。

5. 数据量上来之后的性能与布局问题

5.1 节点爆炸的三种应对

桑基图的渲染成本和节点数、边数同时相关。我的经验是节点超过 60 个、边超过 200 条之后,开始能感觉到首屏延迟;到 200 个节点以上,拖拽和悬停都会明显掉帧。

第一种应对是合并小流量。按占比设阈值,低于阈值的边统一归到一个"其他"节点:

const total = links.reduce((s, l) => s + l.value, 0); const threshold = total * 0.005; const merged = new Map(); for (const l of links) { if (l.value < threshold) { const key = l.source + '\u0000其他'; merged.set(key, (merged.get(key) || 0) + l.value); } else { merged.set(l.source + '\u0000' + l.target, l.value); } }

这样做会损失一点精度,但换来的是可读性和流畅度,对展示型大屏来说完全值得。

第二种应对是分面或下钻。把四层结构拆成两张两张的图,或者点击某个节点再加载它的下一层细节。

第三种是关掉动画:animation: false。桑基图的动画本来就比较贵,静态展示的场景直接关掉最省事。

5.2 布局参数对观感的影响

layoutIterations的效果不是线性的。32 到 64 之间提升明显,64 以上基本就看不出来了,但耗时还在涨。节点数多的时候,我更倾向于把它降到 16,然后通过调整data里节点的顺序来减少交叉——这比让算法自己迭代要快得多。

还有一个常被忽略的参数是orient。默认水平布局,适合宽屏。如果容器是窄高的(比如侧边栏里的小图),改成orient: 'vertical',流向自上而下,反而更清晰。但要注意竖向布局时标签的position要相应改成bottom或者top,不然文字会压到节点上。

节点高度不足也是常见现象:当某一层节点数量多、容器高度却不高时,nodeGap会挤压节点高度,最后变成一堆细线。这时候要做的不是调nodeGap,而是增加容器高度或者减少该层节点数量。

5.3 大屏适配与字号缩放

很多人用了pxtorem之类的方案之后发现 ECharts 里的文字纹丝不动,原因很简单:ECharts 的图形是画在 canvas 上的,canvas 内部的字号由 option 里的数字决定,跟根元素的font-size没有任何关系。CSS 的 rem 只能影响 DOM,管不到 canvas 内部。

正确的做法是监听容器尺寸变化,自己算一个缩放比例再重新设置字号:

const DESIGN_WIDTH = 1920; function applyScale() { const w = container.clientWidth || DESIGN_WIDTH; const scale = w / DESIGN_WIDTH; chart.setOption({ series: [{ label: { fontSize: Math.max(10, Math.round(12 * scale)) } }] }); } let rafId = null; window.addEventListener('resize', () => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { chart.resize(); applyScale(); }); });

这里用requestAnimationFrame做一次节流,是因为浏览器 resize 事件触发非常密集,每次直接调resize()和setOption()会让大屏在拖拽窗口时卡成幻灯片。另外字号要用Math.max兜一个下限,否则窗口缩得特别小时文字会消失。

6. 排查清单与数据口径校验

6.1 图不显示时的排查顺序

按这个顺序查,基本能覆盖九成情况:

  1. 容器高度。clientHeight是不是 0?在 flex 布局里父元素没给高度、或者用了height: auto,容器实际高度就是 0,init不会报错,但什么都看不见。
  2. 初始化时机。在 Vue 或 React 里,init必须等 DOM 挂载完成,Vue 用onMounted或nextTick,React 用useEffect。太早调用会拿到一个还没渲染的容器。
  3. 按需引入遗漏。前面提过的SankeyChart没注册。
  4. 数据字段名写错。比如把links写成了edges,或者把value写成了count。
  5. value 非法。负数、字符串数字、NaN都可能让边消失。
  6. 存在环。控制台翻一遍有没有 DAG 相关的提示。
  7. 容器被隐藏过。如果图表是在 tab 切换后才显示的,初始化时容器宽度可能是 0,需要在切换后调用一次chart.resize()。

6.2 守恒性校验比图好看更重要

桑基图不会强制校验数据守恒。也就是说,即使你的中间节点流入 1000、流出 800,ECharts 也照样画得出来,只是节点看起来会有点怪。这种错误肉眼很难发现,所以值得在渲染前跑一个校验函数:

function checkBalance(links) { const inflow = new Map(); const outflow = new Map(); for (const { source, target, value } of links) { outflow.set(source, (outflow.get(source) || 0) + value); inflow.set(target, (inflow.get(target) || 0) + value); } const names = new Set([...inflow.keys(), ...outflow.keys()]); const bad = []; for (const name of names) { const i = inflow.get(name) || 0; const o = outflow.get(name) || 0; if (i > 0 && o > 0 && Math.abs(i - o) > 1e-6) { bad.push({ name, 流入: i, 流出: o, 差值: i - o }); } } return bad; }

判断逻辑是:只有"既有流入又有流出"的中间节点才需要平衡。起点只有流出、终点只有流入,这两种属于正常情况,不参与校验。跑一遍返回空数组,说明口径基本是对的;有差值,就去查时间窗口是否对齐、去重逻辑是否一致。

6.3 版本差异带来的"玄学"问题

ECharts 4 到 5 在桑基图上有几处行为变化。emphasis的focus是 5.x 才稳定的能力,4.x 上没有;label的overflow、width、ellipsis也要 5.3 以后才支持;levels里lineStyle的继承行为在不同小版本之间也有细微差别。

我现在的做法是在package.json里把 ECharts 锁到具体的小版本,而不是用^5.0.0这种范围。桑基图这类高级图表的配置项多、耦合深,小版本升级引入的细微变化很难第一时间定位,锁版本能省掉很多莫名其妙的排查时间。

真要说桑基图这件事上最省时间的习惯,其实是先看数据再看图:把聚合后的links用console.table打出来扫一眼,看有没有自环、有没有异常大的数值、来源和目标的名称有没有拼写不一致,这几步做完,渲染阶段基本不会出意外。至于nodeGap调到 8 还是 10、curveness用 0.5 还是 0.6,这些都是要看具体数据密度的,很难有标准答案,多试几次凭眼睛定就好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询