简介:这是一份基于ECharts实现动态数据展示的完整Web工程,面向需要在前端页面中呈现实时数据的前端开发者与数据可视化学习者。资源围绕动态图表的开发主线,整合折线图、柱状图、饼状图等常用图表的动态更新示例,覆盖实时数据接入、鼠标悬停提示、点击联动、数据区域缩放、响应式布局及自定义主题等关键实现,可直接观察各配置项对图表渲染的影响。包体共86个文件,压缩包大小6.62MB,以24个依赖jar包、15个XML配置、12个class文件、9个JS脚本、6个JSP页面及4个Java源文件为主,其余包含SQL初始化脚本、Eclipse/MyEclipse工程描述与Git忽略文件,结构清晰,导入开发环境即可运行调试。已有502人学习浏览,适合希望系统掌握ECharts动态数据开发流程、理解前后端交互与图表性能调优的开发者参考,也可作为课程设计或项目起步模板。
1. 动态数据显示的入口:echars(ECharts)的 setOption 合并机制
echars 是 ECharts 最常见的拼写变体,对应一个很常见的真实需求:图表已经渲染好了,数据还在随时间追加,页面不能整页刷新。监控大盘、交易曲线、设备看板都属于这类场景。很多人的第一版实现是每次数据到了就销毁实例重新 init,或者传一整个新 option 给 setOption,结果不是闪烁就是 CPU 飙高。动态显示的关键不在“重新画”,而在“增量改”:setOption 自带合并与 diff 能力,你的职责是喂对数据、选对合并策略,数据量变大后再切换到采样和 appendData 这类专用通道。这篇按最小可实现、性能治理、生产接入、收尾验证的顺序展开,适合已经会画静态图、但没把动态链路想透的开发者。
2. ECharts 动态数据的最小闭环:setOption 合并、轮询与滚动窗口
先建立最朴素的动态更新模型:数据源每 2 秒产生一个新点,图表要把它追加到曲线尾部,同时保持用户已选中的缩放区间不跳变。拆开看,这里只有两个动作——数据数组 push 一个点,再调用一次 setOption。ECharts 拿到新 option 后不会整图重绘,而是把新旧配置做 diff,只重绘变化的部分,旧点不会产生闪烁。这套机制是动态显示的底座,但它的表现好坏,取决于你传给 setOption 的两个布尔参数用对没有。
2.1 notMerge 与 lazyUpdate:两个决定动态体验的开关
setOption 的完整签名是setOption(option, notMerge, lazyUpdate),默认值都是 false。notMerge 为 false 时,新 option 会和当前实例已存在的配置做增量合并:你只传了series.data,它就不会动tooltip、legend这些未提及的配置;notMerge 为 true 时则反过来,整个实例的配置会按新 option 全量重置,未声明的组件会回退到初始状态。动态场景里最忌讳的就是手滑传了 notMerge = true,常见后果是图表下方拼命堆叠默认图例、tooltip 配置被清掉、切换过的 dataZoom 区间瞬间复位。
lazyUpdate 控制的是重绘时机。传 true 时 setOption 不会立即触发渲染,而是把这次更新合并到下一帧的渲染任务里。我一般会在高频更新场景下配合requestAnimationFrame使用:把每次推送来的数据先塞进待处理队列,帧回调里一次性 setOption(option, false, true),这样一帧内只重绘一次,能省掉大量无效渲染。下面是两个参数搭配的具体效果:
| setOption 参数 | 合并行为 | 渲染时机 | 适合场景 |
|---|---|---|---|
setOption(option) | 增量合并,保留未提及配置 | 立即执行 | 常规轮询、交互联动 |
setOption(option, true) | 全量替换,未声明组件复位 | 立即执行 | 主题切换、图表类型切换 |
setOption(option, false, true) | 增量合并 | 延迟到下一帧 | 高频数据推送、拖拽缩放 |
setOption(option, true, true) | 全量替换 | 延迟到下一帧 | 低频大规模配置重置 |
需要提醒的是,notMerge 只影响配置合并策略,不会销毁图表实例本身。销毁实例的唯一方式是chart.dispose()。如果只是数据变了,永远不要让“重新 init 一次”进入你的代码路径,那会连事件绑定一起重建,得不偿失。
2.2 用轮询 + setTimeout 链驱动温度曲线
最直接可抄的轮询实现长这样。假设后端提供一个返回最新温度和时间戳的接口,前端维护一个全局数组,每次拉取后 push 进新点,再整体交给 setOption:
const chart = echarts.init(document.getElementById('temp')); const allData = []; // 维护在外部,作为唯一数据源 let timer = null; async function fetchLatest() { const res = await fetch('/api/metrics/latest'); const json = await res.json(); return [json.timestamp, json.temperature]; } async function tick() { try { const point = await fetchLatest(); allData.push(point); if (allData.length > 300) { allData.shift(); // 只保留最近 300 个点 } chart.setOption({ series: [{ type: 'line', data: allData, showSymbol: false }] }); } catch (err) { console.warn('拉取数据失败', err); } finally { timer = setTimeout(tick, 2000); // 用链式调用代替 setInterval } } timer = setTimeout(tick, 2000);这段代码里有三个容易被忽略的细节。第一,数据源用 setTimeout 链而不是 setInterval:setInterval 不关心上一次请求是否结束,接口慢时会形成任务堆积,链式结构天然保证“上一次完成后再排下一次”。第二,series.data 每次传的是全量数组,不是只传新增的那个点。ECharts 的 diff 机制会把新旧数据做对比,新增点才触发过渡动画,旧点原地不动;如果只传增量,它会认为你替换了整条曲线,视觉上就是曲线闪烁重画。第三,showSymbol: false在小数据量下看不出来,但跑到一两百个点之后,序号轴上的空心圆点会明显拖慢渲染,先关掉省心。
2.3 用 dataZoom 的 startValue/endValue 做固定长度滚动窗口
动态曲线一旦跑起来,坐标轴一定会面临“数据越积越多,可视范围撑爆”的问题。常见做法是让展示窗口固定为最近 100 个点,老数据仍然保留在内存里,但滑出可视区。实现时不建议给 xAxis 设min: 'dataMin',因为 dataZoom 的百分比计算不受 xAxis.min 影响,两者混用会出现坐标轴剧烈跳跃、缩放区间看起来“不听使唤”的情况。干净的写法是只更新 dataZoom 的窗口端点:
function scrollWindow(ts, keepPoints = 100) { const lastIndex = allData.length - 1; const startIndex = Math.max(0, lastIndex - keepPoints + 1); chart.setOption({ dataZoom: [{ type: 'inside', xAxisIndex: 0, startValue: allData[startIndex][0], endValue: ts }] }); }startValue 和 endValue 接受的是数据值本身,不接收索引,所以这里直接传时间戳。keepPoints 控制数据窗口宽度,数值按业务调:监控类图表 100 到 300 点比较合适,再多会丢掉局部细节。有一点要提前说明:dataZoom 的窗口宽度是动态的,它只控制可视范围,不控制内存里的数据量。内存中数据持续增长的问题,后面第 3 章专门处理。
3. 大数据量下的动态显示治理:sampling、appendData 与滑动窗口
轮询模型跑通后,数据量会是第一个撞上的墙。前面示例里的全量 setOption 在小样本下表现良好,但点位数到几万级时,setOption 的 diff 成本、坐标转换成本、渲染成本都会线性上升。处理动态大数据量,业界方案大致分三条路线:采样降密度、增量追加、滑动窗口裁剪。这三条可以叠加使用,顺序上先做窗口裁剪,再做采样,最后用 appendData 处理真正的超大序列。
3.1 内存里能放的点数和画布上能画的点数是两回事
先说为什么会有瓶颈。一次 setOption 传入几万个点时,ECharts 要做三件事:新旧数据 diff、把数据从业务坐标映射到像素坐标、以及触发一次完整的绘制流程。机器性能好时三五万点还顶得住,但动态场景下每秒可能更新多次,加上 tooltip 的 hover 检测和事件绑定都基于全部数据点,交互一旦发生,卡顿感立刻出现。实际工程里还有个隐性成本:每次 setOption 都相当于序列化并拷贝一次数据引用,几万点的二维数组在内存里反复出入,GC 压力也不小。
所以正确的处理顺序是:先限制内存中的数据总量,再考虑绘制端优化。维护 allData 数组时用 push + shift 已经是最简单可靠的内存窗口方案,每来一个新点就挤掉一个最老的,数组长度恒定,内存不会膨胀。但 shift 对引擎来说是一次 O(n) 的数组搬移,几十万点的高频写入建议改用环形缓冲区。
3.2 用 sampling 降低绘制成本:lttb 与其它取值策略
当窗口内点数仍然很大,比如一次要展示 10 万个点时,采样是最后一道防线。ECharts 给折线图内置了 sampling 配置,不需要预处理数据,直接在渲染引擎层抽稀:
series: [{ type: 'line', data: largeData, sampling: 'lttb', showSymbol: false, progressive: 2000, progressiveThreshold: 3000 }]sampling: 'lttb'是推荐配置,它基于 Largest-Triangle-Three-Buckets 算法,把数据分成等宽桶,每个桶里选出一个能最大程度保留原形状特征的点。对比看,'max'取每个桶的最大值,适合峰值监控但会把低谷拉平;'average'计算桶内均值,平滑但会削掉尖刺。三种取值策略的取舍可以按业务特征选:
| sampling 值 | 保形能力 | 计算开销 | 适合场景 |
|---|---|---|---|
'lttb' | 强,保留趋势与尖峰 | 中 | 动态曲线、交易走势 |
'max' | 弱,只保每桶最大值 | 低 | 机房温度、水位最高值 |
'average' | 中,削尖刺 | 低 | 长时间跨度趋势总览 |
progressive和progressiveThreshold是配合大数据的另一个开关:当数据点数超过 progressiveThreshold 时,渲染会切成增量绘制模式,每帧只绘制 progressive 指定的点数,避免一次绘制卡住主线程。动态场景下这两项建议显式设置,默认值在部分 ECharts 版本里阈值偏高,等触发时界面已经明显掉帧了。
3.3 用 appendData 做真正的数据追加
如果你维护的是秒级甚至毫秒级数据,一屏轻松超过 50 万个点,上面的方案仍然不够。ECharts 5 提供了 appendData 接口,专门用于高频追加场景,它绕开了 setOption 的 diff 流程,直接把新数据块挂到已有序列尾部。用法分两步:初始化时声明 series 的 data 为空数组并开启 large,之后反复调用 appendData:
const chart = echarts.init(document.getElementById('realtime')); chart.setOption({ series: [{ type: 'line', data: [], // appendData 模式下初始数据为空 large: true, largeThreshold: 2000, progressive: 2000, sampling: 'lttb' }] }); // 后端推来新数据后,追加数据块 function appendChunk(points) { chart.appendData({ seriesIndex: 0, data: points }); }这里的 data 建议用 TypedArray 打平,比如Float32Array,顺序按 series 里声明的 encode 排列。如果 series 没配 encode,时间序列折线图默认按 [x, y] 对一维排列,打平后就是[x1, y1, x2, y2, ...]。appendData 与 setOption 最大的不同是它不做配置合并,语言上也更接近“追加”语义,性能比全量 setOption 高一个量级。代价是 appendData 只支持部分序列类型,折线图、柱状图没问题,地图和树图不能这样用。另外,appendData 追加的数据不会自动清理,内存窗口仍然要靠你自己维护。
3.4 按时间窗口清理数据:内层 shift 与外层 clear 的边界
appendData 和全量 setOption 两种模式下,数据清理的姿势不同。appendData 模式下,原始数据块被 ECharts 内部持有,外部数组清空不会真正释放它,需要依靠实例自身能力和时间窗口配合。常见做法是设定一个“窗口上限”,每追加 N 个数据块就调用一次chart.clear()重建实例配置,把内部数据缓存整个丢掉。
let appendCount = 0; const APPEND_LIMIT = 50; // 每追加 50 次,重建一次内部数据 function appendChunk(points) { chart.appendData({ seriesIndex: 0, data: points }); appendCount += 1; if (appendCount >= APPEND_LIMIT) { chart.clear(); chart.setOption(buildEmptyOption()); // 重建 series 配置 appendCount = 0; } }需要注意,chart.clear()会清空所有组件和绘制内容,包括 dataZoom、tooltip 配置,所以调用后必须重新 setOption 一次。如果你的事件是挂载在 chart 实例上的(如chart.on('click', ...)),clear 不会移除它;但如果事件是绑定在组件上的,或者你用了dispatchAction设置的选中态,重建后要重新挂。这个边界容易踩,建议把构建 option 的逻辑抽成纯函数,清理后调用一次即可复原。
4. 动态数据接入生产环境:WebSocket 推送与生命周期收口
前两章的方案都假设数据源是“主动拉取”的。真实生产环境里的实时大盘、交易看板,绝大多数走的是服务端推送。推送方案里 WebSocket 最常用,SSE(Server-Sent Events)在单向实时流场景也有应用价值。这一章从选型边界讲起,给出一套可以直接落地的推送接入和资源收口代码。
4.1 轮询、SSE 与 WebSocket 的选型边界
选择传输层方案,先看两个指标:数据流向和连接数。轮询是客户端主动请求,适合频率低、实时性要求不高的场景;SSE 是单向长连接,服务端可以在数据产生时立刻推给浏览器,断线重连由浏览器原生支持,但只能用 GET 请求且无法从客户端主动发消息;WebSocket 是双向长连接,延迟最低,但需要自己处理心跳和重连逻辑。三者对比如下:
| 传输方案 | 延迟 | 连接数 | 断线重连 | 数据方向 |
|---|---|---|---|---|
| HTTP 轮询 | 高,取决于轮询间隔 | 频繁创建销毁 | 自然重试 | 单向拉取 |
| SSE | 低 | 每条连接常驻 | 浏览器原生自动 | 服务端单向推送 |
| WebSocket | 极低 | 每条连接常驻 | 需自行实现 | 双向实时 |
日常的动态图表需求,实时性要求在秒级以内时,SSE 成本更低;需要双向交互(比如前端要控制推送频率、发起立即刷新)再用 WebSocket。下面按 WebSocket 给出接入代码,SSE 的接入差异只在事件监听方式,整体结构可以复用。
4.2 WebSocket 数据契约与断开重连
WebSocket 接入的关键是先把“推送消息”和“图表信号”解耦。服务端推来的消息往往不只是数据点,还有连接状态、心跳、数据版本号,前端需要先按 type 分发,再决定是否调用更新函数:
let ws = null; let retryDelay = 1000; const MAX_RETRY_DELAY = 30 * 1000; function connect() { ws = new WebSocket('wss://api.example.com/metrics'); ws.onopen = () => { retryDelay = 1000; // 连接成功后重置重连间隔 ws.send(JSON.stringify({ type: 'subscribe', channel: 'temperature' })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'data') { appendChunk(msg.payload); // 交给第 3 章的更新函数 } else if (msg.type === 'heartbeat') { ws.send(JSON.stringify({ type: 'pong' })); } }; ws.onclose = () => { setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY); // 指数退避 }; ws.onerror = () => { ws.close(); // error 后主动关闭,触发 onclose 重连 }; } connect();指数退避的起始间隔设 1 秒,每次失败翻倍,封顶 30 秒。订阅消息在 onopen 里发送,保证连接建立后再申请数据流。后端的 payload 建议统一成[{ timestamp: 1710000000000, value: 23.5 }, ...]的结构,前端只在 appendChunk 里做一次数值校验,不解析嵌套业务字段。心跳一般由服务端主动下发,前端收到后回 pong 即可,不必单独开定时器;如果服务端不主动发心跳,前端需要每 10 秒自行发一次 ping 来维持连接不被中间层断开。
4.3 页面不可见时不画图:visibilitychange 与动画关闭
浏览器标签页切到后台时,requestAnimationFrame 会被自动暂停,但 WebSocket 的消息仍会不断到达,每次消息都会触发 setOption 或 appendData。白白消耗 CPU。处理方式是监听页面可见性,在隐藏时直接关掉动画并把更新函数切换为空操作:
document.addEventListener('visibilitychange', () => { if (document.hidden) { chart.setOption({ animation: false, series: [{ data: allData.slice() }] }); } else { chart.setOption({ animation: true, animationDurationUpdate: 300, series: [{ data: allData.slice() }] }); chart.resize(); // 回来后校正尺寸 } });注意切换 animation 时把当前数据重新 setOption 一次,否则从隐藏到可见的瞬间会看到一次突兀的补间动画。切回来之后调用chart.resize()是因为部分浏览器在标签页隐藏时不会触发 resize 事件,画布实际尺寸可能已经失真。这个处理也适用于交互动画没关干净的其它场景,比如模态框遮挡图表时,用同样的模式控制动画开关即可。
5. 让动态曲线更稳的收尾细节:动画参数、异常兜底与帧率验证
动态图表上线前,还有几个直接决定观感的细节要处理,分别涉及动画时长、异常数据和性能验证。这几项不需要新增依赖,改几行配置就能看出差别。
5.1 animationDurationUpdate 与动画阈值的取舍
ECharts 更新数据时的过渡动画时长由animationDurationUpdate控制,默认 300 毫秒。动态场景下这个值不宜太长,否则曲线会像“抹布”一样拖尾。常见配置是 100 到 200 毫秒,配合animationEasingUpdate: 'linear'让新点平滑接入而不是弹跳入场。
| 参数 | 推荐值 | 说明 |
|---|---|---|
animationDurationUpdate | 100 ~ 200 | 数据更新时的单次过渡时长 |
animationEasingUpdate | 'linear' | 线性过渡,避免弹性动画 |
animationThreshold | 2000 | 超过 2000 个点自动禁用动画 |
animationThreshold是容易被忽略的一个:数据量超过这个值后,ECharts 会自动关闭动画以保证性能。如果你希望大数据量下仍然有过渡效果,要手动调大,但代价是首帧渲染变慢,不建议盲目改。
5.2 空值与突刺:connectNulls 与数据清洗
上游推送偶尔会缺数据或出现超范围突刺,直接渲染会让曲线断掉或爆表。填充NaN表示缺失值是 ECharts 原生的空值约定,配合connectNulls: true可以跨过缺失点连线。突刺过滤则要在数据进入图表之前做限幅校验:
const MAX_DELTA = 20; // 相邻点最大允许跳变 function sanitizePoint(point, prevPoint) { if (point[1] == null || Number.isNaN(point[1])) { return [point[0], NaN]; } if (prevPoint && Math.abs(point[1] - prevPoint[1]) > MAX_DELTA) { return [point[0], prevPoint[1]]; // 丢弃突刺,沿用上一值 } return point; }限幅阈值按量纲设置,温度曲线设 20 度跳变基本不会误伤真实快速升温,股价、流量这类波动大的数据要单独调。注意清洗逻辑放在 WebSocket 消息回调的最前面,不要放在渲染函数里,避免每次 setOption 重复计算。
5.3 用 requestAnimationFrame 手动观测帧率
最后给一套验证渲染性能的方法。不用装任何工具,直接用 rAF 计数每秒回调次数:
let frameCount = 0; let lastCheck = performance.now(); function measureFps(now) { frameCount += 1; if (now - lastCheck >= 1000) { console.log(`图表渲染帧率: ${frameCount} fps`); frameCount = 0; lastCheck = now; } requestAnimationFrame(measureFps); } requestAnimationFrame(measureFps);把这段代码挂到页面里,同时让后端按正常速率推送数据,观察窗口停留在 10 秒以上。帧率稳定在 55 fps 以上属于健康状态,掉到 30 fps 以下就说明当前方案需要降级:先看 sampling 是否配置,再看是否该换 appendData,最后检查数据窗口是否过大。测完记得把这段日志代码摘掉,它本身也会产生一帧回调,同屏多个图表同时测量时结果会互相干扰。
本文还有配套的精品资源,点击获取