ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ECharts非平滑折线图流动特效:lineDashOffset实战

ECharts非平滑折线图流动特效:lineDashOffset实战 上周接了个大屏的活儿客户指着一块实时监控面板说这几条折线太平了想让数据活起来有种光在管子里跑的感觉。我一开始偷懒把smooth: true打开觉得曲线圆润一点更高级结果流动动画一加上就露馅了——光段跑到弯道的地方忽长忽短有的地方密得像一串珠子有的地方又断成一大截。前后折腾了两个晚上最后把 smooth 关掉改成老老实实的折线问题反而全没了。echarts 折线图流动特效这个需求看着简单其实坑不少。它不像柱状图加个渐变色就完事也不是动画库里随便调个 API——你要自己把数据点 → 像素坐标 → 路径长度 → 虚线相位这一整条链子串起来。而非平滑曲线这个限定词恰恰是让整件事从能看变成好看的关键变量。这篇就把我从踩坑到跑通的全过程写下来包括两套实现方案、参数怎么配、以及几个能让你少熬一个晚上的排查技巧。适合已经会用 echarts 画基础折线、想往视觉动效方向再走一步的人看前端和可视化方向的同学都能直接抄作业。1. 需求拆解为什么非要非平滑的流动折线1.1 大屏和实时监控里流动线到底解决什么问题先说清楚这个特效存在的理由。普通的静态折线图信息是完整的但它缺一个东西——方向感。当一块大屏上同时挂着七八个图表人的视线是散的眼睛会到处乱跳最后只能靠颜色和面积去猜哪个是重点。流动线的作用就是在这片静止的图海里制造一处动态焦点它会强行把视线拉到这条线的走向上从起点一路带到终点。除了引导视线流动线还有一层隐含语义它在暗示这条数据是活的。哪怕你的数据其实是五分钟前拉一次只要线在动观看者潜意识里就会认为它在实时跳动。所以这类效果特别常见于实时流量趋势、能耗曲线、产线节拍、订单量波动这些场景——这些业务本身就带持续变化的属性用流动线来表达是贴合的不会显得为了炫技而炫技。反过来说如果是季度营收对比、年度目标完成度这种结论已经固定的数据硬套流动特效就会很奇怪看的人会一直等它停下来。这是我做了几次之后才想明白的一条经验动效是语义的一部分不是装饰。选错了语义再漂亮的动画也是减分项。1.2 smooth 打开之后流动效果为什么反而变差回到标题里的关键词——非平滑曲线。我最初的想法很朴素曲线圆润一点不是更好看吗所以上来就写了smooth: true然后在这个基础上做虚线滚动结果就是开头描述的那种灾难现场。原因得从 echarts 怎么画平滑曲线说起。当你打开 smoothecharts 并不是简单地把折角磨圆而是把相邻的数据点重新拟合成一条三次贝塞尔曲线。这条曲线的形状由前后几个点的位置共同决定端点之间的实际弧长早就不是两点间的直线距离了。而虚线滚动这件事本质上是沿路径的弧长做等距切分弧长一旦不均匀光段的视觉长度自然就忽大忽小。更要命的是拐点处。折线的拐点是一个明确的角光段在那里会干脆利落地转向视觉上是有骨头的。平滑曲线在拐点附近的曲率变化非常剧烈虚线经过这段时会出现一种很别扭的打滑感——你会感觉光段在那个位置被拉了一下像是走在冰面上。我试过用更小的段长去缓解结果只是把打滑变成了高频抖动更难看。还有一个纯工程上的理由非平滑折线的总长度可以直接用勾股定理逐段累加一个循环就出结果平滑曲线要算贝塞尔弧长得做数值积分或者采样逼近精度和性能都要额外操心。我做过一次测试同样 200 个点的数据平滑曲线的路径长度采样计算在低端设备上会带来两到三毫秒的额外开销虽然不多但在每秒 60 帧的重绘循环里就是实打实的压力。所以结论很明确做流动特效就老老实实关掉 smooth。想要柔和的观感可以靠圆角 lineJoin、稍宽的点符号、渐变色去补别指望平滑曲线来救。1.3 三条实现路线的取舍把需求想清楚之后我梳理出三条技术上都能走通的路各自适用面差别挺大先上表对比。方案核心思路实现难度流畅度兼容性适合场景A. 双层 series 定时改 type叠两条折线上层虚线定时器不停改变lineStyle.type数组低一般相位从起点算流动不自然最好不依赖渲染器要求不高、快速出效果B. 自定义图形 lineDashOffset用 zrender 叠加一条 polyline自己控制虚线偏移中很好帧率完全可控好Canvas/SVG 都能用大屏主力方案推荐C. SVG 渲染器 CSS 动画切到 SVG 渲染给 path 注入 CSS keyframes低极好走浏览器合成层一般依赖 DOM 结构版本升级可能失效线条少、追求极致丝滑方案 A 我最早试过思路是在setInterval里不断给lineStyle.type塞一个变化的首段长度。问题在于虚线图案的相位是按路径起点算的改首段长度只会让第一段实线在起点生长和收缩看起来像脉冲不像流动。除非你叠很多层去遮掩代码反而更乱。方案 B 是最后落地用的可控性最强参数全在自己手里想做多快就做多快。方案 C 最省代码也最顺滑因为 CSS 动画跑在浏览器合成层JS 主线程卡一下它也不受影响代价是要去 DOM 里捞 path 元素稳定性差一点。下面重点讲 B再把 C 作为备选补上。2. 原理层虚线相位滚动是怎么骗过眼睛的2.1 底纹加光段一层线叠出的层次先说视觉层面的设计逻辑。如果你只画一条虚线让它动效果是不成立的——静态看就是一条花纹线动起来也只是花纹在平移很难让人读出数据在流动。真正有效果的做法是两层叠加底层一条完整、连续的实线颜色压低饱和度或者降低不透明度线宽稍微放大一点。它的职责是交代路在哪让眼睛知道整条轨迹的形状。上层一条窄一点的亮色虚线同样是完整路径但只呈现出若干段光斑。它的职责是跑。两层的数据必须完全一致smooth也必须同时关掉否则像素路径对不上光段会从底线上飘出去。这一点看起来是废话但我真的见过有人只给上层关了平滑结果两层差了七八个像素特别明显。在暗色大屏上底层线还可以加一点shadowBlur做辉光会显得更有科技感。不过这里有个度shadowBlur在 Canvas 上是很贵的尤其是路径很长的时候每次重绘都要重新计算阴影。我的做法是底层线不加阴影只在最上面叠一条极细的、带一点点模糊的高亮线或者干脆只在关键节点上放effectScatter增加点缀。2.2 dashOffset 到底在移动什么这是整个特效的核心值得掰开讲。Canvas 和 SVG 处理虚线的方式是一样的都基于一个叫dash 数组的概念。[a, b]的含义是沿路径从头开始先画 a 长度的实线再空 b 长度然后无限循环。如果给的是[a, b, c, d]那就按 a 实、b 空、c 实、d 空、再回到 a 实这样循环。那lineDashOffset是什么它是整个图案沿路径方向平移的距离。你可以把它想象成有一条无限长的虚线条纹布现在把它贴在路径这条轨道上offset 就是你向左或向右拉动这块布的距离。布的图案本身没变只是贴合的位置变了。这里有个很容易搞反的地方offset 的数值增加光段视觉上是往起点方向退的。想要光段朝数据终点方向跑就得让 offset 递减或者取负值。我第一次写的时候方向搞反了光段倒着跑看了半天还以为是自己速度设错了。再有一个必须记住的结论图案的周期 T 等于 dash 数组各项之和对于[a, b]就是a b。当 offset 变化了整数倍 T 的时候视觉上和没变是完全一样的。所以 offset 只需要在[0, T)这个区间里循环就够了。别让它无限增长下去——虽然短时间内浮点精度不会有问题但跑一整天的看板这个数字会涨到很大一旦出现精度丢失光段就会开始轻微抖动这种问题排查起来非常让人抓狂。2.3 非平滑折线的路径长度好算在哪前面提过非平滑折线就是若干直线段的拼接总长度可以直接累加function pathLength(points) { let total 0; for (let i 1; i points.length; i) { const dx points[i][0] - points[i - 1][0]; const dy points[i][1] - points[i - 1][1]; total Math.sqrt(dx * dx dy * dy); } return total; }这段代码没有一行是多余的也不会有任何精度争议。而如果用平滑曲线同样的功能得写成贝塞尔弧长的采样逼近采样点少了误差大采样点多了性能吃不消还要处理采样密度不均匀导致长直段被低估的问题。对于动效这种对实时性有要求的场景能不给自己找麻烦就别找。顺带说一个和路径长度相关的调参心得你可以算一下总长度 / (段长 间隔)这个比值。如果结果接近整数说明图案的首尾会刚好对齐整条线看起来会有一点规律感某些场景下反而显得假不整除的时候图案在末尾会被截断视觉上更自然一点。我一般会稍微调一下段长把比值从整数挪开几个百分点。3. 主力方案自定义图形驱动 lineDashOffset3.1 先把数据点换算成屏幕像素echarts 的坐标系和数据坐标是两套东西你要拿到真正能画线的像素坐标得用convertToPixel。这个 API 需要一个坐标系来源所以必须先有一个 series 存在。const chart echarts.init(document.getElementById(main)); const rawData [120, 180, 96, 224, 158, 266, 140]; const categories [1月, 2月, 3月, 4月, 5月, 6月, 7月]; chart.setOption({ grid: { left: 48, right: 32, top: 32, bottom: 40 }, xAxis: { type: category, boundaryGap: false, data: categories, axisLine: { lineStyle: { color: rgba(120,180,255,0.3) } } }, yAxis: { type: value, splitLine: { lineStyle: { color: rgba(120,180,255,0.1) } } }, series: [ { id: baseLine, type: line, smooth: false, symbol: none, data: rawData, lineStyle: { color: rgba(90,160,255,0.28), width: 6, cap: round }, z: 2 } ] });拿到坐标系之后就可以转换坐标了。注意 xAxis 是 category 类型时convertToPixel的第一个参数传的是索引而不是类目名function getPixelPoints() { return rawData.map((v, i) chart.convertToPixel({ seriesId: baseLine }, [i, v])); }如果你的 yAxis 设了inverse: true不用担心convertToPixel会自己处理翻转你拿到的就是正确的屏幕坐标。数据里如果有 null断点转换结果会是NaN或者[NaN, NaN]这种情况必须先把数组按断点切段每一段单独成一条路径否则整条线都会画不出来。3.2 用 zrender 叠一条自定义 polyline拿到像素点之后下一步是绕过 echarts 的 series 体系直接在 zrender 层加图形。好处是它完全不受 series 动画生命周期的干扰setOption也不会把它冲掉除非你调用了clear。const zr chart.getZr(); const flowLine new echarts.graphic.Polyline({ shape: { points: getPixelPoints() }, style: { stroke: #00e5ff, lineWidth: 3, lineCap: round, lineJoin: round, lineDash: [16, 10], lineDashOffset: 0, fill: none }, silent: true, z: 10 }); zr.add(flowLine);几个配置的用意说一下。silent: true很关键如果不加这条覆盖在上面的线会拦截鼠标事件导致底层折线的 tooltip 和 hover 高亮全部失效用户会以为你的图表坏了。z: 10保证它盖在底层线上面但如果你的图里有zlevel更高的东西还得再调。lineCap和lineJoin都设成round是为了让光段的端点是圆的。这点在非平滑折线的拐点处特别重要——直角端点会显得很生硬圆头会让整个效果柔和不少。如果你用的 echarts 版本里没有导出Polyline退路是直接引入 zrender 包用new zrender.Polyline({...})构造参数完全一样。或者用echarts.graphic.extendShape自己定义一个带buildPath的图形类用ctx.moveTo和ctx.lineTo逐段画原理是一样的。3.3 让光段匀速跑起来动画循环我建议老老实实用requestAnimationFrame并且基于时间戳计算偏移量而不是每次自增一个固定值。const SPEED 70; // 像素每秒 const DASH_CYCLE 16 10; // 一个虚线周期的长度 let startTime null; let rafId null; function tick(ts) { if (startTime null) startTime ts; const elapsed (ts - startTime) / 1000; // 取负值让光段朝数据终点方向跑取模避免数值无限增大 const offset -((elapsed * SPEED) % DASH_CYCLE); flowLine.style.lineDashOffset offset; flowLine.dirty(); rafId requestAnimationFrame(tick); } rafId requestAnimationFrame(tick);为什么一定要基于时间戳因为requestAnimationFrame的回调间隔不是恒定的。在 60Hz 屏幕上是 16.7ms 左右但页面卡顿时会掉到 30Hz 甚至更低。如果你写的是每帧加 1.2 像素那么在掉帧的时候动画就会明显变慢跟其他动效就对不上了。用ts算出来的偏移量无论帧率怎么变光段在单位时间内走过的路程都是恒定的观感就很稳。另外注意flowLine.dirty()这行。zrender 有自己的刷新循环标记 dirty 之后它会在下一个刷新时机重绘不需要手动调zr.refresh()。手动 refresh 会强制同步重绘在高频循环里反而拖性能。3.4 图表尺寸变化之后的重建这一块是很多人会漏掉的。浏览器窗口一变化chart.resize()会重新计算坐标系数据点对应的像素位置就变了但你之前加的那条 zrender 图形还停留在旧坐标上结果就是光段和底线彻底分家。window.addEventListener(resize, () { chart.resize(); // resize 之后坐标系才更新这里要重新取点 flowLine.attr(shape, { points: getPixelPoints() }); flowLine.dirty(); });attr可以直接更新 shape比 remove 掉再 add 一条新线要轻。但要注意调用时机chart.resize()内部可能是异步渲染的稳妥一点可以套一个chart.on(finished, ...)或者干脆放到setTimeout(..., 0)里。如果数据本身也会更新那就更简单了——数据一变就重新setOption然后调一次上面的重建逻辑即可。我在项目里把这些都包成了一个rebuildFlow()函数数据源、resize、主题切换三个地方都调它省心。4. 轻量方案SVG 渲染器配合 CSS 动画4.1 什么时候值得切到 SVG 渲染echarts 初始化时第三个参数可以指定渲染器const chart echarts.init(dom, null, { renderer: svg });默认是 Canvas。SVG 渲染的好处是每个图形都是真实 DOM 元素你能用 CSS 直接控制它动画交给浏览器合成层去做JS 主线程就算在忙别的也不会影响它。代价是元素多了之后性能急剧下降——线条数超过几百条、或者数据点成千上万的时候SVG 会明显比 Canvas 卡。所以我的判断标准是图表里需要流动的折线不超过 3 条且单个系列的数据点不超过 200 个就可以用 SVG 方案。超出这个范围还是老老实实用 Canvas 加 zrender 手动控制。4.2 把 CSS 动画注入到指定的 path 上具体操作是先给 SVG 注入关键帧样式再去 DOM 里把目标路径捞出来const styleEl document.createElement(style); styleEl.textContent keyframes dashFlow { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -260; } } .flow-line-anim { stroke-dasharray: 16 10; animation: dashFlow 3s linear infinite; } ; document.head.appendChild(styleEl); const FLOW_COLOR #00e5ff; function attachFlowClass() { const svgRoot chart.getDom().querySelector(svg); if (!svgRoot) return; const paths svgRoot.querySelectorAll(path); paths.forEach((p) { const stroke (p.getAttribute(stroke) || ).toLowerCase(); if (stroke FLOW_COLOR) { p.classList.add(flow-line-anim); } }); } chart.on(finished, attachFlowClass);这里用颜色去匹配是比较好用的一招。因为 echarts 在 SVG 模式下会把lineStyle.color直接写到stroke属性上只要这个颜色在你的图里是唯一的筛选就很准。比按元素顺序取要靠谱得多不会因为图例换了个位置就失效。要注意-260这个偏移量必须是一个周期的整数倍16 10 26所以 260 正好是 10 个周期动画循环的时候才不会有跳跃感。这个数字我见过不少人随手写改成 -200 就会在每次循环结束时顿一下肉眼能看出来。4.3 两套方案的实际对比跑下来我的感受是SVG 方案的代码量大概是 Canvas 方案的三分之一观感也更顺因为浏览器合成层的动画天然比 JS 逐帧驱动更稳。但它的两个短板很致命一是数据量大就崩二是 echarts 版本升级后 SVG 的 DOM 结构有可能会调整届时那套基于颜色筛选的逻辑可能要重写。Canvas 方案反过来代码多一些但你能完全掌控节奏甚至能做光段跑到某个节点时停顿一下这种定制化效果。我在正式项目里最终选的基本都是 Canvas 方案SVG 方案更多用在演示页面或者个人小项目上。5. 参数调优让光段看起来顺而不是抖5.1 段长、间隔、宽度、速度的配比这几个参数没有放之四海皆准的答案但有一张我常用的起步参考表可以先照着配再微调参数推荐区间说明光段长度 a10 ~ 22 px太短显得碎太长像整条线在闪间隔长度 b8 ~ 18 px一般取 a 的 0.8 ~ 1.2 倍光段线宽2 ~ 4 px比底层线窄一半左右层次才明显底层线宽5 ~ 8 px压暗、加透明度流动速度50 ~ 100 px/s大屏远看取偏快桌面端取偏慢线端点round拐点处不生硬速度这块有个经验值如果一条线的总长度在 600px 左右速度取 70px/s光段大约 8、9 秒走完全程这个节奏在大多数场景下最舒服。再快就会让人心慌再慢又会觉得卡住了。大屏的话因为观看距离远可以往 90 到 110 提一提。段长和线宽的比例也要协调。线宽 3px 的时候段长低于 8px光段就缩成一个个小方块了圆角几乎看不出来线宽 5px 的时候段长超过 30px又会感觉整段是连续的流动感变弱。我一般按段长 ≈ 线宽 × 4这个粗略关系去估。5.2 多个系列同时流动怎么处理一张图里如果三条线都在跑很容易看花眼所以要做区分。我通常用三种做法第一种是错开相位。给每条线的lineDashOffset加一个不同的初始值比如第二条从-8开始第三条从-16开始防止它们的光段在同一时刻经过同一个横向位置画面就不那么呆板。第二种是拉开速度差。主数据用 80px/s次要数据用 55px/s视觉上会自然形成主次关系。不过要注意速度差别太大也会显得乱控制在 1.5 倍以内比较稳。第三种是做主次区分。真正重要的那条线用饱和度高、亮度高的颜色其余两条直接用底纹线处理不加流动。这也是我做得越多越偏爱的方案——一条会跑的线是焦点三条会跑的线是噪音。5.3 暗色背景下的发光处理大屏基本是深色底这时候光段加一点发光会好看很多。zrender 的 style 里可以直接给shadowBlur和shadowColorstyle: { stroke: #00e5ff, lineWidth: 3, lineDash: [16, 10], shadowBlur: 8, shadowColor: rgba(0, 229, 255, 0.9) }但这里要提醒一句shadowBlur在 Canvas 上非常吃性能尤其是在每帧都在重绘的循环里。我实测过一条 200 点的折线加上 shadowBlur 之后单帧耗时从 1.2ms 涨到了 5ms 上下在低配一体机上就直接掉帧了。替代方案是叠一条更宽的、低透明度的同色线做假发光成本几乎为零const glowLine new echarts.graphic.Polyline({ shape: { points: getPixelPoints() }, style: { stroke: rgba(0, 229, 255, 0.25), lineWidth: 9, lineCap: round, lineJoin: round, fill: none }, silent: true, z: 9 });把glowLine放在flowLine下面一层视觉上同样有扩散感性能却好得多。这个技巧我在好几个项目里都用过客户根本看不出来区别。6. 排查手册那些让我熬夜的坑6.1 光段一卡一卡看着像在闪这是最常见的反馈出现频率极高。原因基本可以归到三类。第一类是用了setInterval驱动。setInterval的触发时机和浏览器绘制不同步很容易出现两次更新挤在同一帧、下一帧又没更新。改成requestAnimationFrame立刻就好。第二类是每帧都调setOption。有人为了改变偏移量写了个定时器不停chart.setOption({...})这等于每一帧都触发一次完整的图表更新流程渲染、布局、生命周期全走一遍不卡才怪。正确做法就是前面说的直接改图形样式然后标记 dirty。第三类是速度值设得太大。光段在相邻两帧之间位移超过半个周期就会产生跳格的视觉错觉看起来就像在闪烁。判断方法很简单速度 × 帧间隔如果超过(a b) / 2就该降速或者把段长调大了。6.2 拐点处出现颜色错位或者毛刺如果发现光段在经过拐点时和底线对不齐先检查两层的像素点是不是同一份。我踩过一次坑底层用的是[i, v]转换上层用的是[categories[i], v]转换category 轴下这两种写法在结果上确实都能跑但在边界情况下会差半个像素放大看就是错位。另一个可能是lineJoin没设成round。非平滑折线的拐点是真正的尖角虚线经过时会形成斜接miter当夹角很小的时候斜接会向外延伸出去看起来就是一根小刺。设了round之后就没了。6.3 数据量大之后明显掉帧数据点超过 500 个的时候瓶颈通常不在虚线本身而在每帧全量重绘整个 zrender 画布。这时有几个优化方向可以试把large: true打开让 echarts 走大数据渲染路径把光段动画的重绘范围限制在局部zrender 的dirtyRect概念或者干脆降低动画帧率比如每两帧更新一次偏移量人眼几乎察觉不到区别但耗时直接砍半。还有一个容易忽略的点坐标轴的animation。如果你在数据更新时没关掉坐标轴过渡动画每次更新都会有一段时间的轴动画和你的光段动画同时跑两个循环叠加起来性能压力翻倍。用animation: false或者只在必要的时候开就够了。6.4 页面切到后台再回来动画就断了浏览器为了省电标签页不可见时会暂停requestAnimationFrame这时候elapsed是停住的。等你切回来如果代码里用的是累计elapsed光段会突然跳到一个很远的位置看起来像猛蹿一下。解决办法是记录最后一次的时间戳在恢复时重置startTimedocument.addEventListener(visibilitychange, () { if (document.visibilityState visible) { startTime null; // 下一帧重新取基准时间 } });这样切回来之后动画是从当前位置平滑续上的不会有跳变。这个小细节不影响功能但会直接影响观感尤其是那种长年挂在大屏上不关的看板。6.5 快速自查清单把上面这些整理成一张表出问题的时候可以按顺序过一遍现象优先排查项光段闪烁、跳格是否用 rAF 驱动、速度是否过大光段和底线错位两层像素点是否同源、lineJoin 是否为 round整体卡顿是否每帧 setOption、shadowBlur 是否过重切后台回来跳变是否重置了动画基准时间戳光段长度忽长忽短smooth 是否被打开、路径是否分段处理了 null鼠标 hover 没反应上层图形是否设了 silent: true7. 几点实际用下来的体会这套东西我在三个项目里复用下来最大的感受是别把动画参数写死在代码里。段长、速度、颜色这几个值我后来全部提到了一个配置对象里因为不同客户对大屏的观感偏好差别非常大有人觉得快才带感有人觉得慢才高级。做成配置之后调参从改代码重新部署变成改一个 JSON效率完全不一样。另外一个体会是关于取舍的。刚开始做这类效果的时候我总想把所有能加的动效都加上——流动线、呼吸灯、粒子、扫描光。结果做出来很热闹但客户看完只说了一句有点花。后来我把动效减到只剩一条流动主线其余全部回归静态反而被夸说清爽、专业。视觉这件事做加法容易做减法难一个页面里能有一处真正抓住眼睛的动效比铺满十处要有效得多。如果你手上的场景是地图上的飞线那是另一套东西走的是地理坐标系下的轨迹系列加上effect配置原理和本文这条折线不一样别把两者的参数互相套用会绕远路。而如果只是普通的柱线混合图里想加一条流动线那本文这套流程可以原封不动搬过去只要记得把convertToPixel里的 seriesId 换成对应折线系列的 id 就行。
返回列表