先说我碰到的一件真实的事。一个看板项目六条折线展示14天趋势需求评审的时候大家都觉得没什么不就是几根线吗。结果开发自测那天我打开页面差点没认出来这是同一个坐标系第3天、第6天、第8天的几个数值标签挤成一团销售额标签压住了访问量标签的一半转化率标签又正好卡在订单量标签和x轴刻度之间整个图表的右上角像被揉过的便利贴。这个问题就是典型的Echarts 多列折线图/柱状图标签遮挡。我花了两天时间把能用的方案全部捞了一遍后来总结出一套从排查到解决的完整套路。这篇不打算写成文档式的逐条整理而是按我实际的调试顺序来讲为什么会出现、哪些配置能直接缓解、什么时候该用labelLayout、什么时候干脆换一种呈现方式最后再放一个我实际用过的看板案例。给正在被标签拥挤折磨的同行一条捷径。1. 标签为什么会“打架”先看懂ECharts的标签渲染机制1.1 标签是“画”上去的不是“排”出来的很多人以为给series.label.show设为true之后ECharts 会自动帮我们做标签避让。这个理解在 5.0 之前是错的在 5.x 里也只有部分图表受labelLayout.hideOverlap控制。原因在于 ECharts 的渲染模型每个系列的标签都是独立计算锚点坐标然后以position指定的方向、偏移量直接绘制到 canvas 上的图形元素。它不像 HTML 的文档流两个标签碰撞后会被撑开或自动换行而是像在墙上贴便利贴——每张便利贴只知道自己该贴在哪个数据点旁边完全不知道旁边还有没有别的纸条。我刚开始排查时也踩过这个误区以为调一下grid边距、把图表塞宽一点就完事。结果发现标签不是跟着“绘图区”走的而是跟着“锚点”走的。锚点在哪里标签就在哪里。position: top就是锚点坐标上方固定偏移若干像素insideTop就是锚点内部上方。只要两个锚点靠得足够近、标签文字又足够宽它们就算隔着一个汉字的距离都能叠给你看。1.2 高频“打架”的三类场景根据我见过的项目标签遮挡基本逃不过这几种场景。多折线数值接近时。这是重灾区。比如两条折线分别代表“访问量”和“销售额”在第 5 天的值一个是 5200、一个是 5380坐标几乎重叠。两个标签一个往正上方跑、一个往正上方跑直接压在一起。如果你把 position 改成top和bottom交替又会出现新的问题底部的标签可能压到 x 轴刻度文字。多系列柱状图并排时。一个分类下有三根柱子柱间距本来就小标签又是“1,024,365”这种带千分位的长数字。position: top时标签宽度超过柱子本身横向就跑到隔壁柱子的地盘去了。坐标轴刻度标签和数据标签互相拥挤。尤其当 x 轴有十几二十个类目axisLabel设置了rotate或interval: 0后x 轴文字占掉大量纵向空间。此时数据标签如果还在柱子顶部两者之间的距离会被压缩到几像素视觉上就像“标签压着 x 轴标签”。我把这三类场景做成了一张快速自查表调试的时候对着看现象特征大概率原因切入方式两个或多个折线标签在同一坐标点附近重叠多系列锚点距离过近position 方向冲突条件显示 / position 交错标签横向超出柱子或图表边界标签文字长度超过有效空间富文本压缩 / formatter 缩写标签压到坐标轴刻度或图例grid 留白不足标签溢出绘图区containLabel / grid 边距调整标签相互遮挡但图形本身没越界没有全局避让机制labelLayout 或 tooltip 替代1.3 排查入口先把遮挡“拆开”看遇到标签打架不要直接套方案先用两分钟把问题拆出来。第一步全局搜索代码里的label配置把show全部临时设成false。如果图上只剩线和柱说明基础图形没问题。然后再逐个系列打开show: true记录是打开哪个系列时开始“沾”上其他元素的。我通常用二分法先开一半系列定位到具体是哪两个再单独调那两兄弟的 position。第二步调试时关掉动画。用option.animation false或者初始化时加一个参数。动画会在每次鼠标悬浮/数据更新时触发重排标签位置在运动过程中很难判断是否稳定关了动画看静态效果才靠谱。第三步判断这个标签到底“该不该存在”。有些图表标签纯粹是为了好看有些是为了精确读值。如果目标是精确读值完全可以让tooltip承担展示图上不标任何数字。这一条放到后面第 4 部分细讲。2. 从配置项下手给标签“排班”把冲突降下来2.1 用 formatter 做条件筛选只在关键点显示最省事的方案是“少显示”。一个折线图如果有 14 个点全部标数值确实信息全但读图的人根本看不过来。我们的视线只会被最大点、最小点、最后一个点吸引。所以formatter完全可以做成一个过滤器。label: { show: true, position: top, formatter: function (params) { var data params.seriesIndex 0 ? visitData : orderData; var isLast params.dataIndex data.length - 1; var isMax params.value Math.max.apply(null, data); var isMin params.value Math.min.apply(null, data); return isLast || isMax || isMin ? params.value : ; } }这个写法有两点值得说明。第一Math.max.apply只适合中小量数据几百个点的场景没问题数据量上万时建议先自己遍历一次存下最大最小值。第二formatter返回空字符串不代表标签占据的空间消失ECharts 在 5.x 里会跳过空文本的绘制所以不会留下占位放心用。我实际测试下来14 个点只显示 3 个标签折线之间的遮挡率从 100% 直接降到几乎为零。代价是用户不能一眼看到每个点的数值但这个空缺正好由 tooltip 补位。2.2 改变 position 策略按系列轮流“上下铺”当你想保留全部标签时条件显示的方案就不够了。这时可以考虑让不同系列标签显示在数据点的不同侧。series: seriesList.map(function (item, index) { return { ...item, label: { show: true, position: index % 2 0 ? top : bottom, formatter: {c} } }; });由于position支持传入回调函数而回调的入参params.seriesIndex可以拿到当前系列的下标所以我们在生成 series 时就可以交错设置。偶数系列标上面奇数系列标下面效果像上下铺至少两个系列互相压住的概率小很多。但这里有个坑如果两个系列的值在某个点非常接近且一个是 top 一个是 bottom标签不会重叠但视觉上会拉开一段距离读者很难把标签和线对应起来。我一般会搭配label.offset: [0, -10]或者dy做纵向微调让它们离锚点近一点、看起来更紧凑。2.3 富文本换行与截断把长指标标签压缩成短句柱状图最常见的遮挡是“数字太长”。一个柱子宽度可能只有 20 像素标签却是“1,024,365”这必然溢出。与其硬撑不如把数值改成缩写。label: { show: true, position: top, formatter: function (params) { var val params.value; if (val 1000000) { return (val / 1000000).toFixed(1) M; } if (val 1000) { return (val / 1000).toFixed(1) K; } return String(val); } }更复杂的场景可以用rich富文本。rich的好处是同一个标签内可以划分不同样式区域做成两行甚至三行都不破坏格式。label: { show: true, position: top, formatter: function (params) { return {a| params.seriesName }\n{b| params.value }; }, rich: { a: { color: #999, fontSize: 12, lineHeight: 18 }, b: { color: #333, fontSize: 14, lineHeight: 20 } } }第一行显示系列名第二行显示数值中间用\n换行。实际渲染时标签高度会比单行大所以要注意这也会压缩柱子顶部的空间最好配合下一节的 grid 调整。2.4 用 grid 和 containLabel 给标签留出“呼吸空间”当标签不是互相压而是压到坐标轴文字或容器边缘时问题就不在 label 本身而在绘图区的边界。建议所有图表都加上grid.containLabel: true。grid: { top: 60, right: 40, bottom: 50, left: 60, containLabel: true }containLabel: true的作用是让坐标轴刻度标签占据的空间被算进 grid 边界里避免“绘图区把坐标轴标签挤出图表”的怪相。但注意它管不到 series 的 label——是坐标轴标签不是数据标签。数据标签仍然可能溢出所以这套方案只解决“标签压轴”这类问题不能替代前面的 formatter 和 position 策略。3. 升级手段用 labelLayout 让标签自己找位置3.1 认识 ECharts 5 的 labelLayout如果你的项目用的是 ECharts 5.0 及以上版本那么有一个更“正经”的官方方案labelLayout。它是配置在 option 根节点上的一个属性可以对已经计算出来的标签位置做二次调整。官方文档给出的能力包括hideOverlap: true自动隐藏重叠标签align、x、y、dx、dy平移/对齐标签moveOverlap重叠时移动错开回调函数完全自定义布局逻辑最无脑的用法是全局加一行labelLayout: { hideOverlap: true }实测下来这个配置对折线图效果还行同一坐标值周围有多个标签重叠时会保留先渲染的那个、隐藏后渲染的。但当数据点密集时它隐藏的可能是你想保留的“最后一个点”或“最大值节点”因为它的隐藏判定跟渲染顺序有关不可控。3.2 自定义 labelLayout 回调把重叠标签“搓开”如果hideOverlap不够我们就自己写距离计算。目标很明确当同一 x 坐标下有多个系列标签时按顺序纵向推挤让它们错开。labelLayout: function (params) { var MAX_OFFSET 60; if (params.seriesType ! line) { return; } // params.rect 是数据图形的包围盒 // 以当前 dataIndex 生成纵向偏移量同一组点内每个系列偏移不同 var offsetY (params.dataIndex % 2 0 ? -1 : 1) * (params.dataIndex % 3 1) * 8; return { dy: offsetY MAX_OFFSET ? MAX_OFFSET : offsetY }; }这里的关键理解params.dataIndex在折线图里对应这条线的第几个数据点同一个 x 坐标上不同系列拥有相同的 dataIndex所以可以用dataIndex % n来做分组错峰。如果三个系列就按 0、1、2 分别偏移 0、8、16 像素。点多了之后偏移量会超出图表边界所以要设置上限。我不建议把 offset 值写得超过 20因为在折线图上标签如果离锚点太远读者会怀疑它是不是这根线上的数。3.3 labelLayout 在不同图表类型上的效果差异折线图和柱状图的锚点体系不一样labelLayout的表现也不太一样。折线图中标签锚点是数据点本身rect是一个像素级的点标签可以自由移动。柱状图中锚点是柱子的顶部或内部而且柱子还有宽度。比如三根柱子并排中间柱子的标签如果向上偏移太多两边柱子的标签就会“越界”跑到它头上。所以柱状图我更推荐用position: insideTop加“只对特定柱显示”的组合而不是依赖 labelLayout 做大幅偏移。还有一点labelLayout对stack堆积柱状图你是拿不到每个段的真实位置的因为堆积后每个柱段的坐标已经虚化强行偏移标签会出现错位。这种情况建议先把stack去掉再看。3.4 三种配置方案的适用性对比方案版本要求保留标签数量控制粒度代码复杂度formatter 条件显示4.x 可用少数据点级别凭 value/dataIndex 控制低position dy 交错4.x 可用全部系列级别低labelLayout 回调5.0全部标签包围盒级别可精确计算重叠中高从这个表能一眼看出来如果需要精确控制labelLayout 最灵活但也要接受它的调试成本。我一般建议先试 formatter 条件显示不够再加 labelLayout两者并不冲突。formatter 负责“减少标签”labelLayout 负责“摆放剩余的标签”。4. 换一种呈现方式改变视觉通道从源头消灭遮挡4.1 拆分坐标轴与多 grid 分面当系列多到一定程度任何避让策略都是治标不治本。比如一个图表里塞了 8 条折线数值范围从几百到几十万就算标签全错开图也乱得没法看。与其在一个坐标系里硬挤不如拆成多个 grid每个 grid 放一两条线各自有独立的 y 轴。grid: [ { left: 60, right: 40, top: 40, height: 120 }, { left: 60, right: 40, top: 200, height: 120 }, { left: 60, right: 40, top: 360, height: 120 } ], xAxis: [ { type: category, data: days, gridIndex: 0 }, { type: category, data: days, gridIndex: 1 }, { type: category, data: days, gridIndex: 2 } ], yAxis: [ { type: value, gridIndex: 0 }, { type: value, gridIndex: 1 }, { type: value, gridIndex: 2 } ], series: [ { name: 访问量, type: line, xAxisIndex: 0, yAxisIndex: 0, data: visitData }, { name: 订单量, type: line, xAxisIndex: 0, yAxisIndex: 0, data: orderData }, { name: 销售额, type: line, xAxisIndex: 1, yAxisIndex: 1, data: saleData }, { name: 转化率, type: line, xAxisIndex: 2, yAxisIndex: 2, data: rateData } ]这里有个细节多个 grid 共用同一个 x 轴的话如果 x 轴 class 相同需要显式设置gridIndex来绑定不然会报错或渲染到第一个 grid。y 轴同理。分面之后每个子图的点数变少标签空间变大而且系列之间的遮挡天然消失。代价是图表纵向变长大屏上如果高度有限这套方案就不适合。4.2 让 tooltip 承担全部读数功能这是我在大屏项目中最常用的方案图上默认不显示任何数值标签把所有读数交给 tooltip。配置如下tooltip: { trigger: axis, axisPointer: { type: cross } }, series: [ { // ... label: { show: false } } ]配合trigger: axis鼠标悬停在坐标系任意位置时x 轴同一个刻度下所有系列的值都会在 tooltip 里列出来。用户不需要在图上找某一条线的标签只需盯着一条垂直参考线所有数据都在悬浮框里。有些大屏项目没有鼠标交互可以做一个“定时轮播 tooltip”的常见技巧通过myChart.dispatchAction({ type: showTip, seriesIndex: 0, dataIndex: i })按固定间隔切换显示不同的数据点。这样连鼠标都省了数据照常读取而且静态截图永远不遮挡。4.3 大数据量场景抽稀与缩放数据点上千之后标签避让已经失去意义因为就算全部错开图上也会密密麻麻全是数字。这时要做的不是“避让”而是“降维”。在 series 上开启sampling: lttbECharts 会按最大趋势保留代表性数据点然后再让标签只显示聚合后的点。配合dataZoom让用户放大局部。series: { type: line, data: largeData, sampling: lttb, label: { show: true, position: top, formatter: function (params) { // 只显示被采样后仍在绘制区的点这里通过 params.dataIndex 判断 return params.dataIndex % 20 0 ? params.value : ; } } }, dataZoom: [ { type: inside, start: 0, end: 100 }, { type: slider, start: 0, end: 100 } ]注意sampling不会改变原始数据只是绘制时抽点。label的formatter拿到的是被采样绘制出来的点对应的值所以配合一个dataIndex % 20 0之类的条件可以把标签数量限制在极低水平几乎不可能遮挡。4.4 图例空间让位滚动图例与名称缩写很多人忽略legend也会挤压标签空间。当系列多到 7 个以上图例默认横排会把图表撑得非常扁数据标签被压缩。两个做法用legend: { type: scroll }让图例变成一个可滚动的条形区不占太多空间。给系列起短名字比如“PV”“UV”“GMV”全称放到tooltip里。图例只负责区分颜色不负责解释业务含义长名称让 tooltip 和图表标题去承载。这样又一次在“源头”减少了视觉元素标签自然就不挤了。5. 一个真实看板案例三线一柱的标签避让完整配置5.1 需求与数据口径用一个我实际做过的销售看板来收束前面所有方案。场景是一个电商运营团队的大屏展示最近 7 天的四个指标柱状图每日订单量单位单折线 1访问量单位人折线 2销售额单位万元折线 3转化率单位%这些指标量级差异很大访问量以万为单位销售额以万元为单位转化率只有百分之几直接共享一个 y 轴会导致转化率变成一条贴在 0 的水平线。所以这里用了双 y 轴柱状图和访问量共用左侧 y 轴销售额和转化率共用右侧 y 轴用yAxisIndex分别绑定。标签遮挡最容易出现在访问量和销售额两条线在某个点数值接近的时候。数据如下var days [周一, 周二, 周三, 周四, 周五, 周六, 周日]; var orderData [3200, 4100, 3800, 5200, 6100, 5800, 4900]; var visitData [8200, 9800, 9100, 12000, 14200, 13600, 11800]; var saleData [13.5, 18.2, 16.8, 24.5, 28.9, 26.3, 21.4]; var rateData [3.2, 4.1, 3.8, 5.2, 6.1, 5.8, 4.9];5.2 标签方案组合的取舍逻辑这个图我最终采用的组合是柱状图的label.show设为false原因7 根柱子全部标数值会跟左侧访问量折线的标签发生大面积重叠而订单量的精确值通过 tooltip 完全可以获取。访问量折线只显示最大值和最后一个点。最大值是本周业务的峰值最后一个点用于对齐“今天”的读数。销售额折线只显示最大值并且把数字格式化成“28.9 万”这种缩写。转化率折线全部隐藏标签因为它的量级小、线条贴近图表下半部标签会压到 x 轴刻度只用 tooltip 展示。组合逻辑很简单静态图上只保留最重要的 2-3 个信息锚点其余读数全部靠悬浮工具提示。这样既保证了图表干净又不丢失精确数值功能。5.3 完整配置与关键参数解读option { animation: false, tooltip: { trigger: axis, axisPointer: { type: cross } }, legend: { data: [订单量, 访问量, 销售额, 转化率], type: scroll, bottom: 10 }, grid: { top: 70, left: 60, right: 80, bottom: 60, containLabel: true }, xAxis: { type: category, data: days, axisLabel: { interval: 0 } }, yAxis: [ { type: value, name: 订单量/访问量, axisLabel: { formatter: function (val) { return val 10000 ? (val / 10000).toFixed(1) w : val; } } }, { type: value, name: 销售额/转化率, axisLabel: { formatter: function (val) { return val %; } } } ], series: [ { name: 订单量, type: bar, data: orderData, barWidth: 24, label: { show: false } }, { name: 访问量, type: line, yAxisIndex: 0, data: visitData, label: { show: true, position: top, formatter: function (params) { var isLast params.dataIndex visitData.length - 1; var isMax params.value Math.max.apply(null, visitData); return isLast || isMax ? params.value : ; }, backgroundColor: rgba(255,255,255,0.8), padding: [2, 6], borderRadius: 4 } }, { name: 销售额, type: line, yAxisIndex: 1, data: saleData, label: { show: true, position: top, formatter: function (params) { return params.value Math.max.apply(null, saleData) ? (params.value).toFixed(1) 万 : ; }, backgroundColor: rgba(255,255,255,0.8), padding: [2, 6], borderRadius: 4 } }, { name: 转化率, type: line, yAxisIndex: 1, data: rateData, label: { show: false }, lineStyle: { type: dashed } } ] };几个容易被忽略的参数grid.right: 80是为右侧 y 轴的“销售额/转化率”文字留空间不设够的话右侧标签会溢出容器。backgroundColor和padding给标签加了白底即使短暂覆盖在其他图形上视觉上也能通过底色“浮”出来这是我应对极端遮挡的兜底手段。axisLabel.formatter里把访问量缩写成带w的值减少了y 轴刻度的占位宽度变相给右侧标签腾出空间。5.4 调试过程与最终效果这个配置不是一次到位的。第一次我只加了 formatter 条件显示发现访问量最大值“14200”和销售额最大值“28.9万”虽然不重叠但销售额最大值标签底下的虚线转化率线刚好穿过去数字压在虚线上观感很差。于是我给销售额标签加了白底问题解决。第二次是右侧 y 轴刻度“6%”和销售额标签“28.9万”在周六那天离得太近。我的处理办法是把grid.right从 40 加到 80给右侧轴标签让出空间。记住一个原则标签避让不是只调整 series网格、坐标轴标签、图例位置都可以配合。最终图上静态标签只剩 3 个访问量最大值、访问量最后一天、销售额最大值。三条折线一条柱各自清晰互不干扰。放到大屏上截一张图也不会出现任何数字打架的尴尬。6. 踩坑记录与实践清单这些方案怎么选6.1 我实测过的坑坑 1formatter 返回空字符串的位置仍可能占位。我用的版本是 5.3.3空字符串会被跳过绘制但在 4.9 版本里标签渲染逻辑不一样有时会留下空白占位导致数据点错位。建议判断到条件不满足时返回后再用labelLayout或itemStyle同步确认或者干脆用show false控制不了单点就老老实实针对单点数据设置label: { show: true/false }。坑 24.x 版本里写labelLayout直接不生效。ECharts 5.0 才引入的配置老项目升级前不要以为只是多一行代码。如果还在用 4.x请用第 2 部分的 formatter position 方案不要与版本较劲。坑 3position 回调只有部分系列生效排查半天发现是series.type写错。折线图和柱状图的position可取的值不一样。柱状图值常见有top、insideTop、insideBottom折线图值常见有top、bottom、left、right。把insideTop用在折线图上有些版本会静默忽略行为非常难查。坑 4labelLayout.hideOverlap隐藏的标签不可控。它根据内部碰撞检测先后隐藏不保证你能保留的那个点一定幸存。如果必须突出某个点请用 formatter 手工指定显示。坑 5stack堆积图里标签位置错乱。当多个柱/线用stack: total堆积时每个段的 label 锚点基于堆积后的累计坐标计算。如果某个段是负值标签会出现在意想不到的位置标签互相压得比普通图更夸张。这时候我没找到特别好的全局解法基本只能靠 formatter 隐藏大部分标签或用tooltip读取总数。坑 6标签与emphasis状态叠加。ECharts 默认的emphasis会把标签放大加粗如果你在正常状态下做了交错偏移鼠标悬浮时 emphasis 的标签又会被重新计算出现“一悬浮就乱跳”的观感。遇到这种需要显式设置emphasis: { label: { show: false } }或把 label 的偏移逻辑同步到 emphasis 里。6.2 标签遮挡问题的决策路线我把处理标签遮挡的思路总结成一条决策链按顺序执行绝大多数项目都能在第三个节点前结束战斗确认是否必须静态展示所有数值。如果不是优先用 tooltip / 交互式读取。如果必须静态展示数据点是否少于 10 个是用 formatter 条件显示 position 交错。数据点较多但总量可控用 labelLayout 回调做向错峰。系列超过 5 个或量级差异大拆 grid 分面或多图组合。数据点上千配合 sampling dataZoom把标签变成“局部放大的特权”。6.3 我最后的几句真心话标签遮挡本质上是信息密度超过了一个坐标系的物理承载能力。这不是调一两个参数能彻底解决的它需要你决定这张图到底要给读者看什么。如果只是看趋势线就够了如果需要精确读值就交给鼠标和 tooltip如果既想好看又想信息全那就必须牺牲一部分“全”。我在之后的项目里已经养成了一个习惯写图之前先问自己三个问题——这个图有几条线、几个柱子、我需要读者第一眼看到的是什么。把这三个问题的答案写死在注释里再动手配 label。这样不仅能减少踩坑也能避免为“标签好看”而做一堆无效优化。标签遮挡从来都不仅是布局问题更是取舍问题。