做前端可视化这行,碰到堆叠柱状图显示百分比的需求,实在是太常见了。业务方永远不满足于“这根柱子有多高”,他们更想知道“每个颜色分段到底占了多少比例”。ECharts 的堆叠柱状图本身只负责把数值一个个叠上去,并不会自动帮你算好百分比并展示出来,于是就有了标题里说的“关于使用 echarts 堆叠柱状图百分比显示的问题”。
这篇文章我想把自己实际踩过的坑、用过的方案、最后沉淀下来的完整写法都整理一遍。你会看到三种典型诉求——柱体上直接标注百分比、tooltip 里同时展示数值和占比、以及 y 轴是否要用百分比刻度——各自对应的配置方法和计算逻辑。不管你是刚接触 ECharts 的入门选手,还是已经写过不少图表但每次都被百分比显示逼疯的老手,看完应该都能直接“抄作业”,并且能根据数据场景选对方案。
1. 需求拆解:先分清“百分比”到底要显示在哪里
1.1 堆叠柱状图的典型业务场景
堆叠柱状图的本质是把多个系列在同一个分类下纵向累加,最终呈现“一根柱子 = 若干部分组成”的视觉结构。最常见的场景就是构成类分析,比如一个网站的流量来源构成:直接访问、搜索引擎、外部链接、社交媒体链接,四类流量每天都叠成一根柱子,看总量也能看各自变化。再比如电商后台的订单渠道分析、运维平台的资源分配占比、广告投放的媒体消耗构成,套路都一样。
在这些场景里,业务方看一个堆叠柱子时,眼睛会自动聚焦在两个维度上:这根柱子代表的总量是多少、每个颜色段占总量的百分比是多少。总量可以直接从 y 轴读出来,或者从 tooltip 看到,但“某个颜色段占比多少百分比”这件事,ECharts 默认是不会替你做的。它只知道把数值相加、画出来,至于百分比,是你的业务逻辑,需要你自己来算。
1.2 为什么 ECharts 不直接支持“堆叠分段显示百分比”
初学的时候我一度以为是不是自己配置写错了,搜了半天发现根本没有一个stackPercent: true这种开关。原因要从 ECharts 的渲染机制说起:堆叠只是通过 series 里的stack: 'total'字段告诉渲染引擎“这些系列要累加在一起”,但图表的 label 是对每个系列单独配置的。当你打开某个系列的 label 时,ECharts 只把当前这个系列的当前数据项交给 formatter 回调函数,它并不知道同一根柱子上其他系列的值是多少,自然也就没办法替你算出“当前分段在整根柱子中的占比”。
换句话说,ECharts 知道怎么画堆叠,但它不关心你业务里的“占比”概念。占比是需要你基于数据本身去计算和传递的,它只负责把你算好的结果展示出来。这一点想通了,后面的方案就都顺理成章了。
2. 百分比计算的两种思路:先想清楚再写代码
2.1 思路一:预计算百分比数据,渲染时直接取用
第一种做法是在构建 ECharts 的 option 之前,先把原始数据加工一遍。具体来说就是三个步骤:先算出每个分类下所有系列的总值;再遍历每个系列的每个数据项,用当前值除以对应分类的总值得到百分比;最后把百分比和原始值一起塞进 series 的 data 项里,data 项从一个普通数字变成一个对象。
const source = { categories: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], series: [ { name: '直接访问', data: [320, 302, 341, 374, 390, 450, 420] }, { name: '搜索引擎', data: [120, 132, 101, 134, 90, 230, 210] }, { name: '外部链接', data: [80, 90, 50, 60, 40, 70, 60] } ] }; // 第一步:计算每个分类下的总值 const totals = source.categories.map((_, index) => { return source.series.reduce((sum, s) => sum + (s.data[index] || 0), 0); }); // 第二步:生成带百分比的数据 const seriesWithPercent = source.series.map(s => ({ name: s.name, data: s.data.map((value, index) => ({ value: value, percent: totals[index] === 0 ? 0 : +(value / totals[index] * 100).toFixed(2) })) }));这样处理完之后,配置 label 就非常简单了,formatter 里直接从params.data.percent取出来用:
option = { xAxis: { type: 'category', data: source.categories }, yAxis: { type: 'value' }, series: seriesWithPercent.map(s => ({ name: s.name, type: 'bar', stack: 'total', data: s.data, label: { show: true, position: 'inside', formatter: params => params.data.percent + '%' } })) };我实际用下来觉得这种方式最稳。它的核心好处是计算逻辑集中、调试方便,渲染的时候 formatter 里不用做任何数学运算,性能开销几乎为零。哪怕你的数据有几千个点,也只是在构建 option 的时候算一次百分比,后面图表交互、动画、缩放都不会重复计算。
2.2 思路二:在 formatter 回调里实时计算
第二种方式是不预计算百分比,而是在 label 的 formatter 里现场通过闭包或者全局变量拿到当前分类下所有系列的值,再现场算出占比。
const seriesList = source.series.map(s => ({ name: s.name, data: s.data })); const option = { xAxis: { type: 'category', data: source.categories }, yAxis: { type: 'value' }, series: seriesList.map(s => ({ name: s.name, type: 'bar', stack: 'total', data: s.data, label: { show: true, position: 'inside', formatter: params => { const total = source.series.reduce((sum, item) => sum + item.data[params.dataIndex], 0); const percent = total === 0 ? 0 : (params.value / total * 100); return percent.toFixed(1) + '%'; } } })) };这种方式的好处是数据源保持纯粹,不需要加工成对象结构,如果你的数据会频繁通过setOption更新,新数据一到,formatter 里会自动重新计算百分比,不存在“旧百分比数据忘了更新”的问题。代价就是每次渲染 label 时都要遍历一遍所有系列求和,当分类数和系列数都很大的时候,视觉上会有一点点计算开销。
2.3 两种方案到底怎么选
我自己的经验是,这个选择主要看数据规模和更新频率,其次看团队的可维护性偏好。
| 对比维度 | 预计算百分比(思路一) | formatter 实时计算(思路二) |
|---|---|---|
| 数据量大时性能 | 好,计算一次存起来 | 一般,每次渲染都遍历求和 |
| 数据频繁更新 | 每次更新前要重算 | 天然支持,更新后自动正确 |
| 是否需要保留原始值 | 可以用对象同时保留 | 原始值就是 params.value,无额外负担 |
| 代码可读性 | 数据处理逻辑独立,好维护 | formatter 里塞逻辑,配置会略臃肿 |
| 出错概率 | 需要额外处理更新逻辑 | 需要小心闭包引用 |
绝大多数后台报表页面的数据都是一次性渲染的,这种情况下我强烈推荐预计算方案,因为它逻辑清楚、可测试、不容易出幺蛾子。只有当你做的是实时监控、数据每分钟刷新一次的看板时,我才会推荐用 formatter 实时计算,因为省掉了每次更新数据前重算百分比的步骤。
3. 手把手实现:三种显示需求的完整配置
3.1 先把基础堆叠柱状图搭出来
不管最终要显示什么百分比,第一步永远是先把堆叠柱状图画出来。前面已经定义好了source,基础配置长这样:
const baseOption = { tooltip: { trigger: 'axis' }, legend: { data: source.series.map(s => s.name), bottom: 10 }, grid: { left: 50, right: 30, top: 40, bottom: 60 }, xAxis: { type: 'category', data: source.categories }, yAxis: { type: 'value' }, series: source.series.map(s => ({ name: s.name, type: 'bar', stack: 'total', barWidth: 36, data: s.data })) };堆叠的关键就一个字:所有系列必须设置相同的stack字段值。注意这里的stack: 'total'里的'total'只是个标识名,你可以改成任意字符串,只要是同一个字符串,这些系列就会被叠在一起。不同stack值的系列会被画成并列的柱子,这是新手最容易踩的坑之一。
3.2 柱体上直接标注百分比:label 的写法
这是最核心的需求。我的推荐做法是前面 2.1 的预计算方案,把source.series转成seriesWithPercent,然后给每个系列加上 label 配置:
const option = { ...baseOption, series: seriesWithPercent.map(s => ({ name: s.name, type: 'bar', stack: 'total', barWidth: 36, data: s.data, label: { show: true, position: 'inside', color: '#fff', fontSize: 12, formatter: params => params.data.percent + '%' } })) };这里有个重要细节:data 项从数字改成{ value: 数值, percent: 占比 }对象后,params.value依然能取到原始数值,params.data是完整的对象。所以 formatter 里既能用params.data.percent显示百分比,也能用params.value拿到原始值去拼接更复杂的文案。
比如业务方希望百分比和数值都显示,可以这样写:
formatter: params => `${params.data.percent}%\n${params.value}`这个 formatter 返回的内容会自动渲染在柱体分段上,\n换行会让两行信息叠加显示,配合position: 'inside'看起来非常规整。
再提一个视觉优化:当某个分段占总值的比例特别小的时候,比如只有 3%,柱体的可见高度很矮,label 文字放不进去,会出现严重的重叠。我的处理方式是给一个阈值,占比低于阈值的分段不显示 label,具体数值让用户去看 tooltip:
formatter: params => { if (params.data.percent < 5) return ''; return params.data.percent + '%'; }这个小技巧能瞬间提升图表的整洁度,尤其当你的数据里有大量零散小分类时。
3.3 tooltip 同时展示数值和占比:完整的自定义方案
label 显示的是静态百分比,而用户把鼠标悬停在柱子上时,tooltip 应该提供更丰富的信息:每个分段的名称、原始值、占比,以及整根柱子的合计值。
ECharts 的 tooltip 在使用trigger: 'axis'时,formatter 接收到的params是一个数组,数组里包含了当前鼠标悬停所在分类下所有系列的数据项。这意味着我们可以一次性拿到当前柱子的全部组成部分,想要怎么加总、怎么算百分比都自由。
tooltip: { trigger: 'axis', formatter: params => { if (!params || params.length === 0) return ''; const index = params[0].dataIndex; const total = source.series.reduce((sum, s) => sum + (s.data[index] || 0), 0); let html = `<b>${params[0].axisValue}</b><br/>`; params.forEach(item => { const percent = total === 0 ? 0 : (item.value / total * 100); html += `${item.marker} ${item.seriesName}:${item.value}(${percent.toFixed(2)}%)<br/>`; }); html += `合计:${total}<br/>`; return html; } }几个细节值得单独说明:item.marker是 ECharts 自动生成的颜色小圆点标记,直接拼接进字符串就能在 tooltip 里看到系列对应的颜色,不需要你自己去维护颜色值。item.seriesName是系列的名字。item.value在当前配置下是原始数值,因为 data 项是对象时 ECharts 会自动把value字段当作主要数值,老版本里这里偶有差异,4.x 和 5.x 的行为基本一致。
我还会顺手给 tooltip 设置backgroundColor、textStyle等样式,让它在深色图表里更协调,但这不是必须的,按 UI 需求来就行。
3.4 y 轴要不要也显示百分比:先搞清楚数据本身是什么
这个问题我问过好几个同事,他们最常走的弯路就是把 y 轴强制格式化成百分比:“我要显示百分比,那 y 轴的刻度也应该是 0%、50%、100% 吧?” 这种想法的前提是错误地认为所有堆叠柱的总高度都等于 100。
事实上有两种完全不同的数据语义。一种是我们前面一直讨论的“原始数值堆叠”,柱子的总高度就是总量,比如 520、690、492,这个时候 y 轴应该保持数值显示,百分比只是分段在总量里的比例。另一种是数据本身就是比例型,比如三个系列的值分别是 50、30、20,代表占比 50%、30%、20%,每个分类下所有系列加起来恰好是 100,这时候 y 轴用百分比刻度才是合理的:
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }所以关键判断标准是:你的原始数据加起来是不是正好等于 100?如果是,y 轴可以配上百分比格式化;如果不是,千万别硬上 0~100 的百分比轴,会严重误导看图的人。
我见过一个真实的坑例:某同事把原始访问量全部除以一万,假装凑成一个“看起来像百分比”的数,然后给 y 轴加了%,结果柱子高度和真实数据完全脱节,业务方看了半天才发现根柱子不齐。这类问题本质上是数据层没想清楚,不是图表配置的问题,越早沟通清楚越好。
4. 实操中踩过的坑与排查实录
4.1 坑:label 里的百分比加起来不等于 100
有段时间我负责的报表图莫名出现周一各分段百分比加起来只有 98.9%,排查了很久才发现问题出在求和时漏掉了一个系列。我的原始代码里把“外部链接”这个系列单独拎了出来,认为它不重要,结果它也有值,只是没参与 totals 的计算,所有分段的分母都少了一块。
这也暴露了预计算方案的一个隐患:totals 必须用同一个数据源里“全部”的系列来累加,任何一个系列漏了,后面所有百分比都会偏小。而且这种偏差往往不明显,差个一两个百分点,眼睛扫过去根本看不出来。
排查起来倒也简单,在控制台里把每个分类的 totals 打印出来,手动加一遍就能发现是不是漏了系列。另外如果你在数据里混用了 null、undefined 和 0,求和时记得统一处理,sum + (value || 0)这个写法要慎用,因为如果 value 是字符串,value || 0会把非空字符串原样加进去,直接变成字符串拼接。建议老老实实判断类型:
const num = typeof value === 'number' ? value : Number(value) || 0;4.2 坑:除零导致 NaN 或者 Infinity
当某个分类下所有系列的值都是 0 时,totals[index]等于 0,直接拿数值除以 0 会得到 Infinity,如果是value / total * 100得到 NaN,再套上.toFixed(2)会返回字符串"NaN"显示在页面上。这是我见过最频繁的报错现场。
解决方式非常简单,除法之前先判断:
const percent = totals[index] === 0 ? 0 : +(value / totals[index] * 100).toFixed(2);注意这里用totals[index] === 0判断,不要用!totals[index],因为后者会把 NaN、undefined 都当成真,逻辑上不够严谨。除零保护看着不起眼,但一旦数据中有空值就立刻暴露成 UI 上的红色 NaN,非常难看。
4.3 坑:label 文字全部挤在一起,根本看不清
堆叠柱中添加 label 后,经常出现一个柱子上四个分段的百分比挤成一团。原因主要有两个:柱宽太窄或者字体太大。我习惯先把柱宽固定下来,加上barWidth: 40这一类的配置,至少让每个分段有足够的横向空间。其次看分段数量,分类多、分段多的时候,fontSize 降到 10 或者 11,通常能缓解个七八成。
如果还是挤,就需要给 label 设个优先级:占比低于阈值的就不显示。前面已经给了代码,判断条件可以根据你的柱宽微调,5% 到 8% 都算合理区间。还有一种做法是把不显示的分段的 label 折叠到 tooltip 里说明,点击查看详情。
4.4 坑:浮点数精度问题,算出来的百分比总是奇奇怪怪
JavaScript 的浮点数精度问题,做前端的都知道,0.1 + 0.2 不等于 0.3。在百分比计算中,一堆value / total * 100运算后经常会得到19.724770642201835这种长尾巴,如果你直接展示,就会看到一长串小数,看着就很“草台班子”。
我的处理习惯是:百分比计算过程中先不要做任何取整,全部当成浮点数算完,最后在显示的时候用.toFixed(2)统一保留两位小数。如果你要生成的是传给 ECharts data 的 percent 字段,就用+(value / total * 100).toFixed(2),既保留了两位小数精度,又把它转回数字类型,避免字符串参与后续计算。
4.5 坑:多图表实例共享变量,百分比张冠李戴
在同一个页面渲染多个 ECharts 图表是常见需求,但如果每个图表都使用了同一个全局变量来缓存 totals 或者 series 数据,就会发生第二个图表把第一个图表的 totals 覆盖掉,导致第一个图表的 label 百分比全部错乱。
我的建议是:把百分比计算逻辑封装成一个纯函数,每次传数据进去,返回加工后的 series 数组,不要依赖任何跨图表全局状态。
function buildStackedPercentSeries(source) { const totals = source.categories.map((_, index) => source.series.reduce((sum, s) => sum + (s.data[index] || 0), 0) ); return source.series.map(s => ({ name: s.name, data: s.data.map((value, index) => ({ value: value, percent: totals[index] === 0 ? 0 : +(value / totals[index] * 100).toFixed(2) })) })); }然后在每个图表实例里只调用这个函数,chart 实例本身是隔离的,数据字段也是局部的,基本不会再出现污染问题。这也是我最终沉淀下来的标准做法。
4.6 版本差异带来的小坑:ECharts 4.x 和 5.x 之间的行为变化
通常在项目里我用的 ECharts 版本是固定的,但偶尔会遇到旧项目升级到 5.x 后 label 显示异常的情况。ECharts 5.x 对数据项对象的支持更完善,params.value在有 value 字段的数据对象中会准确取到 value 值,而在部分 4.x 版本中可能取到整个对象。遇到这种问题,最快的排查方式就是直接在 formatter 里打印params对象,看看到底长什么样。
另外 ECharts 5.x 默认开始支持 dataset 和编码映射,不过 dataset 模式下 label 的 formatter 参数结构有区别,想要显示百分比需要额外通过 encode 维度来取值,复杂度偏高。老实说,如果只是要实现堆叠柱状图的百分比显示,用传统series.data的方式反而最可控,不太建议为了“新特性”把简单问题复杂化。
5. 配置速查表与进阶建议
5.1 四种显示需求速查表
为了以后复制方便,我把几种常见显示需求的配置整理成了一张表:
| 显示需求 | 需要配置的位置 | 关键写法 |
|---|---|---|
| 每个分段上显示百分比 | 每个 series 的label.formatter | params.data.percent + '%' |
| tooltip 里同时显示数值和占比 | tooltip.formatter | 自定义函数,遍历params数组 |
| y 轴刻度显示百分比 | yAxis.axisLabel.formatter | '{value}%',但数据必须是比例型 |
| tooltip 里展示合计行 | tooltip.formatter手工拼接 | 先累加所有系列再拼接 |
| 隐藏过小分段的 label | label.formatter返回空字符串 | params.data.percent < 5 ? '' : ... |
这个表基本覆盖了 90% 的堆叠柱状图百分比显示需求。剩下的 10% 可能是像“鼠标移入某个分段时其他分段高亮”、“点击分段下钻”这类交互需求,那已经不是百分比显示本身能覆盖的范畴了。
5.2 再进阶一点:把数据加工和图表配置分开维护
我最后践行的架构是把数据加工和图表配置拆成两个模块。数据层负责计算 totals、百分比、排序等业务逻辑,图表层只负责把加工好的数据渲染成 ECharts option。实现上就是前面写过的buildStackedPercentSeries这样一个纯函数,底层接收原始数据,返回加工后的 series。
这样做的好处很实际:第一,数据层可以单独写单元测试,比如给定一组已知数据,断言百分比结果是否正确;第二,UI 设计变更时只需要调整图表层的 option 配置,数据层完全不用动;第三,后续接入数据接口的时候,后端返回什么格式都行,前端在数据层统一转换,图表层傻白甜地直接吃加工后的数据。
如果项目里用的是 Vue 或 React,还可以把 ECharts 封装成一个通用组件,接收 categories 和 series 数组作为 props,内部调用buildStackedPercentSeries,最后用setOption渲染。组件化之后,同一个“堆叠百分比柱状图”在系统里随便复用,不用每次复制粘贴大段 ECharts 配置。
我在实际项目里还常用一个技巧:给柱状图加一个不显眼的borderRadius样式,让堆叠柱的圆角顶部自然一点,视觉上高级不少。
series: seriesWithPercent.map((s, i) => ({ name: s.name, type: 'bar', stack: 'total', barWidth: 36, itemStyle: { borderRadius: i === seriesWithPercent.length - 1 ? [4, 4, 0, 0] : 0 }, data: s.data }))最后一个系列(柱子的最顶端)加圆角,下面的分段保持直角,看起来既柔和又不会糊成一片,这个细节在很多设计稿里都会加分。
5.3 关于性能的最后一个提醒
可能有人会问,数据量特别大的时候,预计算百分比的方式是不是也有问题?我的经验是:堆叠柱状图本身就不适合展示海量分类。如果分类超过几十个,柱状图会变得密密麻麻毫无可读性,这时候更好的选择是改用堆叠面积图,或者对分类做聚合、抽样后再用柱状图展示。
当分类数在几十个以内、系列数在个位数的时候,预计算模式下的计算量基本可以忽略,用不用 formatter 实时计算都没有感知差异。真正的性能瓶颈通常不在百分比计算,而在 setOption 渲染 DOM 和 canvas 绘制的数量上。所以选方案的时候,不要为了“优化”而优化,把代码写得简单、可直接维护,比省那几微秒更值钱。
最后再分享一个我的小习惯:任何百分比计算逻辑里,我都会在最后加上isNaN检查,比如isNaN(percent) ? 0 : percent。这一行看着多余,但能挡住所有前头预想不到的脏数据,比起事后到处排查 NaN 显示,用这一行提前兜底省心太多了。堆叠柱状图的百分比显示,本质上就是一个“想清楚数据语义 + 正确计算占比 + 选对展示位置”的问题,把这三件事做对,图表想难看都难。