1. 问题拆解:formatter到底缺什么参数
先把这个需求还原到真实场景里。我们用ECharts画图表,图例(legend)默认显示的是序列的name,比如“访问量”“转化率”这种静态文案。但产品常常会提一个需求:图例文字后面要带一个动态数值,比如“访问量 12,431”,或者“转化率 3.2%”,而且这个数字会随着数据刷新、指标切换、筛选条件变化实时更新。
第一反应往往会写成这样:
legend: { formatter: function (name) { return name + ' ' + myDynamicValue; } }然后在某个时刻给myDynamicValue赋新值,却发现图例纹丝不动,或者干脆报错说undefined。这就引出了问题的核心:formatter本质上是一个在渲染时被调用的回调函数,它不会自动感知外部变量的变化,也不存在“传参”机制。所谓的“动态传值”,实际上是在讨论如何让这个回调函数在每次执行时都能拿到最新的数据,这需要我们自己设计取值路径。
稍微看一眼ECharts源码里的调用逻辑就明白了。图例渲染时,ECharts内部会执行类似legendFormatter(name)的调用,唯一能稳定传入的就是当前图例项对应的序列名称name。也就是说,formatter的参数列表是框架定死的,我们无法通过formatter: function(name, myParam)这种方式多塞一个参数进去。所以解决方案都集中在“如何让formatter函数体内部,通过某种渠道访问到最新的动态值”。
我把做过的几个方案按照适用场景和踩坑程度排了个序:
| 方案 | 实现难度 | 适合场景 | 踩坑指数 |
|---|---|---|---|
| 闭包捕获 | 低 | 页面内一次性渲染,值变化频率低 | 中等 |
| 全局变量 / 模块级变量 | 极低 | 老项目临时救急 | 较高 |
| 直接从数据源(data)读取 | 低 | 动态值本身跟着数据走 | 低 |
| 组件实例挂载共享状态 | 中等 | 中大型项目、复杂状态管理 | 低 |
| 通过回调函数动态计算 | 低 | 值需要实时计算,不依赖缓存 | 低 |
下面逐个展开。
2. 最稳妥的做法:操作闭包与作用域链
2.1 引例一:把动态值“关”在函数里
闭包是前端绕不开的概念,也是最直接的“动态传值”手段。原理很简单:formatter函数定义在某个作用域A内部,它就能访问作用域A内的所有变量。只要这个变量在渲染时还活着,formatter就能读取到它的最新值。
let legendExtraData = { visits: 12431, conversion: 0.032 }; function createFormatter() { // 我们想要每次点击、每次刷新时,legendExtraData都是最新值 return function (name) { const extra = legendExtraData[name] ?? ''; return name + ' ' + extra; }; } // 在setOption的时候传入formatter chart.setOption({ legend: { formatter: createFormatter() } }); // 异步数据刷新 setTimeout(() => { legendExtraData.visits = 15000; // 只更新数据,不需要重复setOption legend配置 chart.setOption({ series: [{ data: [...] }] }); }, 2000);这个方案的关键在于:formatter函数在创建时引用了legendExtraData这个对象的引用,而不是具体的值。只要引用不变,后续对对象属性的修改都能被函数感知到。第一次setOption时,ECharts会把formatter函数存起来,每次渲染图例就执行一次,执行时读取的就是当前时刻legendExtraData里的数据。
2.2 闭包失效的典型场景
我见过不少同事在闭包这里翻车,典型错误长这样:
let dynamicValue = 100; legend: { formatter: function (name) { return name + ' ' + dynamicValue; } } // 数据更新后 dynamicValue = 200; chart.setOption({ series: [{ data: [...] }] }); // 图例没变!看到问题了吗?formatter函数并没有重新创建,它捕获的是第一次定义时的旧值?不对,实际上闭包捕获的是变量的引用,如果dynamicValue是基本类型且重新赋值,formatter内部通过作用域链拿到的是最新的值,理论上应该显示200。但为什么没变呢?
这里有个隐藏的坑:如果你在setOption的时候把整个option对象重新拼了一遍,然后里面的formatter函数是在setOption这次调用前重新定义的,那它捕获的确实是旧值;但如果formatter只定义了一次,ECharts内部可能因为合并策略保留了旧配置,压根没重新执行formatter。所以问题往往不是闭包本身失效,而是你写闭包的时机不对。
更规范的做法是:把动态值放进一个对象,或者放入class实例的属性里,然后通过一个稳定的引用去访问:
class ChartController { extraMap = {}; constructor() { this.extraMap = { visits: 0, conversion: 0 }; } updateData(newData) { this.extraMap.visits = newData.visits; chart.setOption({ series: [{ data: newData }] }); } getLegendFormatter() { return (name) => `${name} ${this.extraMap[name] ?? ''}`; } }这样保证this指向稳定,extraMap的引用不会变,formatter在任意时刻执行都能拿到最新值。实际开发中,不要让formatter依赖那些会被重新赋值的基本类型变量,尽量依赖对象属性或class实例的属性,这是最不容易出错的写法。
3. 更工程化的方案:从数据源拿值
3.1 在data里携带展示字段
很多时候,我们要显示的动态值其实就是数据本身的一部分。比如柱状图,图例对应的就是某一个系列,系列名称叫“访问量”,数据点上是具体的数字,你想在图例上显示“访问量 12431”,其实这个12431就是该系列最新的一个数据值。
这种情况最优雅的做法是:不要在外部存变量,直接在series的data里或者自定义结构里放一个字段,然后formatter从params里读取。
const seriesData = { name: '访问量', type: 'bar', // 这里用自定义字段,不影响正常数据索引 data: [ { value: 12431, legendLabel: '访问量 12,431' }, { value: 13500, legendLabel: '访问量 13,500' } ] };formatter怎么写?注意这个时候formatter收到的参数只有name,没法直接拿到seriesData对象。这里需要引入一个中间层:你可以把data定义在setOption的option对象外面,在formatter里通过这个引用去读取。
const legendLabelMap = { '访问量': '访问量 12,431', '转化率': '转化率 3.2%' }; legend: { formatter: (name) => legendLabelMap[name] || name; }那如果连legendLabelMap都不想维护呢?还有个思路是利用formatter第二参数是params这个特性。其实ECharts的legend.formatter在官方定义里只接收name参数,但实际运行时ECharts还会传入一个params对象,里面包含了一些图例项的信息,包括对应的seriesIndex。你可以通过seriesIndex找到series,再从series.data里拿自定义字段。
测试下来,可以用以下方式拿到series的引用:
legend: { formatter: (name, params) => { // params是一个对象,里面包含legendItem的信息 // 包括seriesIndex等 const idx = params?.seriesIndex; const targetSeries = myChart.getOption().series[idx]; // 从data的最后一项读取图例显示字段 const lastItem = targetSeries?.data?.[targetSeries.data.length - 1]; return `${name} ${lastItem?.displayText ?? ''}`; } }注意:params里的字段在不同ECharts版本中略有差异,有的版本有seriesIndex,有的版本只有seriesName,做的时候先在控制台打印看一下。而且每次用getOption()重新读取series数据,在频繁刷新时会有性能开销,所以这个方案适合数据量不大、刷新频率不高的场景。
3.2 从组件实例上挂载共享状态
如果你的项目是Vue或React,组件实例本身就是天然的状态容器。直接把动态值挂到实例上,formatter里通过this或实例引用去拿,这也是一种常见的做法。
以Vue为例:
export default { data() { return { chart: null, extraStats: { visits: 0, conversion: 0 } }; }, mounted() { this.chart = echarts.init(this.$refs.chart); this.renderChart(); }, methods: { renderChart() { this.chart.setOption({ legend: { formatter: (name) => { const stat = this.extraStats?.[name]; return stat !== undefined ? `${name} ${stat}` : name; } }, series: [{ type: 'bar', name: 'visits', data: [...] }] }); }, updateData(newStats) { this.extraStats = newStats; // 只更新series数据,legend配置不变 this.chart.setOption({ series: [{ data: newStats.data }] }); } } };这里有一个关键细节:Vue的响应式更新时,this.extraStats已经变了,而formatter读取的时候是从this.extraStats里读的。因为formatter函数被引用在ECharts内部,它每次执行时都动态访问this.extraStats,所以能拿到最新的值。这也是“动态传值”的本质——传的不是值,而是取值路径。
3.3 数据驱动更新时如何同步
还有个更隐蔽的需求:动态值是由外部数据源驱动,且更新频率较高,比如每5秒从接口拉一次数据,图例上要实时显示“当前人数 1042”“在线率 98.3%”。
此时如果每次都重新走一遍完整setOption,不是不行,但要注意ECharts内部会对option做合并,如果formatter函数是同一个引用,那它依然能读取到最新状态;如果每次setOption都重新传一个formatter函数,那么新函数引用的是最新状态,也没问题。关键在于避免把旧值缓存到某个变量里然后一直引用旧变量。
直接上推荐写法:
let latestStats = {}; function fetchData() { return fetch('/api/stats') .then(res => res.json()) .then(data => { latestStats = data.stats; refreshChart(data.chartData); }); } function refreshChart(chartData) { myChart.setOption({ legend: { formatter: (name) => { const stat = latestStats[name]; return stat ? `${name} ${stat}` : name; } }, series: [{ name: 'visits', type: 'line', data: chartData }] }); }关注点:latestStats被重新赋值了(latestStats = data.stats),表面上是一个基本引用替换,但formatter每次执行时是通过作用域链去查latestStats这个变量名,查到的是当前指向的对象,所以能拿到新值。这就是“取路径而不是取值”的核心思路,只要formatter函数在定义时引用了一个变量名(而非一个死值),每次执行时都会查找这个变量名的当前指向。
4. 完整实战:动态切换指标维度时图例跟着变
4.1 需求描述
用一个最常见的业务场景串一遍。我们做一个销售看板,左侧是“销售额”“订单量”“客单价”三个指标的可切换Tab,右侧是一个柱状图。切换指标时,柱状图的系列名称和数值都会变化,同时图例需要显示“销售额 ¥12,431”“订单量 345 单”“客单价 ¥356.8”。数值需要带单位,而且是实时从模拟接口拿的。
难点在于:图例名称是动态的,图例后面的数值是动态的,而且切换动作不能重置整个图表(不能闪烁、不能重新加载)。
4.2 完整代码
直接用原生HTML + ECharts写一个演示,方便你跑起来看效果。
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>动态图例formatter演示</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="chart" style="width: 600px; height: 400px;"></div> <div> <button>formatter: (name) => { const value = metric.value.toLocaleString(); return `${name} ${metric.unit}${value}`; }这个函数里用到的metric是谁?是render函数作用域里的const metric = metricMap[metricKey]。每次点击切换按钮后,render重新执行,重新创建了一个新formatter函数,这个新函数引用了新的metric对象。这没问题。
但再看定时器里做的异步刷新:
metric.value = Math.round(metric.value * 0.9); chart.setOption({ series: [{ data: [...] }] });这里面并没有重新设置legend.formatter,而是沿用上一次render时创建的函数。此时formatter闭包引用的是同一个metric对象(对象引用没变),所以当metric.value被修改后,下一次图例重新渲染时,formatter执行读取到的value就是新值。这就是闭包+对象引用的精妙之处:值可以变,但引用稳定,函数就能感知变化。
如果你不放心,可以在定时器里打印一下:
console.log(chart.getOption().legend[0].formatter);你会发现打印出来是一个function,和render里的那个函数指针一致。也就是说ECharts确实持有的是函数引用,执行时动态求值。
再想深一层:如果切换指标后,formatter显示的还是旧值怎么办?因为你render里重新定义了formatter,但是ECharts的setOption是合并式的,如果旧选项里已经有一个formatter,新选项里也传了formatter,新的覆盖旧的,所以没问题。但如果render里那句formatter: (name) => {...}被你省掉了,那么ECharts会保留旧的formatter,结果就是切换到“订单量”后,图例还是按“销售额”的格式去拼接,数值和单位都会错乱。这个细节特别容易踩,我做过一次就是因为复用setOption配置时漏传了formatter导致图例一直显示旧格式。
5. 踩坑记录与调试技巧
5.1 常见坑
这里把能踩的坑都列一遍,每条都是实际项目中遇到过的。
第一个坑:忘掉formatter不是响应式的
很多新手以为formatter是某种“绑定语法”,数据变了它自动跟着变。实际上ECharts只有在渲染图例的时候才会执行formatter,而图例的重新渲染取决于你有没有触发setOption或者resize。如果你改了外部变量的值,但没有触发任何setOption,那图例当然不会重新计算。解决思路:每次数据更新后至少调用一次chart.setOption({ series: [{ data: newData }] }),即使series数据没变,也可以设置一个空对象来触发重绘。
第二个坑:formatter里用了未定义的字段
formatter: (name, params) => { // params可能是undefined,直接读取属性就报错 return name + params.seriesIndex; }ECharts实际执行时确实会传入第二个参数,但这个参数的结构在不同版本中不一定一致,而且某些特殊图表(比如只有title没有series时)params可能不完整。稳妥做法是先做防空:
formatter: (name, params) => { const idx = params && params.seriesIndex !== undefined ? params.seriesIndex : -1; ... }第三个坑:把计算量大的逻辑塞进formatter
formatter会在图例项渲染时被调用,如果图例项多、刷新频繁,formatter里做了大量字符串拼接、数组遍历、甚至接口请求,会导致图表卡顿。建议把耗时的计算结果提前算好放到某个map里,formatter只做查表。
const labelCache = {}; function preComputeLabels() { Object.keys(metricMap).forEach(key => { const m = metricMap[key]; labelCache[m.name] = `${m.name} ${m.unit}${m.value.toLocaleString()}`; }); } legend: { formatter: (name) => labelCache[name] || name }第四个坑:混淆了series的name和legend图例项的name
当series数组里有多个系列时,legend的formatter收到的name是每个series的name。如果你动态修改了某个series的name(比如setOption里改了series[0].name),那么图例的显示也会跟着变,但formatter里对这个旧name的缓存就可能对不上了。调试时可以先打印name,确认它到底是什么。
5.2 调试方法
教你一个实用的调试套路。在formatter第一行直接打印,看每次调用的入参和当前动态值:
legend: { formatter: (name, params) => { // 调试用,确认执行时机和参数结构 console.log('formatter called', name, params, latestStats); return `${name} ${latestStats[name] ?? ''}`; } }然后你在控制台执行:
// 手动触发一次重绘,看formatter是否再次执行 chart.setOption({});如果formatter不执行,说明ECharts认为图例配置没变化,没有触发重绘逻辑。这时候可以考虑在更新数据时,临时加一个图例的配置变更来强制刷新:
chart.setOption({ legend: { // 用一个不存在但不会影响显示的属性触发重绘 selectedMode: 'single' } });不过这种花活少用,容易引入新问题。更靠谱的做法是确保数据更新必然走一次setOption,且这次setOption中包含了series的数据变化,ECharts检测到series变化后,会连带重新渲染图例。
5.3 性能提示
最后说一点性能相关的。如果图表数据量很大、图例项有几十个,并且你希望每次刷新时图例文本跟手更新,建议用节流或debounce包裹数据刷新逻辑,避免高频setOption把浏览器刷死。我自己做实时大屏的时候,通常是1秒才刷新一次数据,并且只更新series.data,让formatter函数保持同一个引用,这样性能最稳。
需要特别警惕的是:每次setOption都新建一个formatter函数并传入,旧函数会被垃圾回收,但ECharts内部可能还持有旧配置一段时间,短时间内频繁创建新函数,会带来不必要的内存开销和渲染抖动。最佳实践是:初始化时明确一个formatter函数,后续更新数据时不改formatter,只改series数据和外部状态。这也正是本文一开头强调的核心——formatter不应该被反复“传值”,而是通过稳定的引用路径去感知最新状态。
我在实际项目中,最省心的写法其实是把所有动态值收敛到一个统一的状态对象里,比如state.metrics,然后formatter里直接return name + ' ' + state.metrics[name] || ''。这个state对象无论在React的state里、Vue的data里,还是原生JS的一个普通对象里,都无所谓,只要你保证它的引用稳定,formatter每次执行时都能拿到最新值。说到底,“动态传值”的真相是:不直接传值,而是传递一条能随时拿到最新值的路径。把这个思路想明白了,后面不管遇到什么版本的ECharts、什么框架的封装,都能快速定位问题。