如果你也试过把销售额和转化率放进同一张柱状图,八成会遇到同一个尴尬:数据大的一方柱子顶天立地,小的一方贴在地板上一动不动。这个问题的标准解法,就是做一张左右柱状图公用一个X轴的图——图里只有一条水平类目轴,左右两侧各挂一条数值轴,两组柱状数据各用各的坐标系,互不干扰。我第一次在选课统计报表里把这个配置跑通时,最大的感受是:ECharts 官方文档里关于双Y轴的说明其实就几行,但真正落地的时候,坑全在细节里。这篇文章就把这些细节一次说清楚,适合正在做报表数据可视化、数据大屏,或者在完成课程作业里"每门课程男女选修人数展示"这类任务的同学。
1. 为什么"公用X轴"必然指向双Y轴:量纲差异与视觉陷阱
1.1 什么时候必须用双Y轴:三个一眼识别的场景
先明确一下"双Y轴"要解决的问题。图表里只有一个Y轴时,所有数据的数值都画在同一条数轴上,这是默认情况。可一旦两组数据的量纲差得太多,比如一边是选修人数(一两百人),另一边是考试平均分(70到90分),人眼确实还能看,但如果换成销售额(单位是万)和退货率(单位是0.0几),退货率柱子就会被压到看不见。
我在项目里遇到的实际场景更直接:运营同学要把"每日订单量"和"客单价"放在一张图上对比趋势。订单量是几百到几千,客单价是几十到一百多,虽然不至于压成一条线,但单轴下订单量柱子的高低变化,会完全盖过客单价柱子的细节,哪怕客单价涨了50%,在图上还是一副半死不活的样子。这种时候,双Y轴就是唯一合理的解法。
下面这个表是我判断"要不要上双轴"的速查表:
| 数据组合 | 量纲关系 | 推荐做法 |
|---|---|---|
| 销售额 vs 利润 | 同量纲、量级接近 | 分组柱状图,不需要双Y轴 |
| 选课人数 vs 平均分 | 人数几百、分数0~100 | 双Y轴,左人数、右平均分 |
| 订单量 vs 客单价 | 量级差距明显 | 双Y轴,左订单量、右客单价 |
| 男生人数 vs 女生人数 | 同量纲,直接对比 | 分组柱状图,禁止双轴 |
| 选修人数 vs 选修比例% | 数量与比率 | 双Y轴,左人数、右百分比 |
一句话总结:当一组数据描述"规模",另一组描述"比率、分数、单价",单位完全不同、数值范围也完全不在一个数量级时,优先考虑双Y轴。
1.2 共用X轴是整张图的骨架
"左右柱状图公用一个X轴"这句话,我一开始以为是把两张图画在同一个容器里左右拼起来,后来才意识到完全不是。这里说的是一张图、一个坐标系、一个X轴,只是每个数据系列可以各自选择自己的Y轴。
关键点在于那个唯一的X轴。它决定了所有柱子的分组对齐方式:X轴上列出课程名、月份或产品名,两个系列的柱子都按这套类目定位。无论左轴还是右轴,你画出来的柱子X坐标是一致的,只是Y方向分别映射到不同的刻度体系。在ECharts里,这个结构体现在配置上就是:
xAxis是一个普通对象,定义类目;yAxis变成数组,数组里第一项挂左侧轴,第二项挂右侧轴;- 每个
series用yAxisIndex: 0或yAxisIndex: 1指定自己用哪根轴。
理解了这个,就知道为什么说"公用X轴"是双Y轴图表最核心的骨架。如果自己用SVG硬画,最麻烦的就是两套数据的X坐标换算和柱宽分配,而这些ECharts内部已经处理好了,我们要做的只是在配置里把关系表达清楚。
1.3 双Y轴的误用边界:别让对比变成误导
我必须先泼一盆冷水:双Y轴不是万能的,它是可视化里出了名的"误导重灾区"。因为人眼天然会拿左右两边的柱子高度做对比,一旦量纲不同,这种对比本质上是不公平的。你画出来的意思是"两组数据在各自维度上的走势",但观众很容易理解成"左边比右边高,所以A比B大"。
所以我在实际项目中会给自己定三条规矩:
第一,量纲相同、量级接近的数据,坚决不用双Y轴,直接用分组柱状图,对比才严谨;
第二,只要上了双Y轴,左右轴名字必须写清楚,比如"选课人数(人)"和"平均分(分)",图例也保留,让读者一眼知道左右不是同一个单位;
第三,如果只是要对比两个序列的"趋势方向",更好的做法不是双轴,而是把两边数据各自做归一化(比如都转成0到100的指数),共用一根轴,这才是最不容易误导的做法。
明白了边界和数据基础,下面就可以开始动手了。
2. 最小可用配置:5分钟跑出你的第一张双轴柱状图
2.1 环境准备:从CDN到模块化引入
ECharts 是开源的,引入方式很灵活。做纯静态HTML演示,直接用CDN就够了:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="main" style="width: 900px; height: 500px;"></div> <script> // 后续配置代码写在这里 </script> </body> </html>如果是 Vite、Webpack 这类工程化项目,先npm install echarts,然后在文件顶部import * as echarts from 'echarts';即可。有人问过要不要按需引入来缩减体积,我的建议是:不是对包体积极度敏感的大屏项目,直接用全量引入,少给自己加负担。按需引入的写法在ECharts 5里略有变动,出错之后排查成本不值当。
容器div一定要有显式高度。宽度可以不写,但高度为0时,图表初始化不会报错,可你看不到任何东西,这个问题几乎每周都会有人问。
2.2 核心option解析:yAxis数组 + yAxisIndex绑定
我们用"每门课程选课人数与平均分"这个例子,一次把双轴柱状图跑通。左边轴放选课人数,右边轴放平均分:
const chart = echarts.init(document.getElementById('main')); const option = { tooltip: { trigger: 'axis' }, legend: { data: ['选课人数', '平均分'] }, grid: { left: 80, right: 80, top: 60, bottom: 40 }, xAxis: { type: 'category', data: ['高等数学', '大学英语', '数据结构', '线性代数', '大学物理', '体育'] }, yAxis: [ { type: 'value', name: '选课人数(人)', position: 'left', min: 0, splitNumber: 5 }, { type: 'value', name: '平均分(分)', position: 'right', min: 0, splitNumber: 5, splitLine: { show: false } } ], series: [ { name: '选课人数', type: 'bar', yAxisIndex: 0, data: [320, 280, 215, 260, 180, 240], barWidth: 20 }, { name: '平均分', type: 'bar', yAxisIndex: 1, data: [78.5, 81.2, 72.4, 88.6, 90.1, 76.3], barWidth: 20 } ] }; chart.setOption(option);这段代码的核心就三件事:
第一,yAxis从普通对象变成了数组,数组第二项position: 'right'把轴挂到右侧;
第二,第一个series用yAxisIndex: 0绑定左轴,第二个series用yAxisIndex: 1绑定右轴;
第三,两个柱子都指定了同样的barWidth,视觉上才会整齐。
这里最容易犯的错,就是第二个series忘写yAxisIndex。默认值是0,结果两组数据全画在左轴上,右轴空空荡荡,看起来就像所有柱子都叠在了一块儿。
右侧Y轴我额外加了一句splitLine: { show: false },目的是让右侧不画横向网格线。如果左右两边都画网格线,视觉上会非常吵;只保留左边的网格线,读数时参考线就够了。这也是一个从线上报表里学来的小优化。
2.3 数据加载与容器自适应:图不显示的三大原因
代码写完图表却不显示,十有八九是下面三种情况:
第一,容器高度为0。设了CSS高度但那个div在display:none的父容器里,初始化时拿不到实际高度和宽度。Vue里常见做法是:等数据加载完、容器渲染出来后再调init,或者初始化后补一次chart.resize()。
第二,容器宽度塌陷。有些页面布局用了flex/grid,图表容器本身没有宽度,子元素又被绝对定位,实测里也会出现"图只显示一半"。给容器一个明确的width,或者在window.resize时调用chart.resize(),基本能解决:
window.addEventListener('resize', function () { chart.resize(); });第三,初始化晚了。比如弹窗里的图表,弹窗打开前容器是display: none,打开后才显示,结果图表是黑的一片。我处理这类问题的方法是:弹窗打开、容器可见之后,再走一遍init + setOption,必要时直接chart.resize()。这个顺序问题只有踩过坑才会注意,文档不会告诉你。
3. 实测中翻车率最高的四个细节:刻度、间距、渐变和提示框
3.1 左右刻度段数不一致,零点不在一个水平线上
双Y轴图最常见、也最容易被忽略的翻车点,是左右轴的刻度"段数"不一致。
ECharts 默认会分别计算两根轴的 min、max 和间隔。左边0到400可能被切成4段,右边0到92可能被切成5段,于是两条轴的网格线物理高度完全不同——左边每格100,右边每格18.4。视觉上最直接的后果是:左右轴明明都在0点,但中间的横向网格线全部错开,图看起来像两个坐标系硬拼在一起,非常业余。
解法很朴素:手动把两侧的min都设为0,把splitNumber设为同一个值。比如左轴0到400、分5段,每段80;右轴0到100、分5段,每段20。只要左右轴在画布里高度一样、段数一样,网格线就会严格重合。这个"公用坐标系"的观感,就是靠这个简单的参数保证的。
如果数据不是整数、或者后续由接口返回,就写一个简单的取整函数:
function niceMax(values, step = 50) { const raw = Math.max(...values); return Math.ceil((raw * 1.1) / step) * step; } // 示例:平均分数组 [78.5, 81.2, 72.4, 88.6, 90.1] // niceMax 返回 100,用作右侧轴的 max右侧如果已经是百分比或百分制分数,直接把max固定成100更省事。柱状图从0开始是比较诚实的画法,建议两侧都从0起,不要为了"让柱子更高"把min设成非0值,那样会放大视觉差异,属于双Y轴里最容易误导别人的操作。
另外要注意,如果右侧轴数据上限是100,左侧是动态的,两侧splitNumber相同但每格代表的值不同,这没有关系,网格线依然重合,只是轴刻度上的数字不一样,这正是双Y轴存在的意义。
3.2 两个series都是柱状图时的挤压与间距问题
标题说的是"左右柱状图",也就是两个series的类型都是bar。这个配置下最容易出现的问题是:两个系列的柱子靠得太近或太远,宽度不一致,甚至挤成一团。
ECharts 处理多个bar系列时,默认会在同一类目下把柱子并排排开,并且自动计算每个柱子的宽度。问题在于"自动计算"的结果不一定好看:当两组数据量级差异很大时,一组柱子可能很粗、另一组可能很细,细的那根上面还挂着分布不均匀的空白。
我用的方案是给两个series都手动指定barWidth: 20,并调整barGap:
series: [ { name: '选课人数', type: 'bar', yAxisIndex: 0, data: [320, 280, 215, 260, 180, 240], barWidth: 20, barGap: '20%' }, { name: '平均分', type: 'bar', yAxisIndex: 1, data: [78.5, 81.2, 72.4, 88.6, 90.1, 76.3], barWidth: 20 } ]barGap是同一个类目下不同series柱子之间的间距,用百分比表示、相对柱宽计算。默认值是'30%',实测在双轴柱状图里,我习惯设成 '20%' 或 '10%',让左边柱子和右边柱子挨得近一点,形成"一组"的感觉,同时别完全贴住。
如果两个series数据量级差不多,也可以考虑不用双Y轴、用分组柱状图更合适;只要用了双轴,柱子宽度就必须手动统一,这是我踩过几次坑后固定下来的习惯。
3.3 渐变色柱子在双轴坐标下怎么设置才自然
做过数据大屏的人都知道,纯色柱子太朴素,渐变色几乎是刚需。ECharts里给柱子加渐变,用的是color的线性渐变写法,和左右轴没有直接关系,但方向要选对。
series: [ { name: '选课人数', type: 'bar', yAxisIndex: 0, itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: '#3d7ffc' }, { offset: 1, color: '#a8d0ff' } ] } } }, { name: '平均分', type: 'bar', yAxisIndex: 1, itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: '#ff9c4d' }, { offset: 1, color: '#ffd9ad' } ] } } } ]这里的 (0, 0, 0, 1) 表示渐变方向从上到下,柱顶颜色深、柱底颜色浅,这是大屏视觉上最常见的。有人喜欢反过来(柱底深、柱顶浅),把起点终点互换就行。
我在双轴图里有一个固定习惯:左边series用一个色系,右边series用另一个色系,而且饱和度拉开。比如左蓝右橙。因为左右共用一个图例和提示框,如果两个柱子都长得差不多,看久了很难分清谁是谁。具体用哪两个颜色,看你们公司的视觉规范,但"两个主色系区分开"这条建议,我强烈推荐。
3.4 tooltip与axisPointer:鼠标移到哪,数据跟到哪
热搜词里那句"鼠标点那儿在哪儿显示柱状图",翻译成ECharts配置就是tooltip和axisPointer的组合。
trigger: 'axis'表示鼠标悬停在某个类目范围时,把该X轴位置下的所有series数据一起弹出来;axisPointer.type: 'shadow'会在当前类目下铺一块半透明阴影,让你一眼看出鼠标正指向哪一根柱子。这两者配合起来,就是"跟随鼠标移动的高亮柱状图"效果。
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: function (params) { let html = params[0].axisValue + '<br/>'; params.forEach(function (item) { html += item.marker + item.seriesName + ':' + item.value + '<br/>'; }); return html; } }trigger: 'axis'下,formatter 的params是一个数组,数组里按series顺序包含当前类目下的每个数据项。上面的循环把左右两根柱子都拼进提示框。如果你的需求是鼠标精确悬停在柱体上才显示提示,那就把trigger改成'item',但那样就看不到"阴影跟随"的效果了,两者取舍看你业务场景。
还有一个小细节容易被忽略:当tooltip内容比较长、容器又靠近浏览器边缘时,提示框会被裁掉。给tooltip加上confine: true能避免这个尴尬。我在做窄屏报表时经常用到,属于文章里不写但实际一定会碰到的坑。
4. 从"能出图"到"好维护":轴范围联动、缩放交互与配置抽离
4.1 动态数据下的双轴对齐
真实项目里数据不会像demo一样写死,而是从接口返回。动态数据的双轴图,核心是把第3.1节的max计算变成自动化。
我的做法是写一个buildDualBarOption函数,把所有option的组装逻辑放进去,数据一变就重新调用:
function buildDualBarOption(categories, leftData, rightData, axisNames) { const maxLeft = niceMax(leftData); const maxRight = axisNames[1].indexOf('%') > -1 ? 100 : niceMax(rightData); return { xAxis: { type: 'category', data: categories }, yAxis: [ { type: 'value', name: axisNames[0], position: 'left', min: 0, max: maxLeft, splitNumber: 5 }, { type: 'value', name: axisNames[1], position: 'right', min: 0, max: maxRight, splitNumber: 5, splitLine: { show: false } } ], series: [ { name: axisNames[0], type: 'bar', yAxisIndex: 0, data: leftData, barWidth: 20 }, { name: axisNames[1], type: 'bar', yAxisIndex: 1, data: rightData, barWidth: 20 } ] }; }这里我只是想强调一个思路:把"比例关系"变成参数,而不是每次手算。轴的范围对齐了,后面不管数据变成什么样,图都不会散架。如果右侧是百分比,直接把max固定为100,省得niceMax算出一个101这种奇怪的值。
4.2 课程数据太多时的横向缩放:dataZoom
X轴类目一旦超过15个,所有柱子挤在一起就没法看了,比如要展示20或30门课程的对比。这时需要加数据缩放组件:
dataZoom: [ { type: 'slider', start: 0, end: 60, bottom: 10 }, { type: 'inside', start: 0, end: 60 } ]type: 'slider'会在图表底部生成一个可拖动的缩放条,type: 'inside'支持直接在图表区域里滚轮缩放。双Y轴图加dataZoom,默认作用于X轴,因为X轴才是类目轴,两端Y轴不会跟着缩放,正好符合需求。
网上有人把dataZoom配置到了yAxis上,结果拖动时左右轴也跟着动,数据错乱,这个我亲眼见过。记住:柱状图的场景里,dataZoom主要动X轴,不要动Y轴。
4.3 配置抽离成复用组件:一处定义、处处生成
最后一步,是把上面的逻辑封装成一个可复用的入口。我在项目里的做法很简单,暴露一个createDualBarChart函数,传入容器和数据即可:
function createDualBarChart(dom, categories, leftData, rightData, axisNames) { const chart = echarts.init(dom); chart.setOption(buildDualBarOption(categories, leftData, rightData, axisNames)); window.addEventListener('resize', function () { chart.resize(); }); return chart; } // 用的时候 const myChart = createDualBarChart( document.getElementById('courseChart'), ['高等数学', '大学英语', '数据结构'], [320, 280, 215], [78.5, 81.2, 72.4], ['选课人数(人)', '平均分(分)'] );如果是Vue或React这类单页应用,还要注意卸载时释放实例。切换路由回来时,同一个容器如果被再次init,控制台会提示容器已被初始化。稳妥的做法是在组件卸载时调用chart.dispose(),或者把chart实例存到组件实例上复用。这个问题不是ECharts的Bug,是单页应用生命周期管理的问题,但网上一搜一大片,说明踩的人不少。
封装完成之后,任何模块要生成双轴柱状图,都只是一次函数调用的事情。后续想统一调整配色、圆角、字体,只要改buildDualBarOption一个地方,所有图同步生效。这才是从"能出图"走到"好维护"的关键一步。
我在实际项目里用了很久双轴图,固定套路基本就是这套:两侧min从0起、splitNumber统一、barWidth一致、左右色系分离、tooltip配shadow。只要这五条做到,图基本不会翻车。唯一想再提醒一次的是:双轴图天生自带误导风险,上轴name、留图例、说明单位,这不是可选项,是必须项。数据汇报的时候,也别忘了口头提一句"左右两个轴的单位不一样"。这张图本身不难,难的是把它画得既清楚又诚实。希望这篇记录能帮你少走几步弯路。