1. 先选对trigger,tooltip的大方向才不会错
很多新手拿到echarts文档,第一步就跳去配formatter,结果配了半天发现鼠标悬停时要么不弹,要么弹出来的内容和预期完全不一样。我一般会先反问一句:你的trigger选的是什么?trigger是tooltip的触发策略,它决定了tooltip在什么情况下出现、一次出现会展示哪些数据,也直接决定了后面formatter回调函数拿到的参数到底是对象还是数组。这几样东西一旦选错,后面怎么写都是错的。
1.1 item触发:适合“点对点”查看的图表
trigger: 'item'表示当鼠标悬停到具体的图形元素上时才触发tooltip。典型的场景包括饼图的某个扇区、地图上的某个区域、散点图里的某个散点,以及折线图上的某个数据点。拿饼图举例,鼠标滑到“华东区域”这一块时,tooltip只显示“华东区域”对应的数值和百分比,不会把旁边“华南区域”的数据也带进来。
这其实非常符合人类阅读习惯:饼图和地图本身是“看局部”的图表,用户关心的是“这一块是什么、占比多少”。如果在这种图表上强行用axis触发,效果反而很怪,因为你没有坐标轴可供联动。item触发的formatter参数相对简单,回调里拿到的params是单个对象,直接访问params.name、params.value、params.percent就能拼出想要的内容。
1.2 axis触发:多系列同屏对比时的首选
trigger: 'axis'则是沿着坐标轴方向触发。只要鼠标停留在绘图区某个位置,echarts会自动把该位置对应的类目或坐标值下的所有系列数据全部取出来,按系列排列显示。最常见的例子就是折线图和柱状图,尤其是那种一条图里有“本周销售额”“上周销售额”两条折线,x轴是周一到周日的情况。
鼠标移到周三这块区域时,axis触发的tooltip会同时显示本周三和上周三的销售额,用户不需要把鼠标精确移到某条线上,对比起来效率高很多。反过来,如果在这种多系列对比图中用item触发,用户就得手动hover两条折线才能凑齐信息,体验差得不是一点半点。所以选择trigger的本质,是看你希望用户用tooltip来“看单个点”还是“看一组关联数据”。
1.3 trigger直接决定了formatter参数的“形态”
这一点我觉得是最值得先了解的。同一个formatter函数,在item触发下params是一个普通对象,比如:
{ componentType: 'series', seriesType: 'line', seriesName: '本周销售额', name: '周三', value: 12000, color: '#5470c6', dataIndex: 3, data: { ... } }但换成axis触发之后,params变成了数组,数组里每一项对应一个series当前的数据节点。如果你拿写item触发的逻辑去处理axis触发,比如直接访问params.seriesName,一定会拿到undefined,因为数组根本没有seriesName这个属性。所以我平时写formatter的第一步就是做一次统一:
formatter: function (params) { const list = Array.isArray(params) ? params : [params]; // 到这里统一按数组处理 }这样不管配置哪种trigger都能兼容,后面写逻辑也不用担心数据类型炸掉。如果团队里有人喜欢把tooltip封装成公共配置,这一行判断能免掉大量“为什么我这个图表不显示”的排查时间。
2. formatter写不对,tooltip等于白弹
formatter是tooltip用来生成内容的“渲染函数”,也是整个tooltip配置里最能折腾的地方。它的作用就是决定弹窗里最终显示什么字符串、什么HTML结构。echarts官方给了两种方式:模板字符串和回调函数,两种方式各有适用场景,我在项目里基本都用回调,因为灵活度更高。
2.1 模板字符串里的a、b、c、d怎么用
如果你图省事,模板字符串是最快的。最常用的几个占位符如下:
| 占位符 | 含义 | 典型来源 |
|---|---|---|
| {a} | 系列名称 seriesName | 多条折线图的图例名 |
| {b} | 数据名称,一般是类目名或扇区名 | 饼图扇区、x轴刻度 |
| {c} | 数据值 | series里的value |
| {d} | 百分比,饼图里有实际意义 | 饼图扇区占比 |
举个实际例子,饼图的tooltip如果要用“名称+数值+百分比”,一行模板就够了:
tooltip: { trigger: 'item', formatter: '{b}:{c} 人({d}%)' }这个配置的效果就是弹出“华东区域:3200 人(32%)”,简洁明了。折线图如果用axis触发,也可以靠{a}、{c}把每个系列的数值循环渲染出来。但我要提醒一句:模板字符串适合内容格式非常固定的场景,一旦涉及条件判断、单位换算、日期格式化,模板字符串就不够用了,得换回调函数。
2.2 回调函数:先判断参数,再拼HTML
回调函数的写法是:
formatter: function (params) { return `自定义内容`; }很多场景下,我们需要给tooltip里的每一项加上系列对应的颜色小圆点,让用户能通过颜色对应到图例。在回调函数里可以直接使用params.marker,它就是带html标签的彩色小圆点。我经常用它拼一个比较“正规”的tooltip:
formatter: function (params) { const list = Array.isArray(params) ? params : [params]; let html = '<div style="font-weight:600;margin-bottom:6px;">' + (list[0].axisValue || list[0].name) + '</div>'; list.forEach(function (item) { html += '<div>' + item.marker + ' ' + item.seriesName + ':' + item.value + ' 台</div>'; }); return html; }这段代码在折线图、柱状图里效果很稳:第一行显示类目(比如“周三”),下面每行一个系列,带对应颜色圆点,数值统一带上单位“台”。不过在做这个拼接的时候有个小细节:item触发时params不是数组,所以先用Array.isArray判断包成数组;list[0].axisValue在item触发时不一定存在,所以要加个或条件,回退到list[0].name。这种边缘情况在真实项目里非常常见,多写一步能少踩一个坑。
2.3 项目里我会怎么封装一个通用formatter
业务中一般不会只画一个图表,所以我习惯提取一个通用函数:
function tooltipFormatter(prefix = '', suffix = '') { return function (params) { const list = Array.isArray(params) ? params : [params]; const rows = list.map(function (item) { let value = item.value; // value可能是数组,比如箱线图、散点图,只取最后一个维度 if (Array.isArray(value)) { value = value[value.length - 1]; } const num = Number(value); const display = isNaN(num) ? value : num.toLocaleString('zh-CN'); return item.marker + ' ' + item.seriesName + ':' + prefix + display + suffix; }); return rows.join('<br/>'); }; }用的时候只要这样挂上去:
tooltip: { trigger: 'axis', formatter: tooltipFormatter('¥', '万元') }这类封装最大的好处是,不同图表只要传不同的前缀后缀,就能复用一套逻辑,不用每个option里都重写一遍formatter。等后面客户要求“数值超过一万显示成1.2万”“日期转成YYYY-MM-DD”时,也只需要改动这一处方法,不用逐个图表去找。还有一点值得养成习惯:formatter里尽量不要写太重的DOM操作,也不要去修改图表外的全局变量,不然鼠标一移动,触发频率一高,很容易卡顿。
3. 把tooltip当成一个小型UI组件来调
tooltip的内容搞定了,下面就是长相。默认样式真的是“能用就行”,放到正式项目里基本都会被吐槽。好在样式相关的配置项都是明明白白摆在那里的,照着调就行。
3.1 最常用的样式参数
| 配置项 | 说明 | 推荐值 |
|---|---|---|
| backgroundColor | 背景色 | rgba(255,255,255,0.96) |
| borderColor | 边框颜色 | '#409EFF' |
| borderWidth | 边框宽度 | 1 |
| padding | 内边距 | [12, 16] |
| textStyle.color | 文字颜色 | '#333' |
| textStyle.fontSize | 文字大小 | 12或14 |
| textStyle.fontWeight | 字重 | 500 |
一套典型的定制化tooltip配置长这样:
tooltip: { trigger: 'axis', backgroundColor: 'rgba(255,255,255,0.96)', borderColor: '#409EFF', borderWidth: 1, padding: [12, 16], textStyle: { color: '#333', fontSize: 14, fontWeight: 500 } }配上前面写的formatter,弹窗的观感立刻和页面统一起来。这里我想多说一句backgroundColor:很多人喜欢用纯白,但在浅色背景的页面上,rgba(255,255,255,0.9)这种半透明会更柔和,也不会完全挡住底下的图表。如果你做的是深色大屏,可以换成rgba(20,30,50,0.9),配合白色的textStyle,整体质感会好很多。
3.2 extraCssText是“补丁神器”
有些效果用常规配置项做不出来,比如圆角、阴影、投影、最大宽度。官方留了个后门:extraCssText。这个字段会把额外的CSS字符串直接拼接到tooltip容器上。
tooltip: { ...其他配置, extraCssText: 'box-shadow: 0 4px 12px rgba(0,0,0,0.15); border-radius: 8px; max-width: 280px;' }我经常在数据大屏里靠它实现“阴影+圆角+限宽”,比去翻源码改样式类方便多了。要注意的是,extraCssText里的CSS优先级不一定永远高于其他配置项,如果遇到冲突,可以再加!important。另外,如果设置了appendToBody,tooltip容器被挪到body下面,此时受全局样式影响的可能性会增加,extraCssText这时候也成了兜底方案,尽量把该写的都写全。
3.3 位置定位:position、confine、appendToBody
tooltip默认跟手走,但有时我们需要让它固定在某一个位置,或者防止它在图表边缘被切掉一半。这里面有三个配置最常被问到。
先看position,它是函数时可以拿到当前鼠标位置,然后返回一个坐标数组:
tooltip: { position: function (point, params, dom, rect, size) { // point就是鼠标位置,比如 [120, 300] return [point[0] + 10, point[1] + 10]; } }再看confine,设置true时,tooltip会被限制在图表容器内部,不会跑到容器外面去。页面边缘的图表很需要它,不然弹窗可能一半露在外面,观感很差。最后是appendToBody,这个配置项在echarts 5.x里支持。当图表被放在overflow: hidden的容器或者某些弹窗、横向滚动容器里时,tooltip默认依附于图表容器,很可能被裁剪掉。设置appendToBody: true后,tooltip的DOM会被挪到body下,一般能绕开裁剪问题。代价是它的定位逻辑不再和图表容器百分百绑定,在复杂滚动场景里可能出现偏移,需要自己再调一调。
3.4 enterable:让用户能悬停在tooltip上
这个配置项知道的人不多,但很实用。enterable: true允许鼠标从图形上挪进tooltip内部而不关闭。如果tooltip内部放了可复制的内容、链接或者表单控件,就必须把它设成true。默认是false,鼠标一离开图形tooltip就消失了,想复制一段数值都难。我最早做报表的时候,客户想复制tooltip里的一组订单号,怎么都复制不了,后来查文档才发现是enterable的问题。设成true之后再配合transitionDuration: 0.2这种过渡时间,体验会好很多。
4. 折线图、饼图、地图,tooltip各有各的套路
很多人在网上搜“echarts tooltip自定义”,搜到的都是一张折线图的例子,套到饼图或地图上就失灵了。其实不是代码错,而是不同图表形态对tooltip的需求本来就不一样,需要针对性调整。
4.1 折线图和柱状图:轴模式加多系列展示
折线图和柱状图的data通常都是数组,x轴有类目,series有多个。最重要的两个点:一是trigger要用axis,二是formatter里别只显示一个series,否则用户看多系列对比图还得一个个hover,体验很割裂。
我做一个用户增长报表时,折线图的tooltip就处理成多行结构:
tooltip: { trigger: 'axis', confine: true, formatter: function (params) { const date = params[0].axisValue; let html = '<div style="margin-bottom:6px;"><b>' + date + '</b></div>'; params.forEach(function (item) { html += '<div style="display:flex;align-items:center;gap:6px;line-height:1.8;">' + item.marker + ' ' + item.seriesName + ':' + item.value + '</div>'; }); return html; } }如果series特别多,建议在item.marker后面用style控制行间距,否则弹窗会非常挤。行数多到超出容器时,配合extraCssText里的max-height和overflow-y:auto,还能做出可滚动tooltip,这个技巧在做监控大盘时尤其好用,因为同一时间点上的指标线路可能很多,可滚动tooltip能避免弹窗占满整个屏幕。
4.2 饼图与环形图:数值和占比都不缺
饼图的核心信息是占比,所以formatter里至少要有名称、数值、百分比三项。模板字符串是'{b}:{c}({d}%)',回调函数则用params.percent。要注意percent显示的小数位数不是固定的,如果你希望固定到一位小数,可以自己在回调里算:
formatter: function (params) { const total = 10000; // 这个值从series.data求和得到 const pct = ((params.value / total) * 100).toFixed(1); return params.name + '<br/>数值:' + params.value + '<br/>占比:' + pct + '%'; }环形图还有个常见需求:鼠标放到中间空洞上方时不触发tooltip,因为那个区域没有任何数据,弹一个空白tooltip出来很尴尬。这可以在series里设置silent: true,或者用graphic元素把中间区域挡住,看具体需求选。我记得有个比较笨的写法是在formatter里判断params.dataIndex === undefined就返回空字符串,也能达到隐藏效果,但不如silent干净。
4.3 地图场景:空数据地区不能显示undefined
地图系列用tooltip也很频繁,首先要保证trigger是item,其次formatter要处理一个特殊情况:某个区域没有数据时,value可能是null或undefined。我见过不少页面在hover到无数据地区时,tooltip弹出“某地区:undefined”,瞬间暴露没做兜底。
tooltip: { trigger: 'item', formatter: function (params) { if (params.value == null) { return params.name + ':暂无数据'; } return params.name + '<br/>数值:' + params.value; } }另外,如果是用geo组件加series.map的组合,需要注意geo自带的tooltip和series的tooltip不要同时开启,不然可能同时弹出两个弹窗。我的惯例是只在series.map上配tooltip,geo只负责展示地图底色和边框,这样逻辑清晰,也好排查问题。注册地图数据时,如果用的是自己整理的GeoJSON,还要检查一下区域名是否和series.data里的name完全一致,不然tooltip里的name会显示成undefined之外的异常内容。
4.4 散点图和热力图:注意value是数组
散点图和热力图的数据项常常是[x, y, size]这种数组结构。这时候如果直接在formatter里拿params.value拼字符串,会拼出“12, 34, 56”这种难看的样式。我一般把取数逻辑剥离出来:
function getDisplayValue(value) { if (Array.isArray(value)) { return value[value.length - 1]; } return value; }严格来说,箱线图也会遇到类似情况。自己封装formatter的时候统一处理一下,能省很多后续麻烦。比如在散点图里,数据点的value可能是[15, 20],但用户想看的是最终的数值20,而不是整个坐标数组,如果不做提取,tooltip看起来就是“某系列:15,20”,非常不专业。
5. 实战里绕不开的四个tooltip坑
前几节讲的是配置方法,紧跟着我把这几年踩过的坑整理一遍,基本都是“不遇到会懵,遇到后恍然大悟”的类型,写出来帮大家少走点弯路。
5.1 长文本为什么不换行
tooltip默认使用的是HTML渲染,其实只要在formatter里拼
就能换行。但有时候文本包含空格,或者写进了一些样式,结果不管加多少
都挤在一行里。这种情况多半是extraCssText或全局样式把white-space改成了nowrap。解决办法是在extraCssText里加white-space: normal; word-break: break-all;。如果还是不行,检查页面里是不是有全局样式统一设置了div的white-space或word-break,因为appendToBody之后,tooltip容器不在图表内部,更容易被全局样式误伤。
5.2 tooltip跑出屏幕外,一半看不见
位置在页面右侧或底部的图表,默认tooltip很可能会超出视口,看起来就像被切了一刀。最简单的两个方案:一是设置confine: true,让tooltip限制在图表区域内;二是用position回调自己算位置。我自己更常用confine,它不用操心底层计算,图表容器本来也就是一块占位div,范围完全够用。只有在需要工具提示严格跟随鼠标移动时,才会用position回调,比如:
position: function (point) { const x = point[0]; const y = point[1]; return { left: x + 10, top: y + 10 }; }要提醒的是,position回调返回的坐标是相对图表容器的,不是相对页面的。如果页面是滚动型布局,位置会跟随滚动发生变化,需要结合容器当前所在位置重新计算。这点在大屏页面里尤其突出,因为大屏通常有滚动或多层嵌套,不仔细算的话,tooltip很容易漂移。
5.3 大屏缩放时,tooltip字号不跟着rem走
很多人在大屏项目里用过pxtorem或者transform: scale来做整体缩放,图表本身处理得很完美,唯独tooltip里的文字字号固死了,和整个页面比例不协调。原因是echarts的tooltip DOM虽然由canvas生成,但最终样式会被容器或默认样式覆盖,而canvas内部字体用的是像素值,和rem没有联动关系。
解决办法不复杂:在tooltip.textStyle.fontSize里写一个固定像素值,而不是依赖rem。如果大屏是按设计稿比例缩放的,可以直接根据缩放比动态设置fontSize。比如设计稿宽度是1920,当前窗口宽度是1440,那就乘个0.75。另一个做法是每次resize时把tooltip销毁重建,但这种做法会有闪烁效果,成本也高,我一般不推荐,除非项目里暂时没有别的办法。实际项目里,我更推荐一个字号换算工具函数,统一传给各图表的tooltip,比在每个配置里手写要省心。
5.4 tooltip刷新不及时或卡顿
动态更新数据时,我见过有人反复调用chart.setOption(option)传整个option,结果tooltip里的数值不更新,或者鼠标稍微动一下就卡一下。这通常不是tooltip配置的问题,而是setOption方式太粗暴。正确做法是初始化时传完整option,后续更新只传变化的部分:
chart.setOption({ series: [{ data: newData }] });这样tooltip会随着series数据更新自动重新绑定,性能也好很多。如果某些场景必须重新设置整个option,可以先chart.clear()再重新init,避免旧配置残留。这类残留问题很隐蔽,表现形式是数据明明变了,但tooltip还显示上一轮的旧值。
还有一类卡顿出现在数据量特别大的折线图里。tooltip在鼠标移动时会频繁触发formatter,如果formatter内部每次都做复杂的字符串拼接或DOM查询,可能拖慢帧率。我的习惯是把单位换算、日期格式化提前算好放在data里,formatter只做读取和拼接,尽量减少计算量。真要面对几十万数据的极端场景,还可以考虑开启sampling或减少tooltip的showDelay,但那些属于性能调优的进阶话题了。
最后分享一个调试技巧:调整tooltip样式时,就别一遍遍hover触发了,直接把alwaysShowContent: true开起来,它会让你不移动鼠标也能看到tooltip常驻在页面上,改样式秒级见效。等调完确认效果后再关掉,调试效率能翻一倍。