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只改采集模块前端一行代码都不用动。功能模块上我这个系统做了这么几块全球/全国数据概览总确诊、现有确诊、治愈、死亡四个核心指标用数字卡片展示趋势走势分析折线图展示每日新增确诊数、累计确诊数的时间变化趋势国内各省份分布地图或者柱状图展示各省份的确诊数据实时数据刷新每隔 30 秒自动从后端拉取最新数据页面无刷新更新历史数据查询支持按日期区间查询历史数据。这个功能列表看上去很简单但它是从真实需求里抽出来的。疫情监控的核心矛盾是“信息变化快”所以“实时刷新”是刚需“信息维度多”所以要有概览和趋势两个视角“信息可追溯”所以要有历史查询。这些功能想清楚了后面的开发才有方向。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 MapString, Object cacheMap new ConcurrentHashMap(); public void updateOverview(OverviewData data) { cacheMap.put(overview, data); } public OverviewData getOverview() { return (OverviewData) cacheMap.get(overview); } }2.4 后端接口怎么设计给小前端一个好用的 API接口设计直接决定了前端好不好写。我的原则是**“接口语义化 返回结构统一”**。先说统一返回结构我定义了一个ResultTpublic class ResultT { private Integer code; // 200 成功500 失败 private String message; // 提示信息 private T data; // 数据 }前端判断code 200然后取data所有的接口都走这个格式axios 封装里统一拦截代码清爽很多。具体接口列表如下接口路径方法说明/api/overviewGET获取全国/全球概览数据卡片/api/trend?days30GET获取近 N 天趋势数据/api/provincesGET获取各省份最新数据/api/history?start2023-01-01end2023-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 reftrendChart stylewidth: 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只有在组件挂载完成后才能拿到 DOMsetOption是增量更新如果数据刷新直接再调用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 :gutter20 el-col :xs24 :sm12 :lg6 OverviewCard title累计确诊 :valueoverview.confirmed color#f56c6c / /el-col el-col :xs24 :sm12 :lg6 OverviewCard title现有确诊 :valueoverview.current color#e6a23c / /el-col el-col :xs24 :sm12 :lg6 OverviewCard title治愈人数 :valueoverview.cured color#67c23a / /el-col el-col :xs24 :sm12 :lg6 OverviewCard title死亡人数 :valueoverview.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?useSSLfalseserverTimezoneAsia/Shanghai} username: ${DB_USER:root} password: ${DB_PASSWORD:root}这样部署到不同环境只要改环境变量就行不需要重新打包。带上serverTimezoneAsia/Shanghai这个参数很关键否则 MySQL 连接会出现 8 小时时差问题。5. 常见问题与排查心得5.1 问题速查表这个表是我开发过程中真实踩过的坑整理出来方便后来人快速定位问题问题现象根本原因解决方案前端请求接口报 404后端接口路径写错或未重启检查RequestMapping路径确认接口启动日志中已注册前端请求接口报 403跨域未允许后端添加 CORS 配置类或前端用代理转发图表不显示ECharts 容器没有高度容器必须有固定的height样式不能只写100%数据不刷新定时器被清除或组件被销毁重建检查beforeDestroy是否误清确保定时器在正确生命周期中创建日期数据差 8 小时JDBC 时区未配置数据源 URL 加serverTimezoneAsia/Shanghai页面首次加载慢请求按顺序逐个发出用Promise.all并行请求各模块接口后端启动报端口被占用8080 被其他程序占用修改端口或查找占用进程netstat -ano | findstr 80805.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 判断去兼容。后来统一成ResultT结构前端 axios 封装里统一处理代码清晰很多。第三定时任务的执行时间要有日志记录。每次执行完记录一下执行时间、数据源状态、数据量排查问题的时候能省很多事。我一开始偷懒没做后面出问题只能对着空日志瞎猜非常浪费时间。第四前后端联调的时候mock 数据真的能救命。在真实数据源还没接好的时候先用静态 JSON 把前端页面全部调完后期接入真实数据几乎零改动。Vue 项目的mock插件用起来非常简单开一个开关就能切换 mock 模式和真实接口模式。最后再分享一个画面之外的经验这个项目从开发到部署大概花了 5 天时间其中后端 2 天、前端 2 天、联调部署 1 天。如果你也是第一次做前后端分离的完整项目这个时间预算可以直接参考。核心流程跑通比细节打磨更重要先把骨架搭起来再慢慢往里面填肉整个过程就不会那么焦虑。
返回列表