资讯详情

ECharts堆叠柱状图百分比显示:原理、配置与避坑实践

📅 2026/10/15 15:49:35 | 华诺云谱 👁 阅读
ECharts堆叠柱状图百分比显示:原理、配置与避坑实践
最近在做一个数据展示类的内部工具时接到一个看起来特别简单的需求把某个指标按月份、按类别拆开看构成占比领导要求“一眼就能看出谁多谁少、占比大概多少”于是很自然地就想到了ECharts的堆叠柱状图。等真正把图拖进页面里才发现堆叠柱状图的“百分比显示”远不是给formatter加上一个%就完事而是牵扯到数据计算的时机、tooltip和label的取舍、分母为0的边界以及图例交互后百分比会不会跟着变。这篇文章就把我这次踩坑和最终落地的过程完整记录下来给同样被ECharts堆叠柱状图百分比折磨过的朋友一个可复用的思路。1. 为什么堆叠柱状图的百分比显示一眼简单上手全是问题1.1 需求场景什么情况下会要“百分比堆叠”先说说我遇到的实际需求。某个面板需要展示四个业务品类在最近一年里的月度构成每根柱子代表一个月的总量柱子内部按品类分色堆叠。最开始版本用的是绝对数值效果如下柱子的高度能看出总量波动的趋势但某个品类到底占当月总量的百分之多少只能靠肉眼估。产品经理看完后提了一条意见“把占比直接挂在每段柱子上或者鼠标悬浮的时候直接显示百分比这样更直观。”这个需求本身一点都不罕见在经营分析、流量分析、项目排期、库存结构等场景里几乎天天都能碰到。但“按类别堆叠”和“按百分比堆叠”背后的数据语义是完全不同的。前者每根柱子的高度是原始数值之和后者每根柱子的高度被强制拉成一样高100%只通过分段长度来体现构成占比。大多数人的第一反应是“那我把每一段的值除以这一列的总和再乘以100传给ECharts不就行了”。这个思路没有错但它漏掉了两个关键问题第一你传入的数值如果已经是百分比tooltip里还能不能同时显示原始数值第二当用户通过图例把某个品类隐藏掉之后剩下的分段百分比是应该重新计算还是保持原样这些细节不处理好图看起来就“怪怪的”。1.2 百分比显示的两个层面柱顶标签与悬浮提示实际上ECharts堆叠柱状图的“百分比显示”至少有三种不同的入口很多新手会混淆。第一种是柱子上直接显示每段占比这就需要给series.label配置formatter在柱段内部或上方输出类似“18%”这样的文本。第二种是鼠标悬浮时弹出的提示框也就是tooltip这里显示的是value对应的内容默认情况下就是你传给 ECharts 的原始数值需要你自己写formatter来拼出“原始数值 占比”的完整信息。第三种是Y轴刻度如果你只关心占比结构可以把yAxis的刻度改成0到100甚至直接在yAxis.label.formatter里追加%符号。这三个入口看起来是同一个需求实际上对应的是三份完全不同的代码。更微妙的是如果你选择在传给ECharts之前就把数据换算成百分比那除了tooltip.header还算正常之外tooltip里的原始值、图例开关后的动态占比都变得不好处理。所以这个问题的核心不在于“要不要显示%”而在于“百分比在哪一层算”。提示我在这次实践里最终确定的方案是——传给ECharts的仍然是原始数值只在label和tooltip的formatter层动态计算百分比。这样数据源保持干净图例交互、数据更新都能自动适配后面会详细展开。2. 先把基础堆叠配置搞对再谈百分比2.1 stack属性的作用与堆叠顺序要解决百分比问题前提是把最基础的堆叠配置写对。ECharts的堆叠靠的是series里每一项的stack字段只要stack取同一个字符串值这些系列就会被当成一组按顺序叠加在图表的同一个柱子位置上。比如有三个品类 A、B、C配置大概是这样的series: [ { name: 品类A, type: bar, stack: total, data: [120, 200, 150] }, { name: 品类B, type: bar, stack: total, data: [80, 50, 100] }, { name: 品类C, type: bar, stack: total, data: [30, 70, 90] } ]这里有个很多人忽略的细节堆叠的顺序完全由 series 数组的先后顺序决定第一个 series 在最下面后面的依次往上叠。如果某些数据为0或者负值会出现“往上叠”和“往下叠”同时存在的现象——负数会往Y轴下方延伸导致整个堆叠结构看起来像在打补丁。这个影响在百分比显示时会被放大因为负数的占比没有实际意义。所以如果数据里可能有负数做堆叠之前要先做过滤或归一化处理。同时stack的值本身不会显示在任何地方它只用于分组所以可以随便命名但要保持同一组的所有系列完全一致。曾经因为某两个系列一个写stack: total另一个写stack: 合计图表“很安静地”分成了两根独立的柱子排查了半天才发现是这里的问题。2.2 百分比计算的核心formatter里的this与函数参数ECharts 的label.formatter和tooltip.formatter都支持字符串模板和函数两种写法。字符串模板适合简单拼接比如{b}: {c}但要做除法、保留小数、判断分母模板字符串就无能为力了必须用函数。函数写法需要弄清楚参数结构。以label.formatter为例回调函数接收一个对象参数里面常用的字段包括name当前系列的名称也就是series.namevalue当前数据点的值正常情况下就是原始数值dataIndex当前数据点在data数组中的索引也就是“哪根柱子”seriesIndex当前系列在series数组中的索引配合dataIndex可以从原始数据源里找对应关系tooltip.formatter的参数更复杂一点默认情况下它接收一个数组每个元素对应一根柱子上的一段也就是一个系列数组里的每个对象有seriesName、value、dataIndex、seriesIndex等字段。这两个formatter里做百分比计算思路是一样的拿到当前这一段的数值再拿到整根柱子的合计值然后相除。关键问题是“整根柱子的合计值”从哪来。如果数据都在series里最简单的办法是在外部维护一份与xAxis同步的合计数组const rawData [ { month: 1月, values: [120, 80, 30] }, { month: 2月, values: [200, 50, 70] } ]; const totals rawData.map(item { return item.values.reduce((sum, v) sum v, 0); });然后在formatter里用params.dataIndex取出对应的totals值做除法。这个过程听起来简单但真正让很多人翻车的是在tooltip.formatter里this的指向和params的类型在不同版本、不同入口下不统一。比如label.formatter的函数里this能拿到当前数据点的一些额外属性但用箭头函数时this又不一样了。我建议统一用回调参数也就是箭头函数的第一个参数params来做计算不要依赖this省得在不同ECharts版本间踩坑。2.3 一个能正常出图的基础demo写一个最简可运行的例子供对照。假设有三个品类、三个月的原始数据我们把stack配好label和tooltip都按百分比展示。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / title堆叠柱状图百分比演示/title script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script style .chart { width: 800px; height: 400px; } /style /head body div idchart classchart/div script const chart echarts.init(document.getElementById(chart)); const months [1月, 2月, 3月]; const seriesData [ { name: 品类A, data: [120, 200, 150] }, { name: 品类B, data: [80, 50, 100] }, { name: 品类C, data: [30, 70, 90] } ]; const totals months.map((_, idx) { return seriesData.reduce((sum, s) sum (s.data[idx] || 0), 0); }); const series seriesData.map(s ({ name: s.name, type: bar, stack: total, barMaxWidth: 48, label: { show: true, position: inside, formatter: params { const total totals[params.dataIndex] || 0; const percent total 0 ? (params.value / total) * 100 : 0; return percent.toFixed(1) %; }, fontSize: 12 }, data: s.data })); chart.setOption({ tooltip: { trigger: axis, axisPointer: { type: shadow }, formatter: params { const idx params[0].dataIndex; const total totals[idx] || 0; let html months[idx] 总计: total br/; params.forEach(p { const percent total 0 ? (p.value / total) * 100 : 0; html p.marker p.seriesName : p.value ( percent.toFixed(1) %)br/; }); return html; } }, legend: { data: seriesData.map(s s.name) }, xAxis: { type: category, data: months }, yAxis: { type: value }, series: series }); /script /body /html这个demo出图后柱子上每一段都显示了自己占整根柱子的百分比悬浮时提示框同时显示原始值和百分比。注意第36行params.value的类型在某些情况下是数组不是数字这个我在后面第4节单独讲。3. 手把手写出稳定的百分比显示方案3.1 方案一预计算百分比数据第一种方案是在业务代码里先把原始数据转换成百分比再喂给ECharts。还是上面的例子处理后的data变成每个值除以当月合计、乘100并保留一位小数const percentData seriesData.map(s { return s.data.map((value, idx) { const total totals[idx] || 0; return total 0 ? Number(((value / total) * 100).toFixed(1)) : 0; }); });这种方案的优点是思路直白formatter里甚至不用写任何计算逻辑label直接显示{c}%就行。缺点是后续如果要“同时显示原始值”数据源里已经没有原始值了。实际工作中预计算方案常出现在后端已经算好百分比的场景比如接口直接返回[{ name: 品类A, percentValue: 18.2 }]。这时候前端确实省事但要注意一个问题后端返回的数据一旦出现“维度缺失”比如某个月份品类C没有数据传给ECharts时要么补0要么不传。如果不传堆叠图会把不传的那根柱子“忽略”掉百分比的含义就会被扭曲。这一点一定跟前端同事或后端同事对齐字段协议缺失值统一补0并明确标识。3.2 方案二运行时计算formatter统一处理第二种方案就是我前面demo演示的data保持原始数值label和tooltip的formatter里动态计算占比。这个方案的工程化结构可以再收紧一点把“提取数据索引、计算百分比、格式化输出”封装成一个公共函数。数量较少的项目我用一个工具函数把这件事统一掉function formatPercent(params, totals, digits 1) { const value Array.isArray(params.value) ? params.value[params.seriesIndex] : params.value; const total totals[params.dataIndex] || 0; const percent total 0 ? (value / total) * 100 : 0; return { valueText: value, percentText: percent.toFixed(digits) % }; }然后label.formatter和tooltip.formatter都调用这个函数。这里的Array.isArray判断就是针对于value可能是数组的情况例如某些图表启用了多维度、data里的每个元素是一个数组。虽然在本例里用不上但养成这个习惯可以避免很多隐性的类型错误。但封装的颗粒度还要考虑可维护性。一个图表的多个系列、多个tooltip如果散落着十来处百分比计算逻辑后续加一个“保留两位小数”的需求就得全局搜索。这种情况下建议把“传入原始数据 返回格式化配置”做一次统一转换让所有formatter都从同一个函数生成。我最后的做法是写了一个buildStackPercentSeries(rawSeries, options)的纯函数传入原始系列数组和合计数组返回加工好的series配置数组。因为本身是个内部工具配置不算复杂这样已经足够清晰。3.3 两种方案的取舍本质区别在于预计算把“计算”放在了ECharts外部运行时计算把“计算”放在了ECharts渲染流程内部。数据量小、图表简单、后端已经给好百分比用方案一最省事。但凡是涉及图例交互、筛选联动、数据下钻要特别注意方案一的潜在问题用户点击图例隐藏某个品类后堆叠图会重新计算每段的几何高度但如果显示在label上的百分比是后端算好的固定值隐藏一个系列后合计不变这个固定百分比当然也还在。然而旁边柱子里的其他系列占比却不会重算整张图立即出现“视觉占比和数字占比不一致”的割裂感。坐在旁边看演示的同事一眼就会发现“这个百分比怎么跟柱子的颜色长度对不上”。方案二因为始终从当前series和dataIndex对应的实际数据动态计算图例切换后只要重新执行setOption或ECharts内部触发的渲染更新百分比也会跟着重新计算数据语义保持一致。这个优势在实践里非常突出。所以我最终的结论是除非极简单的纯展示场景否则都建议使用方案二让ECharts的数据永远是“真值”只把“百分比”当成一种呈现形式。4. 实际项目里踩过的那些坑与处理细节4.1 分母为0和null值图表不报错但百分比会变成 Infinity先提一个每个做数据可视化的人几乎都遇到过的情况某根柱子所有系列的数据都是0或者后端返回了null。如果只算百分比而不做防御value / 0的结果是Infinityformatter会直接输出Infinity%。ECharts不会报错但产品验收时看就是明显的bug。处理方式并不复杂但必须在每一处百分比计算的地方统一做判断合计是否为0是则百分比输出为0.0%并在tooltip里明确提示“当月无数据”判断value是否为null/undefined是则跳过这一段的显示也可以把value设置为null避免label输出null%用Number.isFinite()兜底防止后端返回字符串导致类型干扰。我的经验是比起在每个formatter里复制粘贴防御代码更可靠的是在数据进入图表前做一次清洗把null转成0同时维护一份状态数组记录“哪些是真实缺失”。这样formatter只需要关心分母为0这一个边界即可。4.2 图例交互与百分比计算的一致性问题图例交互不是只有“显示/隐藏系列”这一种操作还涉及legend.selected的初始值、legendselectchanged事件的监听。默认情况下图例点击后ECharts不会重新执行你的formatter函数但图上的柱子高度会变params.dataIndex指向的totals数组却还是全量数据的合计。这时如果totals是固定算好的就会出现“某系列隐藏后其他系列百分比相加不等于100%”的问题。要解决这个问题就不能让totals是一个静态数组而应该是“根据当前可见系列重新计算”。我在项目里是用一个getVisibleTotals()函数遍历series配置拿到每个系列的data再根据图例选中状态过滤最后重新计算合计并在legendselectchanged或legendselectall时调用chart.setOption触发重绘。chart.on(legendselectchanged, function (params) { const selected params.selected; const newTotals months.map((_, idx) { return series.reduce((sum, s) { if (selected[s.name]) { sum (s.data[idx] || 0); } return sum; }, 0); }); // 重新设置一个包含 newTotals 的 option myChart.setOption(buildOption(series, newTotals)); });这里注意setOption默认是“合并”模式如果只传tooltip和seriesECharts会尝试合并旧的series有时候会产生重复系列。稳妥的做法是维护一个统一的buildOption函数每次重新生成完整的option结构传进去或者用setOption(option, true)强制清空重建。我在内部工具里采用了第二种方式因为数据量不大清空重建的代价可以忽略但代码逻辑明显更简单了。4.3 数据量大时barWidth与label显示密度怎么平衡堆叠柱状图一旦月份维度拉长比如24个月以上或品类变多比如10个系列一个小问题会被放大成视觉灾难每段柱子的宽度太窄label显示不下字号又无法缩小到看不见。我当时处理一个“近三年36个月、12个品类”的看板时每根柱子下设了barMaxWidth: 24但label仍然挤成一团。解决思路有三条可以组合使用根据可视区域宽度动态调整barMaxWidth保证柱子宽度不小于某个阈值比如12px否则考虑横向滚动label不每段都显示只显示占比最高的前几项其余用“其他”替代这需要label.formatter里访问原始业务数组来做判断tooltip保留全部明细柱子上只显示最小百分比阈值比如小于5%不显示避免杂乱。我的最终配置是label.fontSize: 10label.position: inside并在formatter里加了一个判断——当percent 3时不显示文本。这样既保证了大趋势可见又不会让图上全是密密麻麻的数字。4.4 保留几位小数、四舍五入与合计为100%的微妙关系百分比显示还天然牵涉“小数位数”的语义问题。toFixed(1)会把18.25%显示成18.3%但五个品类显示出来相加可能是99.9%或100.1%。这在追求严谨的业务报表里是需要预警的。我的做法是柱上label只用一位小数目的是给阅读者一个快速直觉而tooltip里则用两位小数并且额外补一行“合计校验”写明各项之和是否等于100%。如果产品要求“必须严格等于100%”就需要用“最大的余数调整法”Largest Remainder Method来分配进位但这个方法不在ECharts层面能解决的范畴需要后端或前端数据处理层实现并把最终算好的百分比值传给图表。这个细节如果产品不特别强调一般控制在“tooltip里说明因四舍五入存在误差”即可。提示ECharts里的toFixed返回的是字符串做后续数学运算比如累加求校验和时会自动隐式转成数字但建议显式Number()一下避免之后拼接字符串时出现意外。5. 从“能显示”到“好看”百分比堆叠的增强玩法5.1 多根柱子之间的“占比趋势”可视化把Y轴变成百分比刻度如果所有柱子的高度都表示占比最直接的做法是把yAxis固定为0~100并让yAxisLabel自带一个百分比后缀yAxis: { type: value, min: 0, max: 100, axisLabel: { formatter: {value}% } }这种写法适合纯粹看“构成结构”的看板每根柱子都被拉伸到同样高度焦点完全在分段比例上。缺点是无法直观呈现总量的高低例如“3月总量是2月的两倍”在百分比模式下完全看不出来。如果想兼顾两者可以用双Y轴左轴显示原始数值右轴是0~100%或者反过来。我在实际项目里更常使用的是“两种模式切换开关”由用户自己决定“看绝对值”还是“看占比”。模式切换不需要改数据只需要在setOption里切换Y轴max值和tooltip的formatter模板。唯一需要注意的是切换到100%模式时label.formatter里比例计算仍然基于原始totals这个对用户是透明的体验很顺。5.2 堆叠总计的标记total条或折线叠加百分比显示最容易让人困惑的一点是“每个柱子加起来到底是多少”。在绝对数值模式下柱顶高度给出了直观答案切到百分比模式后这个信息就没了。针对这个劣势一个有效增强是叠加一条“柱顶平均值”或“总量变化”的折线。ECharts里想要在同一张图上叠加折线只需增加一个seriestype: lineyAxisIndex: 0或1视情况而定。这个折线用的数据是每个月的总量。折线不需要出现在堆叠组里所以它不设stack但要把它的tooltip格式和百分比提示区分开。我当时做了一个版本折线上标记每个月的合计值而柱子的tooltip显示各项占比两者一配合既能看结构又能看总量走势几乎满足了这个看板的所有业务问题。图中与折线叠加时还要注意坐标轴对齐。因为柱子是堆叠的顶部高度恰好是总量折线数据也恰好是总量。这样折线和柱顶在视觉上是贴合的不会出现错位。5.3 结合dataZoom做长周期百分比堆叠如果时间范围扩展到一年以上我建议直接引入dataZoom让用户拖动查看不同时间区间。dataZoom不会影响formatter执行只要totals的计算是按“当前可见的数据点索引”来就能保持正确。这里有一个容易踩的坑dataZoom存在时params.dataIndex是“原始数据数组的索引”而“可视区域内展示的第几个格子”使用起来要注意换算。ECharts帮我们处理掉了大部分映射dataIndex默认指向原始索引所以使用totals[params.dataIndex]是安全的。但如果是通过dataZoom的startValue/endValue动态请求后端数据、并局部更新series.data就需要保持数据长度一致性否则图表会自动补空位。这块在设计组件时最好就约定好无论dataZoom范围怎么变前端始终持有全量数组由ECharts自行裁剪绘制而不是频繁替换数据源。5.4 百分比显示与暗色主题的兼容最后说个偏视觉的细节。内部系统通常有深色模式堆叠柱状图在用百分比标签时label的颜色如果直接写死成#333深色背景上会看不清而不同品类的填充色又深浅不一很难找到一个对所有柱段都适用的文字颜色。我的建议是两种方案任选统一使用label.color: #fff并给label设置textBorderColor: rgba(0,0,0,0.3),textBorderWidth: 2这样白色文字带一圈细边在任何色块上都清晰或者label.color不全局设置改用rich富文本样式按系列单独指定颜色与柱体颜色形成对比色。这个细节看起来小实际投入运行时发现它直接影响“能不能看清”很影响验收印象。最后再分享一个小技巧说到底ECharts堆叠柱状图的百分比显示真正的难点不在ECharts本身而在于你如何看待“数据”和“展示”的关系。如果一开始就把原始数值和百分比当做两套数据分开维护后面十有八九要在图例交互、数据更新、边界处理上反复打补丁如果你把“百分比”当成一种视图层格式化逻辑让ECharts始终持有原始数值大部分问题会变得简单很多。我个人的工程习惯是所有formatter的百分比计算集中在一个公共模块里统一处理分母为0、null值、小数位数和可见系列合计。这样即使页面里有十几个维度相同但配置不同的堆叠图改一处格式或修一个边界问题所有图表都能跟着变。希望这篇文章能帮你少走一点弯路早点把那张“干净、正确、好看”的堆叠柱状图交出去。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑