拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Spring Boot+Vue+ECharts 构建数据可视化大屏全栈项目实战

Spring Boot+Vue+ECharts 构建数据可视化大屏全栈项目实战

先说结论:这个项目看着是疫情主题,本质是一个标准的“数据采集 + 接口聚合 + 可视化大屏”全栈练手项目。就算现在疫情过去了,把它改造成“天气实时监控”“舆情热点监控”“股票行情看板”也就是换数据源的事。技术栈选得也很典型——Spring Boot 管后端接口,Vue 管前端渲染,两者配合刚好覆盖了一个真实业务系统从数据到展示的全部链路。

我当初做这个项目的时候踩了不少坑,从数据源选型到图表渲染性能都有很多值得记下来的细节。这篇博文就按我实操的顺序来写,从整体设计、后端实现、前端可视化、实时刷新机制到常见问题,尽量把每一个环节的“为什么这么做”和“踩了什么坑”都说清楚。

1. 项目整体设计与技术选型思路

1.1 为什么选 Spring Boot + Vue,而不是别的组合

先聊技术选型。市面上做这类监控系统的方案其实不少,Python 的 Flask + ECharts、Node.js 的 Express + React 都能做,但我最终还是选了 Spring Boot + Vue,原因很实在:

Spring Boot 在 Java 生态里属于“开箱即用”的代表。内嵌 Tomcat 不用单独配服务器,Spring Data JPA 或者 MyBatis 操作数据库很成熟,定时任务用@Scheduled一行注解就能搞定,HTTP 接口直接用@RestController暴露——这些特性让后端开发从零到跑通接口只需要半天时间。而且国内企业级项目里 Java 的存量非常大,做一套 Spring Boot 的后端,简历上写出来大家也认。

Vue 这边理由也简单:学习曲线平缓。相比 React 的 JSX 语法和手动管理状态更新,Vue 的模板语法更贴近传统 HTML 的写法,v-for、v-if、{{ }}插值这些指令,半天就能上手。配合 Element UI 或者 Ant Design Vue,后台管理界面的表格、表单、卡片组件都是现成的。最关键的是 Vue 的响应式数据绑定,让“后端数据到了之后自动更新页面”这件事变得非常自然——这正是监控系统的核心需求。

我见过有人用 JSP + jQuery 做类似系统,不能说不能用,但前后端代码耦合在一起,数据更新要手动拼接 DOM 字符串,做到后面非常痛苦。Vue 的双向绑定直接省掉了大量 DOM 操作的代码,维护性高了一个档次。

1.2 整体架构和功能模块拆解

来看整体架构,我画了一个很简单的三层结构:

浏览器(Vue 页面) ↓ HTTP/JSON Spring Boot 后端接口层 ↓ 定时任务采集数据 → 本地存储(MySQL / 内存缓存)

这个架构设计的核心思路是数据分层:最底层是数据采集模块(定时从数据源拉取数据),中间层是业务服务层(数据处理、按维度查询),最顶层是接口接入层(提供给前端的各种 REST API)。好处是每一层都可以独立替换——比如把数据源从 A 换成 B,只改采集模块,前端一行代码都不用动。

功能模块上,我这个系统做了这么几块:

  1. 全球/全国数据概览:总确诊、现有确诊、治愈、死亡四个核心指标,用数字卡片展示;
  2. 趋势走势分析:折线图展示每日新增确诊数、累计确诊数的时间变化趋势;
  3. 国内各省份分布:地图或者柱状图展示各省份的确诊数据;
  4. 实时数据刷新:每隔 30 秒自动从后端拉取最新数据,页面无刷新更新;
  5. 历史数据查询:支持按日期区间查询历史数据。

这个功能列表看上去很简单,但它是从真实需求里抽出来的。疫情监控的核心矛盾是“信息变化快”,所以“实时刷新”是刚需;“信息维度多”,所以要有概览和趋势两个视角;“信息可追溯”,所以要有历史查询。这些功能想清楚了,后面的开发才有方向。

1.3 开发环境和工具准备

实际开发之前先把环境列出来,免得装到一半才发现版本不兼容:

工具版本建议备注
JDK1.8 或 11Spring Boot 2.x 版本用 JDK 8 最稳
Spring Boot2.3.x 或 2.6.x2.3 是经典稳定版,2.6 功能更全
MySQL5.7 或 8.0存储每日快照数据
Maven3.6+后端依赖管理
Node.js14+前端构建环境
Vue CLI4.x 或 5.x脚手架工具
Element UI2.15+前端 UI 组件库
ECharts5.x图表库

提示:Spring Boot 3.x 和 2.x 的配置方式有很大差异,如果照着 2.x 的帖子写 3.x 的代码会踩不少坑。新手建议直接用 2.6.x 起步,资料最全,出了问题好搜。

2. 后端核心功能拆解与实现

2.1 数据从哪来:数据源的采集与标准化

疫情数据不像普通业务数据能自己造,必须得有可靠来源。我当时调研了几条路:

  • 公开的疫情 API 接口:当时有一些第三方汇总平台提供 JSON 接口,字段齐全,每天更新。但是这些接口的稳定性参差不齐,有些后来直接关掉了。
  • 爬虫抓取:从丁香医生、百度疫情地图等页面爬数据。问题是很多页面是动态渲染的,直接用 HttpClient 抓不到内容,得模拟浏览器,成本很高。
  • 手动录入 + 定时模拟:适合新手,用一个固定的 JSON 文件模拟数据源,系统按格式解析入库。虽然不“真实”,但整套技术流程是一样的。

我最后选的方案是:优先使用公开 JSON API,同时做好数据源异常兜底。在DataFetchTask里配置了一个数据源列表,第一个失败就尝试第二个,全部失败就保留上一次的数据,保证页面不会因为上游故障而空掉。

采集到的原始数据格式往往很乱——有些字段叫confirmed,有些叫diagnosed,还有的按国家维度和按省份维度混在一起。我写了一个DataNormalizer类专门做标准化,统一转成内部定义的CountryData和ProvinceData结构。这一步很重要,因为它把“上游数据源”和“内部业务”解耦了,以后换数据源只要替换采集器,不用动业务逻辑。

下面是标准化数据结构的核心代码:

public class CountryData { private String countryName; // 国家名称 private Integer confirmed; // 累计确诊 private Integer suspected; // 疑似(如有) private Integer cured; // 治愈 private Integer dead; // 死亡 private String updateTime; // 数据更新时间 } public class ProvinceData { private String provinceName; // 省份名称 private Integer confirmed; // 确诊 private Integer cured; // 治愈 private Integer dead; // 死亡 private String updateTime; }

2.2 数据存储设计:MySQL 还是内存缓存?

存储方案我纠结了一阵子。最直接的想法是每次从上游拉完数据直接返回给前端,不留存。但这样做有两个问题:一是上游接口万一挂了,前端就白屏了;二是没法做“历史趋势”功能——你必须有历史数据才能画折线图。

最后的方案是 MySQL + 缓存两层结构:

  • MySQL 表存储每日快照:每天定时把当天全国和各省份的数据写入一张daily_data表;
  • 常用查询走内存缓存:最近一次的全量数据存在ConcurrentHashMap里,接口直接读缓存,响应速度控制在 10ms 以内。

表结构设计如下:

CREATE TABLE daily_data ( id BIGINT AUTO_INCREMENT PRIMARY KEY, data_date DATE NOT NULL, -- 数据日期 country_name VARCHAR(50) NOT NULL, -- 国家/地区 province_name VARCHAR(50), -- 省份(国家维度时为空) confirmed INT DEFAULT 0, -- 确诊 suspected INT DEFAULT 0, -- 疑似 cured INT DEFAULT 0, -- 治愈 dead INT DEFAULT 0, -- 死亡 update_time DATETIME, -- 更新时间 UNIQUE KEY uk_date_country (data_date, country_name, province_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

UNIQUE KEY是防止定时任务重复执行时插入重复数据的关键设计,配合INSERT ... ON DUPLICATE KEY UPDATE,更新很方便。

2.3 定时任务与缓存策略的搭配

Spring Boot 的定时任务特别简单,一个注解搞定:

@Component public class DataFetchTask { @Scheduled(cron = "0 0 */2 * * ?") // 每两小时执行一次 public void fetchData() { // 1. 从外部API拉取数据 // 2. 标准化处理 // 3. 写入MySQL // 4. 更新内存缓存 } @Scheduled(cron = "0 30 */2 * * ?") // 每两小时的第30分钟执行 public void refreshTrendData() { // 重新计算近30天趋势数据 } }

注意两个定时任务之间隔了 30 分钟,这样设计是为了给数据抓取留足完成时间,避免两个任务并发操作数据引起冲突。

缓存这块我用了一个很朴素的写法,没上 Redis。因为疫情数据本身的量级很小(全球两百多个国家 + 国内三十多个省份),单机内存完全扛得住。Redis 的优势在分布式多实例场景下才明显,单实例做监控系统用本地缓存更简单。核心代码如下:

@Service public class DataCacheService { private final Map<String, Object> cacheMap = new ConcurrentHashMap<>(); public void updateOverview(OverviewData data) { cacheMap.put("overview", data); } public OverviewData getOverview() { return (OverviewData) cacheMap.get("overview"); } }

2.4 后端接口怎么设计:给小前端一个好用的 API

接口设计直接决定了前端好不好写。我的原则是**“接口语义化 + 返回结构统一”**。先说统一返回结构,我定义了一个Result<T>:

public class Result<T> { private Integer code; // 200 成功,500 失败 private String message; // 提示信息 private T data; // 数据 }

前端判断code === 200然后取data,所有的接口都走这个格式,axios 封装里统一拦截,代码清爽很多。

具体接口列表如下:

接口路径方法说明
/api/overviewGET获取全国/全球概览数据卡片
/api/trend?days=30GET获取近 N 天趋势数据
/api/provincesGET获取各省份最新数据
/api/history?start=2023-01-01&end=2023-01-31GET历史数据查询

接口的粒度怎么把握?一开始我把所有数据塞进一个/api/all大接口里,前端一次拉齐,倒是省事。但后来发现一个严重问题:每次刷新连历史趋势数据一起拉,响应体很大,前端解析也慢。后来拆成了上面这几个细粒度接口,各自负责各自的模块,还可以针对高频接口单独做缓存优化。

3. 前端可视化与交互实现

3.1 前端工程初始化与路由设计

Vue 这边我用了 Vue CLI 创建项目,选Router和Axios插件。项目结构大概长这样:

src/ ├── api/ # 接口请求模块 │ ├── overview.js │ ├── trend.js │ └── provinces.js ├── assets/ # 静态资源 ├── components/ # 组件 │ ├── OverviewCards.vue │ ├── TrendChart.vue │ ├── ProvinceMap.vue │ └── DataTable.vue ├── router/ # 路由配置 │ └── index.js ├── views/ │ ├── Dashboard.vue # 主看板页 │ └── History.vue # 历史查询页 └── App.vue

路由设计这里说说我的思路。监控系统的界面很简单,不需要复杂的权限管理,所以我只设了两条路由:

const routes = [ { path: '/', name: 'Dashboard', component: Dashboard }, { path: '/history', name: 'History', component: History } ]

没做动态路由、没做懒加载,因为项目总共就两个页面,懒加载的收益不大,反而增加复杂度。路由设计永远要跟着项目规模走,不是越复杂越好。

3.2 ECharts 图表的接入与动态数据渲染

图表部分选了 ECharts 5.x,这是国内做数据可视化事实上的标准库。官方文档很友好,社区案例也多,遇到奇怪需求基本都能搜到答案。

接入步骤很简单,三步走:

第一步,安装依赖:

npm install echarts --save

第二步,在组件里引入并用ref绑定 DOM 容器:

<template> <div ref="trendChart" style="width: 100%; height: 400px;"></div> </template> <script> import * as echarts from 'echarts' export default { name: 'TrendChart', data() { return { chart: null } }, mounted() { this.chart = echarts.init(this.$refs.trendChart) this.loadTrendData() }, methods: { async loadTrendData() { const res = await this.$api.getTrendData(30) this.renderChart(res.data) }, renderChart(data) { this.chart.setOption({ tooltip: { trigger: 'axis' }, legend: { data: ['累计确诊', '新增确诊'] }, xAxis: { type: 'category', data: data.dates }, yAxis: { type: 'value' }, series: [ { name: '累计确诊', type: 'line', data: data.cumulative }, { name: '新增确诊', type: 'bar', data: data.daily } ] }) } } } </script>

这段代码有几个细节要注意:图表实例必须在mounted阶段初始化,因为ref只有在组件挂载完成后才能拿到 DOM;setOption是增量更新,如果数据刷新直接再调用setOption就行,不需要销毁重建。

3.3 数据刷新逻辑:setInterval 定时轮询

实时监控系统的“实时”究竟怎么实现,这里是我踩坑比较多的地方。

一开始我想到的是 WebSocket 长连接,服务端一有新数据就推给前端。但后来仔细一分析,这个场景其实是低频数据更新——疫情数据不像股票行情那样每秒都在变,更合理的频率是几分钟甚至几小时更新一次。用 WebSocket 是杀鸡用牛刀,还要额外处理连接断线重连、心跳检测这些麻烦事。

所以我用了最简单的setInterval 轮询:

mounted() { this.fetchData() this.timer = setInterval(() => { this.fetchData() }, 30000) // 每30秒刷新一次 }, beforeDestroy() { clearInterval(this.timer) // 组件销毁前清除定时器 }

这个beforeDestroy里的clearInterval千万别漏。我第一次做的时候忘了清除定时器,结果从首页切到历史页再切回来,定时器叠加触发,接口请求量翻倍增长,还影响了图表更新的流畅度。现在养成习惯:凡是 setInterval 必配 beforeDestroy 清理。

3.4 大屏响应式布局与卡片组件

监控系统最常见的形态是大屏展示。大屏布局的第一原则是:不能让图表因为窗口大小变化而变形或者留白。

我用的是 Element UI 的el-row和el-col栅格布局,24 列栅格配合响应式断点:

<el-row :gutter="20"> <el-col :xs="24" :sm="12" :lg="6"> <OverviewCard title="累计确诊" :value="overview.confirmed" color="#f56c6c" /> </el-col> <el-col :xs="24" :sm="12" :lg="6"> <OverviewCard title="现有确诊" :value="overview.current" color="#e6a23c" /> </el-col> <el-col :xs="24" :sm="12" :lg="6"> <OverviewCard title="治愈人数" :value="overview.cured" color="#67c23a" /> </el-col> <el-col :xs="24" :sm="12" :lg="6"> <OverviewCard title="死亡人数" :value="overview.dead" color="#909399" /> </el-col> </el-row>

图表响应式还有一个坑:ECharts 初始化以后,如果浏览器窗口大小变了,图表不会自动跟着变,需要自己监听 resize 事件:

mounted() { window.addEventListener('resize', () => { this.chart && this.chart.resize() }) }

4. 实时刷新机制、部署与性能优化

4.1 轮询 vs WebSocket:这个场景该怎么选

前面提到我最后选了轮询,但这里想多说一句架构决策的思路,因为很多人在“到底用轮询还是长连接”上纠结。

判断标准很简单:数据更新的实时性要求有多高?如果要求是“秒级以下”(比如股票行情、在线聊天),必须上 WebSocket;如果是“分钟级以上”(疫情数据、天气数据、服务器监控面板),轮询就是最简单可靠的方案。轮询还有两个隐含好处:无状态,任何一次请求失败都不会影响下一次;天然兼容 HTTP 协议,不需要额外处理跨域和代理配置。

如果想优化轮询的效率,可以用一个技巧:通过后端返回的数据更新时间判断是否真的需要更新页面。比如接口返回里带一个updateTime字段,前端存一个lastUpdateTime,如果两次updateTime没变化,就直接跳过图表渲染。这样能避免数据没变的时候前端反复渲染图表消耗性能。

4.2 前后端分离部署:Vue 打包后放进 Spring Boot 的两种方式

项目做完要部署给别人看,前后端怎么合到一起是个经典问题。有两种方案:

方案一:独立部署(开发环境常用)

前端npm run serve跑在 8080 端口,后端mvn spring-boot:run跑在 9090 端口,前端通过vue.config.js配置代理转发接口请求:

module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true } } } }

这样做的好处是前后端热更新互不影响,开发效率高。

方案二:单包部署(生产环境常用)

前端执行npm run build,生成的dist目录里是静态文件,把整个dist文件夹拷贝到 Spring Boot 项目的src/main/resources/static/目录下,重新打包:

mvn clean package -DskipTests

打出来的 jar 包自带前端页面,部署到服务器上只需要一个 Java 进程。这种方式对没有独立 Nginx 的服务器特别友好。

我在生产环境采用的是方案二,但踩了一个路径坑:Vue 打包默认资源路径是/,如果后端有 context-path 配置,静态资源会找不到。解决办法是在vue.config.js里配publicPath: './',用相对路径引用资源。

4.3 接口响应时间优化:比想象中速度提升一倍

做了性能测试之后发现接口响应时间偏慢,问题出在每次请求都查 MySQL,而且数据还要做一次 JSON 序列化。优化手段有三个:

第一,数据缓存。前面已经提到,热点数据放内存,接口不再打数据库。

第二,JSON 序列化瘦身。用 Jackson 的@JsonInclude(JsonInclude.Include.NON_NULL)注解,空字段不序列化,响应体体积减少 30% 左右。

第三,全局 Gzip 压缩。在application.yml配置:

server: compression: enabled: true mime-types: application/json,application/xml,text/html,text/plain min-response-size: 2048

配置之后响应体大于 2KB 就自动压缩,实测接口响应时间从 200ms 降到 80ms 左右。这些优化操作都很简单,但对用户体验的提升非常明显。

4.4 部署服务器时的注意事项

部署到云服务器上运行时,需要处理几个经常被忽略的问题:

防火墙要开端口。如果用的是云服务器,除了在系统层firewall-cmd或者ufw里放行端口,很多人忘了安全组也要同步放行,导致端口明明开着却访问不了,排查半天。

数据库连接串不要写死在代码里。用环境变量注入:

spring: datasource: url: ${DB_URL:jdbc:mysql://localhost:3306/epidemic?useSSL=false&serverTimezone=Asia/Shanghai} username: ${DB_USER:root} password: ${DB_PASSWORD:root}

这样部署到不同环境只要改环境变量就行,不需要重新打包。带上serverTimezone=Asia/Shanghai这个参数很关键,否则 MySQL 连接会出现 8 小时时差问题。

5. 常见问题与排查心得

5.1 问题速查表

这个表是我开发过程中真实踩过的坑,整理出来方便后来人快速定位问题:

问题现象根本原因解决方案
前端请求接口报 404后端接口路径写错或未重启检查@RequestMapping路径;确认接口启动日志中已注册
前端请求接口报 403跨域未允许后端添加 CORS 配置类;或前端用代理转发
图表不显示ECharts 容器没有高度容器必须有固定的height样式,不能只写100%
数据不刷新定时器被清除或组件被销毁重建检查beforeDestroy是否误清;确保定时器在正确生命周期中创建
日期数据差 8 小时JDBC 时区未配置数据源 URL 加serverTimezone=Asia/Shanghai
页面首次加载慢请求按顺序逐个发出用Promise.all并行请求各模块接口
后端启动报端口被占用8080 被其他程序占用修改端口或查找占用进程:netstat -ano | findstr 8080

5.2 跨域问题的处理过程

开发阶段最先遇到的就是跨域报错。浏览器控制台里一行红字:Access to XMLHttpRequest at 'http://localhost:9090/api/overview' from origin 'http://localhost:8080' has been blocked by CORS policy。

解决办法有很多种,我推荐在 Spring Boot 里加一个全局配置类,正规且一劳永逸:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:8080") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .maxAge(3600); } }

注意allowedOrigins不要用*,因为浏览器跨域携带 Cookie 时不支持通配符。开发环境写死前端地址,生产环境因为有代理或者单包部署,其实就没什么跨域问题了。

5.3 图表性能问题:数据量增大后的卡顿

还有一次印象比较深的排查,是趋势图数据量增大以后出现卡顿。图表从 30 天数据改成查询一整年数据,折线图上 365 个点,拖动缩放时明显掉帧。

排查后确定问题出在数据点的渲染上。ECharts 的折线图默认会渲染所有数据点,但屏幕物理像素宽度就那么多,数据点密集到一定程度就会重叠。解决办法是开启sampling降采样,让 ECharts 自动保留关键点、忽略视觉上无差异的中间点:

series: [{ name: '累计确诊', type: 'line', data: this.cumulative, sampling: 'lttb', // 使用 Largest-Triangle-Three-Buckets 算法 symbol: 'none' // 数据点太密集时隐藏圆点标记 }]

加了这两个配置之后,一整年的数据渲染照样流畅。这也是 ECharts 比较有意思的地方——很多性能问题并不是调不了,而是不知道有对应的优化配置。

5.4 数据源失效时的兜底方案

项目运行过程中遇到的最头痛问题就是上游公开 API 突然失效。第一次碰到是在某天早上,打开页面一看数据还是昨天的,排查发现是定时任务夜里执行时接口返回 502。

从那以后我做了三件事:

第一,定时任务里加 try-catch,失败时记录日志但不抛出异常,避免整个定时任务线程中断;

第二,增加多数据源自动切换,配置两个 API 地址,第一个失败自动请求第二个;

第三,数据查询接口做“降级处理”,如果当天数据没有更新,自动回退返回最近一天的有效数据,保证前端页面不会出现空白。

这套兜底逻辑看似简单,但实际运行时非常有用。上线三个月,最长一次数据源连续失效 12 小时,页面上始终显示的是最新的有效数据,基本无感知。

6. 项目扩展与复盘

6.1 这个项目还能扩展成什么

虽然这个项目本身是疫情主题,但它的技术骨架完全可以复用到其他场景。我列几个改造成本极低的方向:

天气实时监控:把数据源改成天气 API,字段换成温度、湿度、风力、空气质量,前端图表换成天气预报卡片和温度趋势线。

商品价格监控:定时爬取电商平台价格,展示价格走势折线图和历史价格区间。这个场景对“历史数据查询”和“趋势分析”的依赖更强,项目后端可以完全复用。

服务器性能监控:定时采集 CPU、内存、磁盘数据,前端用仪表盘展示实时负载,历史数据用折线图展示趋势。这个方向的商业化价值更高,做出来可以直接部署在公司内部使用。

扩展的方法是固定的:换数据源、换字段映射、换前端展示组件。这就是当初架构分层设计带来的好处。

6.2 个人复盘:什么值得坚持,什么值得改进

做完这个项目,有几点体会特别深。

第一,先设计数据结构再写代码,能省一半的返工时间。我在项目前期急着跑通流程,没仔细想数据表的字段设计,结果后面加“省份数据”维度时发现表结构承载不了,不得已重构了表结构。如果一开始就按照“日期 + 地区 + 指标”三个维度去设计,就不会有这个问题。

第二,接口响应结构要一早就统一。我最初几个接口返回格式不一致——有的返回 JSON 对象,有的返回数组,害得前端写了很多 if 判断去兼容。后来统一成Result<T>结构,前端 axios 封装里统一处理,代码清晰很多。

第三,定时任务的执行时间要有日志记录。每次执行完记录一下执行时间、数据源状态、数据量,排查问题的时候能省很多事。我一开始偷懒没做,后面出问题只能对着空日志瞎猜,非常浪费时间。

第四,前后端联调的时候,mock 数据真的能救命。在真实数据源还没接好的时候,先用静态 JSON 把前端页面全部调完,后期接入真实数据几乎零改动。Vue 项目的mock插件用起来非常简单,开一个开关就能切换 mock 模式和真实接口模式。

最后再分享一个画面之外的经验:这个项目从开发到部署大概花了 5 天时间,其中后端 2 天、前端 2 天、联调部署 1 天。如果你也是第一次做前后端分离的完整项目,这个时间预算可以直接参考。核心流程跑通比细节打磨更重要,先把骨架搭起来,再慢慢往里面填肉,整个过程就不会那么焦虑。

返回列表