SpringBoot+WebSocket+Echarts:构建实时数据大屏的完整实践
2026/9/17 5:09:38 网站建设 项目流程

简介:一套面向数据可视化开发者的源码合集,基于Echarts与Java SpringBoot实现动态实时大屏,覆盖运动健康、酒店行业、互联网企业数据分析等10个行业场景,可直接借鉴大屏布局、图表联动与实时数据刷新方案。包体共2000个文件、约43.29MB,其中包含1158个js脚本、273个json数据文件和148个html页面,另有150个css样式、30个java源码及配置文件,前端展示与后端逻辑分层清晰,便于按需提取。已有1699人学习,适合正在做可视化大屏项目或希望系统掌握Echarts+SpringBoot技术栈的开发者。通过案例可学习图表动态渲染、数据接口对接、大屏自适应等关键实现,甚至可将源码改造用于自己的业务看板。

1. 动态实时大屏的选型逻辑:为什么是 Echarts + SpringBoot

拿到这套包含 10 个行业案例的源码包时,我第一反应不是去看图表画得有多炫,而是先确认一件事:它的“实时”到底是怎么做出来的。拆完目录和核心代码后发现,这套范例的价值不在某一张图表的配置项,而在于它把“SpringBoot 后端持续产生/采集数据 → WebSocket 推送到前端 → Echarts 增量更新视图”这条链路做成了可复用的骨架,再套上运动健康、酒店、互联网企业等不同行业的指标模板。也就是说,你买的不是 10 张图,而是 10 份“如何让大屏上的数字自己动起来”的参考答案。这套资源适合两类人:一类是刚接触大屏项目、需要一套能跑通前后端完整链路的参考实现的初中级工程师;另一类是被领导要求“三天出个可视化大屏 demo”的杂活选手——你从案例里挑一个最贴近业务的行业模板,改数据源和指标名就能交差。下面从链路拆解开始,把每个环节的代码和配置逐个过一遍。

2. SpringBoot 后端实时数据链路的搭建:从定时任务到 WebSocket 推送

2.1 两种实时方案的取舍:HTTP 轮询与 WebSocket 推送

大屏的“动态效果”有两种主流实现路径。第一种是前端定时用axiosfetch去请求后端 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() { Map<String, 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 Map<String, Object> generateScreenData() { Map<String, 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。同一张大屏上如果反复用不同的数据结构调用setOption,series 列表会越积越长,图表渲染性能随之下降。

正确做法是,当后端推送的数据结构会发生变化,或者你需要彻底重置图表状态时,显式开启全量替换:

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数组做成的环形饼图比实心饼图更适合大屏:中心区域可以用来放总计数字或排名,也可以通过titletext配合left: 'center'实现指标名居中展示。

3.3 地图与 3D 扩展:从 echarts 中国地图到 echarts-gl 的边界

这套案例里有两处值得单独拎出来说的地图场景:一是用geomap系列展示不同省份的数值分布,比如互联网企业案例里的“全国用户分布”区域;二是通过echarts-glmap3d/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 }] };

visualMapminmax决定了色阶映射范围。不少第一次做地图的人会忽略这个参数,导致所有省份都被映射到同一个色值,以为是地图没加载出来。实际排查时优先看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时宽度计算会是 0,resize 也救不回来;此时需要在容器显示后再调用chart.resize(),或者在init前强制触发一次布局回流。排错套路很简单:打开浏览器控制台执行document.querySelector('.chart-container').offsetWidth,看返回值是否为 0,为 0 就说明容器还没被真正渲染出来。

4. 10 套行业大屏模板的拆解与二次开发:从运动健康到互联网企业数据大屏

4.1 案例矩阵与功能复用点

这套资源的第一大价值在于它提供了 10 个行业场景的完整前端页面和后端业务数据结构。我把案例按“行业类型 → 核心图表组件 → 典型数据指标”整理成矩阵,帮你快速定位哪个案例离你的实际项目最近:

行业模板核心图表组件典型动态指标复用建议
运动健康折线图(心率/步数)、环形图(卡路里消耗分布)实时心率、当日步数、热量消耗物联网/IoT 设备数据展示
酒店行业柱状图(客房入住率)、地图(客源地域分布)入住率、平均房价、RevPAR本地生活、连锁门店分析
互联网企业数据中国地图(用户分布)、饼图(流量来源)、折线图(UV/PV)UV/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控制的是数据更新时旧图形过渡到新状态的动画时长,animationEasingUpdatelinear而不是默认的cubicOut,是为了让循环更新时视觉节奏更均匀,不会因为缓动函数的起落让人感觉画面“一顿一顿”。

5.2 dataZoom 在实时数据下的三个典型陷阱

如果大屏页面里有“近 24 小时趋势”之类的图表,通常会用到dataZoom组件。但实时数据 + dataZoom 的组合有三个很隐蔽的坑。第一个:dataZoomendValue不会随着数据增长自动右移,数据点超出窗口后,你看到的是旧数据被挤走,但 zoom 窗口本身还停在原有位置,新数据看起来只是从右边冒出一个小尖角。常见做法是每次setOption后主动把窗口的 endValue 拨到最新位置。

第二个:dataZoomstartValueendValue传的是“数据索引”还是“数值”,取决于xAxiscategory类型还是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。这行代码在后续运维时经常被忽略,但它恰恰是判断“大屏数据是不是停了”最直接的手段——比监控后端日志更早暴露问题,也比用户发现了再报障要体面得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询