数据可视化做到一定阶段早晚会碰到一类问题普通的柱状图、折线图、饼图只能回答谁多谁少回答不了这些东西是怎么从这一头流到那一头的。比如一笔订单从下单到出库经过了几个环节、每个环节流失了多少比如一个园区一个月的电力从几种来源分别流向了哪几个车间再比如用户从首页出发沿着哪些路径最终走到了支付页。这类带着量在流转的问题用 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里出现过但没写进dataECharts 通常不会直接报错而是自动把它当成一个新节点补进去。问题是这个自动补出来的节点没有任何你自定义的属性颜色、特殊标签全都会走默认值而且它在层级里的位置也不受你控制。所以我的习惯是永远显式声明所有节点哪怕是从 links 反推出来的。2.2 从明细表聚合出 links真实项目里数据几乎不会以边的形式存在而是流水明细。所以中间一定有一层聚合。SQL 里就是一次 group byselect 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 idsankey-box stylewidth: 100%; height: 640px;/divconst 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 图不显示时的排查顺序按这个顺序查基本能覆盖九成情况容器高度。clientHeight是不是 0在 flex 布局里父元素没给高度、或者用了height: auto容器实际高度就是 0init不会报错但什么都看不见。初始化时机。在 Vue 或 React 里init必须等 DOM 挂载完成Vue 用onMounted或nextTickReact 用useEffect。太早调用会拿到一个还没渲染的容器。按需引入遗漏。前面提过的SankeyChart没注册。数据字段名写错。比如把links写成了edges或者把value写成了count。value 非法。负数、字符串数字、NaN都可能让边消失。存在环。控制台翻一遍有没有 DAG 相关的提示。容器被隐藏过。如果图表是在 tab 切换后才显示的初始化时容器宽度可能是 0需要在切换后调用一次chart.resize()。6.2 守恒性校验比图好看更重要桑基图不会强制校验数据守恒。也就是说即使你的中间节点流入 1000、流出 800ECharts 也照样画得出来只是节点看起来会有点怪。这种错误肉眼很难发现所以值得在渲染前跑一个校验函数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这些都是要看具体数据密度的很难有标准答案多试几次凭眼睛定就好。