ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+WebSocket+Echarts:构建实时数据大屏的完整实践

SpringBoot+WebSocket+Echarts:构建实时数据大屏的完整实践 简介一套面向数据可视化开发者的源码合集基于Echarts与Java SpringBoot实现动态实时大屏覆盖运动健康、酒店行业、互联网企业数据分析等10个行业场景可直接借鉴大屏布局、图表联动与实时数据刷新方案。包体共2000个文件、约43.29MB其中包含1158个js脚本、273个json数据文件和148个html页面另有150个css样式、30个java源码及配置文件前端展示与后端逻辑分层清晰便于按需提取。已有1699人学习适合正在做可视化大屏项目或希望系统掌握EchartsSpringBoot技术栈的开发者。通过案例可学习图表动态渲染、数据接口对接、大屏自适应等关键实现甚至可将源码改造用于自己的业务看板。1. 动态实时大屏的选型逻辑为什么是 Echarts SpringBoot拿到这套包含 10 个行业案例的源码包时我第一反应不是去看图表画得有多炫而是先确认一件事它的“实时”到底是怎么做出来的。拆完目录和核心代码后发现这套范例的价值不在某一张图表的配置项而在于它把“SpringBoot 后端持续产生/采集数据 → WebSocket 推送到前端 → Echarts 增量更新视图”这条链路做成了可复用的骨架再套上运动健康、酒店、互联网企业等不同行业的指标模板。也就是说你买的不是 10 张图而是 10 份“如何让大屏上的数字自己动起来”的参考答案。这套资源适合两类人一类是刚接触大屏项目、需要一套能跑通前后端完整链路的参考实现的初中级工程师另一类是被领导要求“三天出个可视化大屏 demo”的杂活选手——你从案例里挑一个最贴近业务的行业模板改数据源和指标名就能交差。下面从链路拆解开始把每个环节的代码和配置逐个过一遍。2. SpringBoot 后端实时数据链路的搭建从定时任务到 WebSocket 推送2.1 两种实时方案的取舍HTTP 轮询与 WebSocket 推送大屏的“动态效果”有两种主流实现路径。第一种是前端定时用axios或fetch去请求后端 HTTP 接口拿到最新数据后手动调用图表实例的setOption。这种方式的优点是实现简单、调试直观、后端几乎不用做额外工作缺点是当页面上的图表数量超过 8 个且刷新频率高比如 2 秒一次时HTTP 请求带来的头部开销、连接建立开销和数据库查询压力会同步上涨而且每个图表各自维护一个定时器的话刷新时刻会错开视觉上会显得“东一下西一下”。第二种是 WebSocket 长连接方案后端在数据变化时主动把新数据推给前端前端只需要在一个全局回调里分发数据。这套资源里的实时大屏范例用的就是这条路线。从后端看实时数据流被分解成两个独立环节一是数据产生端二是数据推送端。数据产生端可以是一张数据库表、一个第三方接口也可以是内存中模拟生成的随机数数据推送端则统一通过 WebSocket 把消息推给前端。这样做的直接好处是前端不需要关心数据从哪来只需要关心message事件里收到的数据结构。我这里用一张表格把两种方案的适用边界列出来方便你做技术选型时直接对号入座维度HTTP 轮询WebSocket 推送实现成本低SpringBoot Controller 前端 setInterval中需要配置 WebSocket 端点和消息处理最小刷新间隔建议不低于 2 秒可以达到毫秒级服务器资源占用连接数高时损耗明显长连接维持连接数受限于服务器配置断线处理前端 catch 错误下次轮询自动恢复需要实现重连与心跳机制适用场景数据变化频率低、图表少、临时 demo多图表同屏、实时性要求高、需要联动刷新本例采用部分静态指标核心动态图表2.2 SpringBoot 定时任务把数据推出去Scheduled 与 WebSocket 的配合后端负责产生数据的核心逻辑通常是用Scheduled注解驱动的。下面是一个简化但完整的推送链路示例我把数据产生和推送分成两个方法方便理解职责边界。Component public class ScreenDataScheduler { private final SimpMessagingTemplate messagingTemplate; private final DataGenerateService dataGenerateService; public ScreenDataScheduler(SimpMessagingTemplate messagingTemplate, DataGenerateService dataGenerateService) { this.messagingTemplate messagingTemplate; this.dataGenerateService dataGenerateService; } // 每 3 秒执行一次向订阅了 /topic/screen-data 的客户端推送最新大屏数据 Scheduled(fixedRate 3000) public void pushScreenData() { MapString, Object screenData dataGenerateService.generateScreenData(); messagingTemplate.convertAndSend(/topic/screen-data, screenData); } }这段代码里有两个关键参数。fixedRate 3000表示从上一个任务开始执行时计时每 3 秒触发一次这种模式适合数据生成耗时远小于间隔时间的场景如果你用的是fixedDelay则是等上一个任务执行完才开始计时更适合数据生成本身耗时不稳定的情况。convertAndSend的/topic/screen-data是目的地前缀Spring 的 WebSocket 消息代理会把消息广播给所有订阅了这个地址的前端会话。2.3 WebSocket 端点配置与前端订阅的对应关系光有Scheduled还不够必须让 Spring 启用 WebSocket 支持。通常在Configuration类里做如下配置Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { // 客户端订阅的前缀前端订阅 /topic/screen-data 时使用 config.enableSimpleBroker(/topic); // 客户端发送消息到服务端的前缀本例不涉及上行消息 config.setApplicationDestinationPrefixes(/app); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 前端 WebSocket 连接入口allowedOriginPatterns 需要按实际域名放开 registry.addEndpoint(/ws-screen) .setAllowedOriginPatterns(*) .withSockJS(); } }配置里两个端口要分清楚/ws-screen是前端建立连接的端点/topic是消息代理前缀。前端连接时用socket new SockJS(/ws-screen)订阅时用stompClient.subscribe(/topic/screen-data, callback)。很多初次接手的人会搞混这两层订阅地址少写/topic前缀或连接地址写成了/app都会导致收不到推送。setAllowedOriginPatterns(*)在本地联调时能用但部署到生产环境后一定要收敛为具体的域名列表否则任何站点都能往你的大屏推送消息——虽然实际业务影响有限但安全审计这一关过不去。消息格式建议统一约定成一个外层结构不要裸传数组或散字段。常见做法是{ timestamp: 1736488800000, type: screen-data, data: { onlineUsers: 1280, orderCount: 358, salesAmount: 666666 } }timestamp字段很重要前端拿到后既能自己决定渲染的时间基准也能用它做数据延迟监控——如果连续收到多条消息的timestamp差值明显大于推送间隔就说明链路里有积压。2.4 数据源侧的准备模拟数据与真实查询的切换这套范例里很多案例默认跑的是内存模拟数据这样免去了搭数据库的步骤。模拟数据一般放在一个 Service 里用ThreadLocalRandom在上一轮数值基础上做小范围浮动Service public class DataGenerateService { private double lastSalesAmount 50000.0; public MapString, Object generateScreenData() { MapString, Object result new HashMap(); // 在上一轮数值 ±5% 范围内浮动模拟真实业务曲线的起伏 double newSalesAmount lastSalesAmount * (1 (ThreadLocalRandom.current().nextDouble() - 0.5) * 0.1); lastSalesAmount newSalesAmount; result.put(salesAmount, Math.round(newSalesAmount * 100.0) / 100.0); result.put(onlineUsers, 800 ThreadLocalRandom.current().nextInt(400)); result.put(timestamp, System.currentTimeMillis()); return result; } }模拟数据要和真实数据平滑切换我一般会在application.yml里加一个开关而不是要求使用者去改 Service 代码screen: >if (real.equals(dataSource)) { return orderMapper.selectLatestSummary(); } else { return dataGenerateService.generateScreenData(); }真实接入数据库时需要注意的点是定时任务的查询不能太复杂避免慢 SQL 阻塞推送线程。如果单次查询超过 500ms建议在推送的Scheduled方法里改用Async异步执行或者先把查询结果缓存到Caffeine/本地内存由推送线程直接读缓存避免数据库慢查询拖死整个 WebSocket 消息循环。3. Echarts 命令式 API 与动态数据更新setOption 的合并语义你真的用对了吗3.1 setOption 的三个参数为什么只传一个参数会导致状态堆积后端链路通了以后前端的工作就是把收到的数据渲染到 Echarts 图表上。大部分人在这个阶段踩过同一个坑页面上的图表累计更新一段时间后变得卡顿或者切换 Tab 再回来发现上一个 Tab 的图表配置和当前指标混在一起。这是因为 Echarts 的setOption默认执行的是“合并”逻辑而不是“全量替换”。具体来说chart.setOption(option);等价于chart.setOption(option, { notMerge: false, lazyUpdate: false });notMerge: false意味着新传入的 option 会和当前实例已有的配置进行递归合并。对于 series 数据如果新旧 series 的 name 能对上Echarts 会保留原 series 的 id只更新对应 data如果对不上就会追加新的 series。同一张大屏上如果反复用不同的数据结构调用setOptionseries 列表会越积越长图表渲染性能随之下降。正确做法是当后端推送的数据结构会发生变化或者你需要彻底重置图表状态时显式开启全量替换chart.setOption(buildOptionFromData(receivedData), true);第二个参数传true等价于notMerge: true会丢弃之前的 series、xAxis、yAxis、legend 等配置完全用新 option 渲染。代价是图表会经历一次重建视觉上有轻微的闪烁。对于大屏场景我更推荐的折中方案是option中不变的部分如 color、tooltip 样式、grid 边距仍走默认合并但每次更新前手动用chart.clear()清空实例再整体setOption。这样避免累积又不需要重新init容器。3.2 折线图与饼图的实时更新写法对比折线图实时更新最常见的技术点是 xAxis 的时间点滑动以及 data 长度裁剪。下面是本范例中运动健康大屏的实时心率曲线的精简版实现function appendLiveHeartRate(lineChart, receivedData) { const timestamp formatTime(receivedData.timestamp); const rate receivedData.heartRate; // 维护 chart 实例上的滚动数据窗口 if (!lineChart.__timeWindow) { lineChart.__timeWindow []; } if (!lineChart.__rateWindow) { lineChart.__rateWindow []; } lineChart.__timeWindow.push(timestamp); lineChart.__rateWindow.push(rate); // 最多保留最近的 20 个数据点 const MAX_POINTS 20; if (lineChart.__timeWindow.length MAX_POINTS) { lineChart.__timeWindow.shift(); lineChart.__rateWindow.shift(); } lineChart.setOption({ xAxis: { data: lineChart.__timeWindow }, series: [{ name: 心率, data: lineChart.__rateWindow }] }); }注意这里的__timeWindow是直接挂载在 chart 实例上的自定义属性这个技巧能让多图表共用一套数据处理逻辑又不必为每个图表维护一套全局数组。shift()是 O(n) 操作但对 20 个点的数组来说可以忽略不计如果数据点上千就要改成“只保留末位索引、每次整体替换 data”但那是反向拖慢性能的做法。饼图的实时更新则更简单因为饼图的基本数据形态就是[{ name, value }]数组function updatePieChart(pieChart, categories) { const option { series: [{ type: pie, radius: [40%, 68%], label: { show: true, formatter: {b}: {d}% }, data: categories }] }; pieChart.setOption(option); }用{b}表示名称、{d}表示百分比占比这种 formatter 写法的好处是无需在数据源里计算比例Echarts 内部会根据 value 自动换算。用radius数组做成的环形饼图比实心饼图更适合大屏中心区域可以用来放总计数字或排名也可以通过title的text配合left: center实现指标名居中展示。3.3 地图与 3D 扩展从 echarts 中国地图到 echarts-gl 的边界这套案例里有两处值得单独拎出来说的地图场景一是用geo或map系列展示不同省份的数值分布比如互联网企业案例里的“全国用户分布”区域二是通过echarts-gl的map3d/scatter3d实现立体的中国地图柱状效果。两者的实现难度和适用场景差异明显。普通中国地图的数据流是meta层用echarts.registerMap(china, geoJson)注册地图数据然后在series中指定map: china配合visualMap做颜色渐变echarts.registerMap(china, chinaGeoJson); const option { tooltip: { trigger: item, formatter: function(params) { return params.name br/指标值 (params.value || 0); } }, visualMap: { min: 0, max: 1000, left: 20, bottom: 20, inRange: { color: [#e0f3f8, #74add1, #4575b4] } }, series: [{ type: map, map: china, roam: false, label: { show: false }, data: provinceData }] };visualMap的min和max决定了色阶映射范围。不少第一次做地图的人会忽略这个参数导致所有省份都被映射到同一个色值以为是地图没加载出来。实际排查时优先看visualMap的值域是否覆盖了数据的真实分布区间。如果要实现热词里提到的“echarts echarts-gl - 使用 geo3d map3d scatter3d 做 3d 地图”那就要引入额外依赖。3D 地图的选项虽然视觉冲击力强但它有一个和实时大屏天然冲突的点map3d系列的重绘成本远高于普通map如果后端每 3 秒推一次数据且每次都全量 setOption浏览器的帧率会明显下降。我一般只在“总览 静态排名”场景用 3D 地图动态数据全部走 2D 地图或飞线图。3.4 图表 resize 与容器边界大屏最常见的白屏原因最后讲一个几乎每个用 Echarts 做全屏大屏的人都会遇到的经典问题页面从 1920x1080 切换到 1366x768或者浏览器缩放图表要么溢出容器边界要么变成一小块挤在左上角。通常的解法是监听window.resize并调用chart.resize()window.addEventListener(resize, function() { screenCharts.forEach(function(chart) { chart.resize(); }); });但这里有两个边界情况要补上。第一大屏如果是嵌套在iframe里的window.resize在 iframe 内部不能可靠触发更稳的做法是用ResizeObserver监听图表父容器的尺寸变化。第二如果你的布局里图表容器是display: none初始隐藏、切换 Tab 后才显示的init时宽度计算会是 0resize 也救不回来此时需要在容器显示后再调用chart.resize()或者在init前强制触发一次布局回流。排错套路很简单打开浏览器控制台执行document.querySelector(.chart-container).offsetWidth看返回值是否为 0为 0 就说明容器还没被真正渲染出来。4. 10 套行业大屏模板的拆解与二次开发从运动健康到互联网企业数据大屏4.1 案例矩阵与功能复用点这套资源的第一大价值在于它提供了 10 个行业场景的完整前端页面和后端业务数据结构。我把案例按“行业类型 → 核心图表组件 → 典型数据指标”整理成矩阵帮你快速定位哪个案例离你的实际项目最近行业模板核心图表组件典型动态指标复用建议运动健康折线图心率/步数、环形图卡路里消耗分布实时心率、当日步数、热量消耗物联网/IoT 设备数据展示酒店行业柱状图客房入住率、地图客源地域分布入住率、平均房价、RevPAR本地生活、连锁门店分析互联网企业数据中国地图用户分布、饼图流量来源、折线图UV/PVUV/PV、渠道占比、实时订单量运营数据中台、用户增长看板其他 7 套混合布局常用漏斗图、雷达图、仪表盘随行业不同而变按页面布局复用框架所有模板的前端页面结构都是有共性的页面整体是一个 CSS Grid 或绝对定位拼出来的大屏布局每个图表的容器 div 都有固定的 id 和宽高图表初始化代码集中在一个initCharts()方法里实时刷新逻辑统一收归到一个onMessage()分发函数。二次开发时把这套“统一初始化、统一分发”的机制保留下来替换掉具体页面内部的buildXxxOption()方法即可。4.2 从一套模板切换到另一套业务的最短路径以酒店行业模板为例后端推送的数据结构大致长这样{ occupancyRate: 82.5, todayOrders: 126, guestSource: [ { name: 华北, value: 42 }, { name: 华东, value: 28 }, { name: 华南, value: 18 }, { name: 其他, value: 12 } ] }如果你要换成自己的业务比如“工厂产线实时监控”最短的修改路径并不是大改代码而是做三次映射第一「数据源映射」——改后端推送的 JSON 字段名和值让它们符合你的业务含义第二「图表映射」——把 occupancyRate 从“住客率”改成“设备稼动率”把 guestSource 从“客源地分布”改成“产线故障类型占比”只需要调整对应 option 里 series 的 name 和工具提示的 formatter第三「视觉映射」——改color数组和backgroundColor但切忌把颜色改成超过 5 种的彩虹配色大屏亮色背景下超过 5 个色相会让人抓不住视觉重点。我实际操作时会先跑通最小闭环只保留一个数字指标比如“今日订单量”、一个图表比如“近 24 小时趋势”确认从后端定时任务到前端 setOption 的链路都正常后再逐步把其他图表从模板里搬回来。这样定位问题的时间会从小时级压缩到分钟级。4.3 模板里的公共模块图表工厂与统一数据分发10 套模板里反复出现的公共代码其实就是两个部分。第一个是“自动化图表工厂”它根据 DOM 容器的 id 自动初始化图表并挂到一个全局 Map 里方便后续统一管理const screenCharts new Map(); function createChartIfAbsent(containerId) { const container document.getElementById(containerId); if (!container) { return null; } if (!screenCharts.has(containerId)) { const chart echarts.init(container); screenCharts.set(containerId, chart); // 图表被销毁时自动从 Map 中移除避免内存泄漏 window.addEventListener(beforeunload, function() { chart.dispose(); screenCharts.delete(containerId); }); } return screenCharts.get(containerId); }第二个公共模块是统一分发函数。后端 WebSocket 推送来的数据会进到同一个入口根据消息里的type字段决定把数据转给哪几个图表function onScreenMessage(message) { const payload JSON.parse(message.body); if (payload.type screen-data) { updateSalesLine(payload.data); updateChannelPie(payload.data); updateChinaMap(payload.data); } else if (payload.type alarm-info) { appendAlarmList(payload.data); } }之所以要统一分发而不是在每个图表初始化时单独订阅一套 WebSocket 通道是因为浏览器对 WebSocket 连接数有限制同时浏览器进程里多个 WebSocket 连接会带来额外的心跳开销。一个页面一条连接、内部做消息路由是成本最低的架构。4.4 二次开发时的前后端联调约定这 10 套模板的联调约定比较隐式需要你在项目里主动固定下来不然换个人来接你的需求时字段名会对不上。我一般会在项目根目录放一个API_CONTRACT.md把每个推送事件类型的 JSON 示例、更新频率、对应渲染的图表清单列清楚。实际联调中经常出现的字段命名冲突是后端用onlineCount前端模板里写的是onlineUsers后端返回字符串1280前端要数字1280后端时间戳是秒级10 位前端new Date(timestamp)需要毫秒级13 位。这些在接真实数据的时候几乎必然踩一遍建议在onScreenMessage入口统一做类型清洗function sanitizePayload(payload) { return { onlineUsers: parseInt(payload.onlineUsers, 10) || 0, timestamp: payload.timestamp * 1000, // 秒转毫秒 salesAmount: parseFloat(payload.salesAmount).toFixed(2) }; }这里的sanitize函数只处理基础类型和单位不处理业务逻辑保证后端同事的命名习惯不被破坏。5. 同屏多图表场景下的性能调优与正确验证方法5.1 从 Performance 面板确认瓶颈在渲染层还是数据层大屏图表数量多起来以后“卡顿”是一个很模糊的描述。我的排查流程是固定的先在 Chrome DevTools 的 Performance 面板录制 10 秒交互看 Main 线程上的长任务主要消耗在哪个函数。如果长任务集中在updateScreenData之类的 JavaScript 逻辑里说明数据清洗或 DOM 操作有问题如果长任务塞满的是Paint/Layerize阶段说明 Echarts 的图形数量太多或图表容器尺寸过大导致每一帧的合成成本过高。这里给一个快速的自检指标对照普通的 Canvas 渲染图表单图表 500 个以内的数据点setOption耗时应当控制在 16ms 以内如果超过 50ms优先检查是不是开了animation的过度动画效果。实时刷新场景下我一般把全局动画时长缩短echarts.init(container, null, { renderer: canvas }); chart.setOption({ animationDuration: 300, animationDurationUpdate: 300, animationEasingUpdate: linear });animationDurationUpdate控制的是数据更新时旧图形过渡到新状态的动画时长animationEasingUpdate用linear而不是默认的cubicOut是为了让循环更新时视觉节奏更均匀不会因为缓动函数的起落让人感觉画面“一顿一顿”。5.2 dataZoom 在实时数据下的三个典型陷阱如果大屏页面里有“近 24 小时趋势”之类的图表通常会用到dataZoom组件。但实时数据 dataZoom 的组合有三个很隐蔽的坑。第一个dataZoom的endValue不会随着数据增长自动右移数据点超出窗口后你看到的是旧数据被挤走但 zoom 窗口本身还停在原有位置新数据看起来只是从右边冒出一个小尖角。常见做法是每次setOption后主动把窗口的 endValue 拨到最新位置。第二个dataZoom的startValue和endValue传的是“数据索引”还是“数值”取决于xAxis是category类型还是time类型。category 类型传索引time 类型传时间戳。混用时会发现窗口完全不受控制。第三个如果你用了dataZoom内部的inside模式用户在页面上拖拽缩放后代码里后续强制设置startValue的操作会被用户手势覆盖造成“我设置了但没生效”的假象。处理办法是把 zoom 事件里返回的 start/end 值存起来下次计算新窗口时基于用户当前窗口做增量平移而不是重置。chart.setOption({ dataZoom: [{ type: inside, xAxisIndex: 0, startValue: Math.max(0, data.length - 20), endValue: data.length - 1 }] });这里的data.length - 20表示窗口始终展示最近 20 个数据点Math.max(0, ...)防止初始数据不足 20 个时 startValue 变成负数。5.3 监控大屏实时链路是否健康的轻量方案最后分享一个不需要额外监控系统就能判断链路是否健康的小技巧。在前端的 WebSocketonmessage回调里记录相邻两条消息的时间戳差值如果连续 5 次差值超过正常推送间隔的 2 倍就在页面左上角渲染一个不显眼的红色圆点提示。类似这样let lastMsgTime 0; let delayCount 0; socket.onmessage function(event) { const currentTime Date.now(); if (lastMsgTime 0) { const gap currentTime - lastMsgTime; if (gap 6000) { delayCount; showDelayIndicator(delayCount); } else { delayCount 0; hideDelayIndicator(); } } lastMsgTime currentTime; // 此处再走正常的 onScreenMessage 分发 };gap 6000这个阈值假设后端Scheduled配置的推送间隔是 3 秒如果你的fixedRate改成了 5 秒阈值就该相应调成 10000。这行代码在后续运维时经常被忽略但它恰恰是判断“大屏数据是不是停了”最直接的手段——比监控后端日志更早暴露问题也比用户发现了再报障要体面得多。本文还有配套的精品资源点击获取
返回列表