如果你做过数据大屏,大概率会遇到这个场景:左侧 Y 轴是几百件的库存量,右侧 Y 轴是 0 到 1 的缺货率,两个轴共用同一条网格线。本来应该横平竖直的 splitLine,实际画出来却是左边一条、右边一条,错开几像素甚至完全交叉。网上搜 alignTicks,几乎所有答案都告诉你“加一个 alignTicks: true 就行”,我一开始也信了,结果项目用的 ECharts 版本压根不认这个配置,图表纹丝不动。因为没找到根因,我干脆自己写了一套刻度适配逻辑,通过重新设置每个 Y 轴的 min、max、splitNumber 来强制对齐,效果反而比官方方案更可控。这篇文章就把这段踩坑经历、适配思路和可直接复用的代码完整讲一遍,适合正在被 ECharts 双轴对齐问题折磨的开发者。
1. alignTicks 到底想解决什么问题,网格线为什么会对不齐
1.1 同一个图表里,左右轴在各自为政
先说清楚一个容易被忽略的事实:在 ECharts 里,每一个 Y 轴都是独立对象,默认情况下它们各自计算自己的 min、max 和 interval。这个计算过程在 ECharts 内部有一套“刻度美化”逻辑,也就是大家常说的 nice ticks。它会优先选择 0、5、10、20 这种看起来顺眼的间隔,而不是严格按数据范围均分。
举个例子,左轴数据范围是 0 到 100,右轴是 0 到 30。在图表高度有限的情况下,左轴可能被分成 5 段,右轴可能被分成 4 段。此时左轴第一条网格线的位置在从上往下 20% 的高度,右轴第一条网格线在 25% 的高度。两条线自然就错开了。更麻烦的是,如果左右轴的 splitNumber 恰好一样,但 interval 取值不同,比例位置也可能错开,因为网格线的位置取决于“当前刻度在整个轴内的分位”,而不只是间隔值。
网格线的本质是 splitLine,它沿着坐标轴刻度位置绘制。所以一切对齐问题,归根结底是刻度位置在像素层面不统一。ECharts 默认不会去协调多个轴的刻度,因为它认为每个轴都应该独立做 nice 算法,这样标签看起来才舒服。
1.2 alignTicks 的设计思路,以及它生效的前提
alignTicks 这个配置项的出现,就是为了解决“多个 Y 轴刻度不同步”的问题。官方示例里通常是这样的:多个 yAxis 共用一个 grid,再把每个 yAxis 都加上 alignTicks: true,ECharts 就会以第一个 yAxis 为基准,调整后面几个轴的刻度间隔和分割数量,让它们的 splitLine 尽量对齐。
这里有两个关键前提。第一个前提是,参与对齐的多个 Y 轴必须在同一个 grid 里,并且像素高度要一致。只有高度一致,比例对齐才有意义。如果你拆成多个 grid,还让每个 grid 的高度不一样,那 alignTicks 也只能干瞪眼。第二个前提是,官方实现不会改变每个轴的 min/max 数据范围,它更多是在 interval 和 splitNumber 层面做协调。也就是说,如果你的某个轴手动设置了特别不合理的 min、max、interval,官方算法也不一定能完全对齐。
alignTicks 本身不复杂,复杂的是它经常“静默失效”。新版 ECharts 里,配置写对了能看到效果;旧版里,这个配置被直接忽略,连警告都不给。这也是我后来不打算迷信官方配置,转而自己写适配的根本原因。
2. “版本需要 … 才可”:我的 alignTicks 为什么不生效
2.1 版本门槛不是玄学,是真有代码在控制
我的项目当时用的是 5.2.x,ECharts 官方文档里提到 alignTicks 需要较新版本才支持,社区里普遍的说法是 5.4.0 之后才算稳定可用。我最初想当然地以为“配置加上就行”,结果echarts.version一看是 5.2.8,这个版本里 yAxis 还没有完整的多轴协同逻辑,alignTicks 这个字段在内部被当成未知配置忽略了。
这不是玄学,而是不同版本对同一配置的解析能力不同。你可以先在浏览器控制台执行一下echarts.version,确认自己项目里实际运行的版本。很多项目表面上 package.json 写的是新版,但 node_modules 里可能被 lock 文件锁在旧版,或者 CDN 引用了多个版本的 echarts.js,后加载的覆盖了先加载的。建议直接在页面运行时检查版本,而不是只看依赖声明。
如果你的版本确实高于 5.4.0,那 alignTicks 大概率是能用的。如果你的版本和我一样卡在 5.x 早期,甚至还在用 4.x,那么对不齐就是正常现象。此时再纠结官方配置没意义,要么升级,要么和我一样手写适配。
2.2 除了版本,这几种情况也会让 alignTicks 静默失效
版本只是第一个坑,配置方式不对同样会让它形同虚设。我整理了一下项目里遇到和同事踩过的典型情况,供你逐项排查。
- 只给其中一个 Y 轴加了 alignTicks: true,另一个没加。官方示例里通常是每个需要对齐的 yAxis 对象都加上这个配置,而不是只加基准轴。
- 某个轴手动设置了 interval,但没有设置配套的 min/max。手动 interval 和 alignTicks 内部的自动调整逻辑是冲突的。
- 两个 Y 轴虽然挂在同一个 grid 下,但有两个单位量级差异巨大的 series,其中一个轴因为大量数据点被压扁,自动算出来的 splitNumber 和另一个轴差太多。
- 配置写在了错误的层级,比如写在 grid 里或 series 里,yAxis 没吃到这个属性。
- 图表使用 dataZoom 后,轴范围被重新计算,原先对齐好的刻度又乱了。
最让人头疼的是,alignTicks 不生效时不会抛异常,也不会打印 warning。它看起来是个合法属性,旧版解析到未知字段时直接跳过,你甚至检查配置对象都看不出问题。所以很多人在这一环节浪费大量时间,我当时也是反复检查配置、反复刷新,最后才确认是版本不支持。
3. 手写适配的核心思路:让 splitNumber 对齐,比让 interval 相同更重要
3.1 我最初踩的坑:老想让间隔值相等
刚开始手写适配的时候,我进了个误区,总想着让左右两个轴的 interval 完全一致。比如左轴 0 到 100,右轴 0 到 30,如果能找到一个公共间隔,两边都能整除,那网格线不就一样了吗?实际操作下来才发现,这个思路有两个致命问题。
第一个问题,寻找公共 interval 通常要牺牲数据的表达精度。左右轴数值范围不同,强行用一个间隔,必然有一边刻度过密或过疏。第二个问题,即使 interval 相同,如果两个轴的 min 起点不同,网格线在像素上依然可能是错位的。因为网格线的位置取决于刻度值在轴范围中的比例,而不是绝对的间隔值。
后面我重新想了一遍数学关系。两个 Y 轴如果共用同一个 grid,那么它们的 top、bottom 像素坐标是一样的,这意味着它们分别占据的像素高度 H 完全一致。这种情况下,只要两个轴都被分成 N 等份,那么第 k 条网格线的像素位置就是 top + H - kH/N。这个位置和轴的范围、间隔大小没有任何关系,只和 N 有关。
所以适配的核心很简单:让参与对齐的所有 Y 轴使用同一个 splitNumber,也就是同一个 N,同时保证每个轴范围内刚好有 N 段网格线。我不需要让 interval 相同,只需要让“分割数相同”这一件事成立,网格线自然就会在相同的比例位置重合。
3.2 确定轴范围时的 nice 化逻辑
既然要固定 N,那每个轴的 min、max、interval 就需要重新计算。我们不能直接拿原始数据的最小值和最大值当 axis 边界,画出来的刻度会很难看,比如 0.30000000000000004 这种。正确的做法是先计算一个合适的步长 step,再让 min 和 max 都是 step 的整数倍。
我这里引用了一个经典的 nice number 算法。它的思路是把一个数表示成 fraction × 10^exponent,然后把 fraction 取整到 1、2、5、10 这几个常用数字上。例如 range 是 11.4,想分成 5 段,每段大概是 2.28。2.28 可以按规则取到 2 或者 5。如果取 2,5 段只能覆盖 10,不够覆盖 11.4;这时候就要退一步,取更大的 nice 数。我通常在代码里先按 round 模式算一次,如果 step 乘上 N 仍然小于原始范围,就再用一次非 round 模式兜底,保证最终 max 能包住真实数据。
确定 step 之后,min 的调整方式是Math.floor(min / step) * step。这里用 floor 而不是 ceil,是为了保证 min 不高于原始最小值,避免数据被截断。max 不能直接用Math.ceil(max / step) * step,因为那样算出来的总跨度可能不是 step 的整数倍,实际段数就不一定是 N。更稳的办法是min + step * N,这样从 min 到 max 一定是 N 段,一根不多一根不少。
如果遇到 min 等于 max 的极端情况,比如某段时间内数据全是 0,直接计算会得到 0 跨度。建议先对范围做一次 padding,比如 0 的话就给 0 到 1,或者给 min 和 max 加一个 5% 的余量,再走同样的 nice 化流程。
4. 一个可直接抄的刻度对齐适配函数
4.1 完整代码实现
下面这个函数就是我在项目里实际使用的版本,核心流程是:先拿到每个 yAxis 对应的真实数据范围,再算出统一的 tickCount,最后通过 setOption 写回每个轴的 min、max、interval、splitNumber。代码没有依赖任何高版本特性,ECharts 4.x 也能跑。
function niceNum(range, round) { if (range === 0) return 1; const exponent = Math.floor(Math.log10(Math.abs(range))); const fraction = range / Math.pow(10, exponent); let niceFraction; if (round) { if (fraction < 1.5) niceFraction = 1; else if (fraction < 3) niceFraction = 2; else if (fraction < 7) niceFraction = 5; else niceFraction = 10; } else { if (fraction <= 1) niceFraction = 1; else if (fraction <= 2) niceFraction = 2; else if (fraction <= 5) niceFraction = 5; else niceFraction = 10; } return niceFraction * Math.pow(10, exponent); } function calcNiceTicks(min, max, tickCount) { let span = max - min; if (span < 0) { [min, max] = [max, min]; span = max - min; } if (span === 0) { const pad = Math.abs(min) * 0.05 || 1; min -= pad; max += pad; span = max - min; } let step = niceNum(span / tickCount, true); if (step * tickCount < span) { step = niceNum(span / tickCount, false); } min = Math.floor(min / step) * step; max = min + step * tickCount; // 避免浮点误差,比如 0.30000000000000004 step = Number(step.toFixed(6)); return { min, max, interval: step, tickCount }; }接下来是获取某个 yAxisIndex 对应的数据范围。这里只处理最常用的数组型 data,也包括 data 里每一项是数组的情况。如果你的 series 比较复杂,有大量 null 或者对象结构,可以按实际数据结构扩展。
function getAxisDataRange(chart, axisIndex) { const option = chart.getOption(); const allSeries = option.series; let min = Infinity; let max = -Infinity; if (!allSeries) return { min: 0, max: 1 }; allSeries.forEach((series) => { const seriesAxisIndex = series.yAxisIndex == null ? 0 : series.yAxisIndex; if (seriesAxisIndex !== axisIndex) return; if (!Array.isArray(series.data)) return; series.data.forEach((item) => { let value = item; if (Array.isArray(item)) { value = item[1]; } else if (item && typeof item === 'object' && 'value' in item) { value = item.value; } if (typeof value !== 'number' || Number.isNaN(value) || value === null) { return; } if (value < min) min = value; if (value > max) max = value; }); }); if (!Number.isFinite(min)) return { min: 0, max: 1 }; return { min, max }; }最后是对齐入口函数。它会将 yAxisIndexs 指定的所有轴重新计算范围和间隔。如果某个轴已经显式设置了 min/max,我会优先使用显式值,因为显式范围本身就不会变,我们只需要给它们设置统一的 tickCount 和 interval。
function alignAxisTicks(chart, yAxisIndexs = [0, 1], tickCount = 5) { const option = chart.getOption(); let yAxes = option.yAxis; if (!Array.isArray(yAxes)) { yAxes = yAxes ? [yAxes] : []; } const updatedYAxes = yAxes.map((axis, idx) => { if (!yAxisIndexs.includes(idx)) return axis; const explicitMin = typeof axis.min === 'number' ? axis.min : null; const explicitMax = typeof axis.max === 'number' ? axis.max : null; const dataRange = getAxisDataRange(chart, idx); const min0 = explicitMin != null ? explicitMin : dataRange.min; const max0 = explicitMax != null ? explicitMax : dataRange.max; const ticks = calcNiceTicks(min0, max0, tickCount); return Object.assign({}, axis, { min: ticks.min, max: ticks.max, interval: ticks.interval, splitNumber: tickCount, }); }); chart.setOption({ yAxis: updatedYAxes }); }这套代码看起来不少,但逻辑很线性:取范围、算 nice 间隔、写回配置。它不依赖 alignTicks,也不依赖内部算法,所以不受版本限制。
4.2 接入时机:setOption、resize、数据刷新都要管
这个函数怎么接入业务,才是实战里最容易出错的地方。很多人只在初始化后调用一次,结果数据刷新后又乱了,然后开始怀疑函数写得不对。
正确的接入方式是:每次你调用chart.setOption(rawOption)之后,紧接着调用一次alignAxisTicks(chart)。因为在数据更新后,轴的数据范围会变化,之前算好的 min、max 可能已经不对了。如果某些场景下 setOption 用了notMerge: true,那么之前写入的 yAxis 配置会被清掉,尤其要在之后重新执行对齐函数。
另外,resize 场景也建议处理。虽然 splitNumber 相同的情况下网格线在数学上一定对齐,但 resize 后 ECharts 可能重新执行内部刻度计算,如果你没有显式设置 interval,轴的真实分割数有可能悄悄变化。所以在窗口 resize 监听里,除了调用chart.resize(),最好也重新调用alignAxisTicks。这个函数的计算开销很小,不用担心性能。
我一般会封装一个统一的刷新函数:
function refreshChart(chart, rawOption) { chart.setOption(rawOption); alignAxisTicks(chart, [0, 1], 5); }也可以在ready回调里调用一次,保证首屏就是对齐的。
4.3 和新版本 alignTicks 的混合使用思路
如果你的 ECharts 版本已经支持 alignTicks,也不是说这个手写函数就没用了。我在升级后的项目里仍然保留它,把它作为 fallback 逻辑。
一种比较省心的做法是:在初始化时读echarts.version,如果版本足够新,就直接在每个 yAxis 上设置alignTicks: true,由官方算法处理;如果版本太旧,就调用alignAxisTicks。
const version = echarts.version; const versionNum = parseFloat(version); const canUseAlignTicks = versionNum >= 5.4; yAxis.forEach((axis) => { if (canUseAlignTicks) { axis.alignTicks = true; } });我个人其实更喜欢手写适配。官方 alignTicks 的目的是“尽量减少差异”,但不会改变每个轴原本的 min/max,这就导致在极端数据下,对齐效果依然是尽力而为。手写方案直接锁定 tickCount,会把所有轴强制变成统一分段,效果更稳定,也不受官方内部实现迭代的影响。
5. 实测中的异常现象和处理经验
5.1 常见异常和排查方向
我在联调过程中遇到过几种现象,这里整理成一张速查表,方便你对照。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 网格线仍然错位 | 调用适配后 interval 没有生效,可能被后续 setOption 覆盖 | 确认每次 setOption 后重新调用,不要用 notMerge |
| 网格线数量不对 | min、max、interval 三者不满足 N 段关系 | 检查 max 是否等于 min + interval * N |
| 顶部的线没了 | min、max 被 nice 化后,真实最大值超出 max | 确认 calcNiceTicks 的 step 覆盖了原始 span |
| 刻度标签出现超长小数 | interval 是浮点数,比如 1.4000000000000001 | 对 interval 做 toFixed(6),再用 axisLabel.formatter 收敛标签 |
| 部分数据点跑出坐标轴 | stack 堆叠系列只取了单条 data 范围 | 堆叠场景显式设置 min/max,或实现堆叠聚合逻辑 |
| 刷新后失效 | alignAxisTicks 没在 setOption 后重新调用 | 封装统一的 refreshChart 函数 |
| dataZoom 缩放后错位 | 缩放改变了轴的数据范围,旧配置未更新 | 监听 datazoom 事件,在事件回调里重新计算 |
如果你发现网格线只差一点点,但没完全对齐,可以先去chart.getOption().yAxis里看看每个轴的 splitNumber 和 interval。如果两个轴的 splitNumber 不相同,多半是某个轴的 interval 被 ECharts 内部重新计算了,导致实际分段数偏离我们设置的值。解决的办法就是显式设置 interval,并且保证它是(max - min) / N。
5.2 多 grid、堆叠柱状图、dataZoom 这些复杂情况
手写适配有一个明确的边界,它只适用于多个 Y 轴在同一个 grid 内的场景。如果你把两个 yAxis 放在了不同 grid 里,而且 grid 的 top 或 height 不一样,那无论如何计算刻度,像素位置都很难对齐。这种时候要做的是先统一布局,让 grid 的 top、height 一致,再用适配函数才有意义。
堆叠柱状图是另一个容易踩坑的地方。getAxisDataRange遍历的是每个 series 的 data,如果同一个类目下多个 series 做了 stack 堆叠,Y 轴实际最大值是所有 series 在该类目下的累加值,而不是其中某一个 series 的最大值。此时直接取单 series 范围,算出来的 max 会偏低,图表顶部的柱子可能被裁掉。
遇到这种情况,我的建议是不要在适配函数里猜堆叠逻辑,直接在 yAxis 上显式设置一个合理的安全范围,例如{ min: 0, max: 300 }。这样适配函数会优先使用显式值,也就绕开了 stack 数据聚合的问题。如果堆叠数据是动态的,那就只能写一个 stack 聚合函数,遍历所有 series,按 category 下标累加同一个 stack 名称的值,工作量会大一些,但原理不复杂。
dataZoom 场景也需要注意。轴的显示范围被 dataZoom 改变后,原本的 min、max 可能已经不再适配当前窗口。ECharts 的 dataZoom 虽然会重新计算轴范围,但不会重新调用我们手写的对齐逻辑。我一般在 option 里给 dataZoom 绑一个回调:
chart.on('datazoom', function () { alignAxisTicks(chart, [0, 1], 5); });最后提醒一个细节:如果右轴数值很接近 0,自适应 step 可能非常小,导致标签挤在一起。此时可以适当增大 tickCount,让网格线更疏一些,或者单独给那个轴设一个 formatter,比如只保留两位小数。轴上标签的长度不影响网格线位置,却会挤压图表绘图区,太长的标签建议用axisLabel.formatter缩短,而不是强行改 grid 宽度。
这套适配逻辑我后来一直留在项目里,哪怕升级到支持 alignTicks 的版本也没删,因为它的行为非常确定,不会在数据极端时突然失效。最后再分享一个经验:遇到这种网上“配置一下就行”的问题,先验证版本,再验证配置作用对象,不要第一时间怀疑是自己代码写错。ECharts 里很多功能都是版本敏感的,而 alignTicks 恰恰是最典型的一个。