最近做数据大屏,碰到一个被产品经理反复追问的需求:地图和散点图上那些点位,能不能让重点点位“闪起来”?这个需求听起来很简单,但真正落到 ECharts 上,远不是给 series 加个 animation 就能搞定的。折腾了小一周,把 effectScatter、定时器驱动的动态 setOption、分层渲染的路子都试了一遍,这里把整个过程整理成一篇实战笔记,正在做数据可视化特效散点图点动态闪烁效果的同学可以直接拿走参考。
1. 先搞清楚:产品说的“闪烁”到底是哪一种
1.1 三种典型需求场景
做可视化特效,最怕的就是需求一句话,理解各不同。同样是“让点闪烁”,放在不同业务里,视觉语言完全不一样。
第一种:定位提醒型。最常见的场景,比如设备告警、人员位置、物流轨迹。地图上飘着一个点,需要让用户一眼看到“这个位置有问题”或“这里有东西”。这种闪烁的本质是“吸引注意”,视觉上适合用涟漪扩散、雷达扫描这一类向外扩散的效果。
第二种:状态脉冲型。比如监控大屏上的在线设备,要表现出“这个点活着、在跳动”。它的视觉特征是点自身按一定频率变亮变暗,像呼吸灯一样。这种闪烁不需要扩散波纹,需要的是点在缩放、明暗之间做周期变化。
第三种:交互聚焦型。用户鼠标移到点上、或者点击某个点时,这个点需要高亮闪烁,其它点保持安静。这种闪烁更像“反馈”,告诉用户你选中了谁。
我这次接到的需求是第一种和第二种的结合:大屏上有两三百个点位,其中十几个重点点位需要以“波纹扩散 + 点体明暗”的方式持续闪烁。搞清楚具体要哪种效果之后,选型思路就清楚了:纯手工用定时器去驱动每个点,性能上基本不可控;用 ECharts 自带的effectScatter先做一层,再补充脉冲逻辑,才是正路。
1.2 需求拆解:两句话逼出技术边界
把需求翻译成技术方案时,有几句话一定要问清楚,这和后面所有代码直接相关:
- “闪烁”是点自己闪,还是产生向外扩散的波纹?
- 闪烁是自动循环播,还是鼠标移上去才触发?
- 闪烁频率要求多快?有没有“别太急促”这种模糊描述?
这三句话决定了你是用effectScatter、showEffectOn,还是自己写定时器。我见过不少同事拿到需求就直接上effectScatter,结果产品说“不对,我要的是那种亮暗呼吸的效果”,其实就是没先对齐语义。所以第一步永远是拆需求,而不是写代码。
2. effectScatter 才是先要试的原生方案
2.1 最小可运行配置
ECharts 里其实专门有一个 series 类型就叫effectScatter,官方文档叫“带有涟漪特效动画的散点图”。它就是为“让点动起来”设计的,所以在绝大多数场景下用它,不要自己造轮子。
先看最小配置:
option = { xAxis: { type: 'value' }, yAxis: { type: 'value' }, series: [{ type: 'effectScatter', data: [[103, 235], [120, 180], [140, 210]], symbolSize: 12, rippleEffect: { period: 3, scale: 2.5, brushType: 'stroke' }, itemStyle: { color: '#00e0ff', shadowBlur: 8, shadowColor: '#00e0ff' } }] };这段配置跑起来,三个点上会不断向外扩散一圈圈波纹,看起来就是“点位在闪烁”。period是波纹扩散一次的周期,单位是秒;scale是扩散的最大范围倍数;brushType是波纹的绘制方式。就这三个参数,足够应付大部分“让点闪起来”的需求。
2.2 rippleEffect 参数速查与联动关系
用表格快速过一下三个参数的脾气:
| 参数 | 作用 | 取值范围经验 | 注意事项 |
|---|---|---|---|
period | 波纹循环周期,秒 | 1~5 | 越小越急促,越大越舒缓 |
scale | 波纹最大范围倍率 | 2~3.5 | 太大会糊成一片,密集点区域慎用 |
brushType | 绘制方式 | stroke/fill | stroke 不遮原图,fill 更像墨滴扩散 |
period和symbolSize是联动关系。symbolSize 越大,波纹扩散的绝对范围越大,哪怕 scale 一样,视觉上也会觉得“闪得更凶”。我的经验值:symbolSize 12 配 period 3 是舒适档;如果要做大尺寸点,建议 symbolSize 20 配 period 4,节奏放缓一点。
scale需要注意的是:它不是绝对像素值,而是相对原始点大小的倍数。scale 超过 4 之后,多个点靠得比较近时,波纹之间会互相覆盖,整体看起来像一团雾,反而不利于定位。做设备密度高的大屏,我一般控制在 2~2.5。
2.3 stroke 与 fill:两种波纹的视觉差异
brushType: 'stroke'画的是描边波纹,像一圈圈水波向外荡;brushType: 'fill'画的是填充波纹,像墨滴在水里化开。做实时告警、重点定位,我更推荐stroke,因为描边不遮原始点,信息层级更清楚。
fill的好看之处在于颜色更饱满,配合半透明渐变色,能够做出类似雷达扫描的层次感。但它的缺点是会盖住点本身,如果点上还要显示一个 icon 或者文字标签,视觉上就会乱。所以我的默认选择是stroke,只有产品明确要求“水墨感、扩散感”时才切fill。
提示:如果只想要一个点闪烁、其它点静态,不要建两个 series 硬拷同一份数据。直接把这个 effectScatter 系列的数据只放需要闪烁的点,再叠加一个普通 scatter 系列放全部点。这个分层思路在后面会细说。
3. 想要真正的“脉冲呼吸”效果,绕不开动态更新这条路
3.1 定时器 + setOption 实现呼吸灯
如果产品和你说“要像呼吸灯一样明暗闪烁”,但effectScatter始终只是涟漪扩散,它没有真正改变点的明暗或大小。这就是我第一次踩的坑:交付了 effectScatter 效果,对方说“不对,我要的是那个点本身一亮一暗”。ECharts 目前没有直接暴露一个“点的循环脉冲动画”参数,所以最常用、也最可控的做法是:定时器驱动 setOption。
核心代码大概是这样:
let bright = false; const interval = setInterval(() => { bright = !bright; myChart.setOption({ series: [{ id: 'mainScatter', data: genData(bright) }] }); }, 600);genData根据 bright 返回值,给重点点位带上不同的大小、颜色、透明度:
function genData(bright) { return baseData.map(item => { const isKeyPoint = keyPointIds.includes(item[2]); if (isKeyPoint) { return { value: [item[0], item[1]], symbolSize: bright ? 22 : 14, itemStyle: { color: bright ? '#00ffa0' : '#00e0ff', opacity: bright ? 1 : 0.45 } }; } return { value: [item[0], item[1]], symbolSize: 10, itemStyle: { opacity: 0.3 } }; }); }这里有几个必须处理的细节:
第一,setOption 时不要重新传 xAxis、yAxis,否则坐标轴也跟着重绘,视觉上能感觉到整个图闪了一下。用series.id定位要更新的系列,其它配置一律不传,让 setOption 按需合并,效率最高。
第二,定时器周期最好与 ECharts 内置动画时长错开一点。setOption 更新数据后,ECharts 会默认播一个 300ms 左右的过渡动画。如果定时器设在 100ms,动画根本没播完又来下一个状态,点就会“抽搐”而不是“呼吸”。我一般把定时器周期设在 600~800ms,正好让过渡动画完整收尾。
第三,定时器必须在组件销毁时清掉。在 Vue 组件里用 setInterval,页面销毁时没清,路径切走之后图表还在背后闪,内存直接往上飙。正确姿势是在onUnmounted/beforeDestroy里clearInterval,并且把myChart.dispose()也调一下。
3.2 为什么不能直接给 itemStyle 写 animation
很多同学会问:ECharts 不是有animation配置吗?给点配一个动画让它自己闪不就行了?
实际不行。ECharts 的animation控制的是图表元素从旧状态到新状态之间的过渡动画,它不负责“一个元素自己持续循环动画”。唯一会自己无限循环的,只有 effectScatter 的 rippleEffect。所以点本身的脉冲动画,必须靠数据周期性变化来驱动。
也有同事尝试用 zrender 底层的 animation API 写一个无限循环,直接修改散点图形的style.opacity和shape。这条路能走通,但需要深入理解 zrender 的渲染架构,而且 ECharts 版本升级后内部对象未必稳定。我的经验是:除非有成千上万个点同时做脉冲,否则定时器 setOption 是最经济、最好维护、出问题最容易回溯的方案。
3.3 多系列联动时的 setOption 细节
真实项目里,闪烁的点经常不是孤立的。点闪烁的同时,可能还要带动飞线、柱状图等其他系列一起变化。这时 setOption 的层级要写清楚:
myChart.setOption({ series: [ { id: 'effectLayer', data: newEffectData }, { id: 'flowLine', data: newLineData } ] });这里有一个必须注意的机制:setOption 默认是 merge 合并,新旧数据按索引对应,如果长度不一致,容易残留旧元素。如果用notMerge: true,整个图会重建,闪烁的连续感会被打断。所以多系列场景,我习惯给每个系列都加上id,只更新data,靠 id 定位系列,这比靠索引安全得多。
3.4 坐标轴边界被 setOption 触发的问题
定时器方案还有个隐蔽问题:如果初始 option 里坐标轴没有显式设置min/max,setOption 更新数据时,坐标轴会重新计算边界,哪怕数据没变化,轴刻度也可能轻微抖动,整张图看起来就像在“呼吸”得不对劲。
解决办法有两种:一是在 xAxis/yAxis 里直接写死数据边界;二是 setOption 只传 series 部分,避免坐标轴被重算。我前面说的“type effectScatter 系列用 id 定位”就是这个原因——它不只是代码洁癖,是真的能避免动画抖动。
4. 进阶玩法:把闪烁做成有反馈的交互信号
4.1 showEffectOn:只在 hover 时闪烁
如果担心全场持续闪烁太吵,ECharts 给 effectScatter 留了一个showEffectOn: 'emphasis'配置。设置为 emphasis 后,涟漪只在鼠标移到该点时触发。默认值是'render',渲染完就自动开始闪。
这个配置特别适合点非常多、但只需要临时查看某个点位的场景。比如有几百个点位,每个点都持续闪,整个屏幕就乱了;改成 emphasis 之后,静止时只是普通点,鼠标一移上去,那个点立刻开始闪,效果非常聚焦。
实际改动只有一行:
series: [{ type: 'effectScatter', showEffectOn: 'emphasis', rippleEffect: { period: 2, scale: 3, brushType: 'stroke' } }]但注意:在showEffectOn: 'emphasis'模式下,rippleEffect触发的是一次涟漪动画,不是无限循环。如果你希望 hover 之后持续闪,那个场景还是回到定时器方案。我一般把这个配置和 tooltip 一起用,鼠标移上去同时弹信息框和闪烁,双重反馈,大屏演示时很出效果。
4.2 后端推送驱动闪烁点:把闪烁状态数据化
真实项目里,闪烁的点往往不是写死的。可能是后端推送一条告警,前端就新增一个闪烁点;告警恢复,点就熄灭或者移除。这个场景需要把“闪烁开关”提炼成数据字段。
我通常在前端定义一个 Map 结构:
const flashMap = { 'pointId-001': { level: 'high', color: '#ff4d4f', period: 1.2 }, 'pointId-002': { level: 'mid', color: '#faad14', period: 2.0 } };每次收到 WebSocket 推送,先更新flashMap,再调用一次 setOption 重刷 effectScatter 系列的数据。这样闪烁点位和告警状态完全由数据驱动,代码里没有任何写死的点。
有一个必须避开的坑:ECharts 对 data 数组的 diff 是基于索引的。如果每次推送都把数组重新排序,闪烁帧会乱,可能 A 点亮着突然变成 B 点。尽量保持数据顺序稳定,只改每一项的状态字段,不要频繁 splice 重排。
4.3 用 graphic 元素叠加自定义光晕
还有一种做法,能做出比 effectScatter 更好看的“光晕扩散”效果:用 ECharts 的graphic元素,在点位上叠加动态圆环。graphic 元素是 ECharts 提供的一种覆盖层,能画 circle、rect、text、image。配合定时器修改 circle 的半径和透明度,可以做向外扩散的光圈动画。
graphic: keyPointIds.map((id, index) => ({ type: 'circle', left: xValues[index] + '%', top: yValues[index] + '%', shape: { r: 8 }, style: { fill: 'rgba(0, 224, 255, 0.2)', stroke: '#00e0ff', lineWidth: 1 }, z: 100 }))这个方案适合点位坐标固定的情况,需要自己算 px 比例或者用坐标系转换。相比 effectScatter,它的好处是光晕可以做得更“自定义”,比如多层光圈、不对称扩散、透明度渐变,都能控制。缺点是坐标计算麻烦,而且 graphic 元素不跟随图表缩放自动同步,地图缩放或窗口 resize 后要对齐点位需要额外处理。我的经验是:优先 effectScatter,只有 effectScatter 满足不了视觉需求时再上 graphic。
5. 大数据量场景下的性能保障:别让闪烁拖垮整张图
5.1 闪烁点和普通点必须分层
这个是我最想强调的一条经验:不要把所有数据点都丢进 effectScatter。effectScatter 的实现机制是每帧渲染多个扩散圆形,动画时刻在跑,点一多帧率必然下降。我实测一个上万点的数据集,如果全部用 effectScatter,哪怕大屏机器性能不错,也能明显感觉到掉帧和风扇狂转。
我的做法是拆成两层:
- 底层
scatter:渲染全部数据点,静态显示,不带动画,负责扛数据量。 - 顶层
effectScatter:只放需要闪烁的关键点,一般控制在 50 个以内,数据从全量点里按业务条件过滤出来。
这样视觉上重点点位在闪,普通点安静待在下面,信息层级也更清楚。代码结构大概是:
option = { series: [ { name: '全部点位', type: 'scatter', data: allPoints, large: true, progressive: 20000, itemStyle: { opacity: 0.3 } }, { name: '重点关注', type: 'effectScatter', data: keyPoints, symbolSize: 18, rippleEffect: { period: 3, scale: 2.5, brushType: 'stroke' } } ] };5.2 large 和 progressive 的边界条件
large: true只在普通scatter里有效,这是 ECharts 优化大数据渲染的关键。开启后,它会用 Canvas 的批量绘制方式,替代每个点单独生成图形对象,省掉大量对象创建开销。配合progressive参数设置每批渲染多少个数据点,比如 20000,能明显改善首屏速度。
但有个约束:large: true模式下,单点的 symbolSize、颜色等定制能力会受限,ECharts 会优先保证绘制速度。如果你需要所有点大小、颜色都各不相同,又想扛大数据量,就要自己做取舍。我建议普通 scatter 层里只展示简单的点,需要精细定制的一律放到 effectScatter 层去。
5.3 动画帧率与 GPU 开销的量化感受
闪烁本身就是高频动画,转成大屏之后就是持续占用 GPU。按我的体感经验:
- effectScatter 点数在 20 个以内:随便用,帧率很稳。
- 20~100 个:注意 symbolSize 不要太大,period 不要低于 1,否则波纹密集会糊且耗性能。
- 超过 100 个持续闪烁:基本不现实,建议重新设计交互,改成“默认不闪、hover/选中才闪”,或者用 minimap 做全局提示。
还有两个容易被忽略的小点:一是初始化时用默认的renderer: 'canvas',不要轻易切'svg',SVG 渲染做少量矢量元素还行,做几万个动态点会非常吃力。二是如果页面只是展示不交互,可以把animation: false设掉。这里的 animation 影响的是 setOption 更新时的过渡动画,关闭后能省下一部分 CPU 开销,而闪烁本身是 effectScatter 和定时器驱动的,不依赖全局 animation。
6. 实战踩坑记录:文档里没细说的事
6.1 频率和时长不匹配,效果从“闪”变“抽”
我在真实项目里调得最久的就是频率问题。最开始定时器设 300ms 切换一次,结果点不是变亮变暗,而是像抽风一样闪现,视觉非常难受。原因在于 setOption 更新数据时,ECharts 默认会带 300ms 左右的动画过渡,新状态还没过渡完,下一个状态又来了,整个动画循环就乱了。
后来我把定时器周期设为动画时长的两倍,也就是 600ms,视觉节奏立刻稳定。另一个经验是:effectScatter 的rippleEffect.period和定时器周期如果都设在 1s 左右,两个循环容易产生“拍频”干扰,波纹和明暗节奏会一阵一阵地合拍又错开。解决方法是把两者的周期刻意错开,比如 ripple 用 2.4s,脉冲用 1.8s,视觉节奏反而更自然。
6.2 坐标轴被 setOption 带着抖,整张图在晃
第一次做定时器方案时,我在 setOption 里直接把整个 option 对象都传了进去。结果坐标轴每次都会从旧边界动画过渡到新边界,虽然数据没变,但坐标轴刻度会轻微左右移动,整张图看起来一直在抖。
修复方案其实很简单:setOption 只传 series 部分,并且用 series id 定位。如果确实必须全量传,可以把animationDurationUpdate设为 0,或者在坐标轴里显式固定min/max,轴不重算边界,自然就不抖了。
6.3 多个点位的“伪闪烁”联动技巧
最后分享一个有点意思的做法。需求方希望:一个点闪烁时,同组其他点降低透明度,形成“聚焦”效果。我用一个很直接但有效的方式实现:维护一个activeId状态,每次定时器触发时,动态计算整组数据的透明度。这本质上把“闪烁”扩展成了“分组联动反馈”,很适合大屏点选场景。
function buildData(activeId, bright) { return allData.map(item => { if (item.id === activeId) { return { ...item, symbolSize: bright ? 24 : 14, itemStyle: { opacity: bright ? 1 : 0.5 } }; } return { ...item, itemStyle: { opacity: 0.15 } }; }); }配合 click 事件,点击哪个点,哪个点就开始闪烁,其它点全部暗淡下来,视觉重点非常突出。这个技巧后来被我复用到好几个项目里,比单纯做“点亮”效果要高级不少。做法本身不复杂,但背后那个思路值得记下来:闪烁不一定是点自己的事,它还可以是一组数据的“状态表达”。