Vue+WebSocket+ECharts:实时温湿度监控系统实战全解析

Vue+WebSocket+ECharts:实时温湿度监控系统实战全解析 不卖关子先说说这个项目的背景。一个典型的温湿度监控系统核心链路无非是传感器采集、数据传输、前端展示、异常告警这几环。但在实际落地时很多开发者的第一反应是用轮询不就行了结果做出来的页面在数据刷新时会卡顿、闪烁历史曲线加载慢告警延迟明显。我这次用Vue把这条链路完整打通采用的是后端主动推送 前端被动渲染的实时通信方案整体跑下来很稳这篇文章就把整个系统的设计思路、核心代码、踩坑过程全部摊开来讲希望能给正在做类似物联网可视化项目的朋友一些直接能用的参考。适合谁来读一类是刚接触Vue生态、想做一个完整实战项目的新手另一类是从传统管理系统转向实时数据可视化场景的开发者。前者可以顺着文章把项目从零搭起来后者可以重点关注数据通信方案和性能优化的部分。1. 系统整体架构为什么选Vue做实时监控前端1.1 技术选型的核心考量先说结论实时温湿度监控这种场景前端技术上最优解就是Vue WebSocket ECharts的组合。为什么这么说我分三层来讲。第一层是数据特性。温湿度数据是典型的时序数据每秒钟都在产生新值而且传感器上报频率可能并不固定——有些工业级传感器在温度突变时会自动加密上报频率从1秒一次变成200毫秒一次。这种动态变化的数据流如果用HTTP轮询去拉前端很难判断下一次请求什么时候发才能既保证实时性又不浪费带宽。WebSocket天然就是为这种场景设计的连接建立后服务端可以随时主动推送数据前端只需要被动接收、更新视图即可。第二层是Vue的响应式机制。Vue最强大的地方在于数据驱动视图当新的温湿度数据到达时只需要更新响应式数据对象页面上的DOM会自动同步不需要手动操作节点。在实时监控场景里这意味着一秒可能触发几十次视图更新如果每次都手动操作DOM元素代码很快会变成一团乱麻而且性能上限很低。用Vue的响应式系统开发者的关注点可以全部放在数据怎么来、怎么处理上视图层的更新完全交给框架。第三层是生态成熟度。Vue的生态里有成熟的路由方案Vue Router、状态管理方案Pinia、UI组件库Element Plus而ECharts作为国内最流行的可视化库对Vue的适配也已经非常完善。更重要的是Vue的社区资料非常丰富遇到问题基本都能搜到解决方案这对做实时监控这种长周期项目来说意味着维护成本可控。1.2 前后端分离架构下的数据链路设计整个系统的架构分成三部分数据采集层、服务端、前端展示层。我在做这个项目时采用的是前后端分离架构前端是Vue 3 Vite后端是Spring Boot两者通过WebSocket通信。数据采集层通常有两种方案。一种是直接对接硬件传感器通过MQTT协议把数据发送到服务端服务端再转发给WebSocket客户端另一种是服务端自己模拟数据生成方便开发调试。我在项目初期用的是模拟数据方案服务端每隔一定周期生成一组温湿度数据通过WebSocket推送给前端。服务端的职责很清晰维护WebSocket连接、接收采集数据、把数据广播给所有连接的客户端。这里有一个设计要点——服务端和采集层之间的通信协议是私有的但服务端和前端之间的通信协议必须明确定义。我定义的WebSocket消息格式是这样的{ type: realtimeData, timestamp: 2025-01-15 14:30:00, data: { temperature: 26.5, humidity: 58.2 } }type字段用来区分消息类型timestamp是数据产生时间data里面放具体的温湿度数值。这个设计在后面扩展告警功能时帮了大忙——告警消息只需要新增一个type类型就行前端的消息分发逻辑不用做大的改动。前端在这一架构中的角色是被动接收者 可视化呈现者。Vue负责接收WebSocket消息、把数据注入响应式系统、驱动页面更新ECharts负责把数据渲染成实时曲线告警模块根据阈值规则判断当前数据是否异常。1.3 项目初始化与环境配置细节项目采用Vite作为构建工具创建Vue 3项目的方式很简单npm create vitelatest greenhouse-monitor -- --template vue创建成功后安装核心依赖npm install vue-router4 pinia element-plus echarts这里有一个需要注意的地方ECharts的引入方式。如果整个页面只需要一个图表实例可以直接在组件里全量引入ECharts。但如果项目里有多个图表、且对首屏加载速度有要求推荐使用按需引入的方式只加载用到的图表类型和组件能显著减小打包体积。我的实际做法是在utils目录下封装一个echarts.js模块import * as echarts from echarts/core import { LineChart } from echarts/charts import { GridComponent, TooltipComponent, LegendComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]) export default echarts这样打包出来的体积比全量引入少了大约60%。另外一个容易被忽略的配置是Vite的代理设置。在开发阶段前端和后端通常是两个端口分别运行前端是5173后端是8080如果前端直接请求后端的WebSocket接口会碰到跨域问题。在 vite.config.js 里配置代理是最省事的方案export default defineConfig({ plugins: [vue()], server: { proxy: { /ws: { target: http://localhost:8080, ws: true, changeOrigin: true } } } })注意ws: true这个选项没有它WebSocket代理会连接失败这个坑我刚开始踩过。2. 实时数据通信WebSocket连接的生命周期管理2.1 前端WebSocket封装的核心逻辑WebSocket的连接管理比很多人想象的要复杂一些。最基本的需求是建立连接、接收消息、关闭连接但实际项目中还需要考虑断线重连、心跳保活、消息积压等场景。我在项目中封装了一个独立的composable模块放在src/composables/useWebSocket.js里核心思路是把连接状态管理和业务逻辑解耦。import { ref, onUnmounted } from vue export function useWebSocket(url) { const isConnected ref(false) const latestMessage ref(null) let socket null let reconnectTimer null let heartbeatTimer null const reconnectInterval 3000 const heartbeatInterval 15000 function connect() { socket new WebSocket(url) socket.onopen () { isConnected.value true startHeartbeat() } socket.onmessage (event) { latestMessage.value JSON.parse(event.data) } socket.onclose () { isConnected.value false stopHeartbeat() scheduleReconnect() } socket.onerror (error) { console.error(WebSocket error:, error) } } function startHeartbeat() { heartbeatTimer setInterval(() { if (socket socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: heartbeat })) } }, heartbeatInterval) } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer) heartbeatTimer null } } function scheduleReconnect() { if (reconnectTimer) return reconnectTimer setTimeout(() { reconnectTimer null connect() }, reconnectInterval) } function close() { stopHeartbeat() if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer null } if (socket) { socket.close() } } onUnmounted(close) return { isConnected, latestMessage, connect } }这段代码的要点在于连接关闭后不能立即重连一定要有延迟机制否则后端在重启时前端会高频重连把后端的连接池打爆看起来就像DDoS攻击一样。2.2 心跳保活的底层原因为什么需要心跳保活很多人可能直接抄了这段代码但不知道原因。WebSocket虽然号称是长连接但中间经过的负载均衡器、网关往往会设置空闲连接超时时间比如Nginx默认的proxy_read_timeout是60秒如果连接上一段时间没有数据传输网关会主动把连接断开。但断开之前客户端和服务端都感知不到直到下一次发送数据时才发现连接已经断了而这个时候再重连就会产生一段假在线时间最直接的后果是数据断档。心跳机制就是定期发送一个很小的数据包让连接保持活跃状态。我设定的是每15秒发送一次心跳这个值的选择是有讲究的——比Nginx的空闲超时时间短一半以上同时不至于因为太频繁浪费带宽。在温湿度监控这类低频数据场景里如果传感器的数据上报间隔是5秒理论上不需要心跳也能保鲜但为了应对数据上报频率不稳定的情况加上心跳更稳妥。2.3 断线重连时的数据补发问题断线重连有一个很容易被忽视的问题重连成功之后前端展示的数据是一段时间前的旧数据还是最新的实时数据如果服务端只在收到新采集数据时才推送前端重连后会有一段空白期。我在服务端增加了断线补发的逻辑——客户端重连成功后先发送一个订阅消息带上最后一次收到数据的时间戳服务端根据这个时间戳从缓存队列中补发错过的数据。这个设计的价值体现在网络抖动的时候。假设客户端的WiFi断了10秒在这10秒里服务端照常采集温度数据、丢弃了一部分推送等重连成功后如果没有补发机制前端的时间轴上会出现一个缺口用户看到的效果就是曲线中间断了一截非常影响体验。当然如果补发数据量太大比如断线了5分钟那也完全没有补发的必要直接清空图表重新开始更合理。我在前端做了一个判断如果最后一次数据时间距离当前时间超过2分钟直接重置图表。3. 数据可视化ECharts实时曲线的渲染细节3.1 数据进图表的正确姿势温湿度数据可视化的核心挑战不是画图而是如何在高频数据流下保持图表流畅。直接往ECharts的setOption里塞数据是最简单的做法但数据量大了之后会越来越卡原因在于ECharts内部需要对全量数据进行重绘计算。我的做法是维护一个固定长度的环形缓冲区比如最多保留最近500个数据点。新数据到达时shift掉最早的数据push新的数据进去。这样做有两个好处一是内存占用恒定不会随着运行时间增长二是ECharts重绘时数据量是固定的渲染耗时可控。在Vue里的具体实现是这样的const MAX_POINTS 500 const timeAxisData ref([]) const tempData ref([]) const humiData ref([]) function appendRealtimeData(message) { const timestamp message.timestamp const temperature message.data.temperature const humidity message.data.humidity timeAxisData.value.push(timestamp) tempData.value.push(temperature) humiData.value.push(humidity) if (timeAxisData.value.length MAX_POINTS) { timeAxisData.value.shift() tempData.value.shift() humiData.value.shift() } }然后watch这些数据变化调用图表的更新逻辑watch([timeAxisData, tempData, humiData], () { chart.setOption({ xAxis: { data: timeAxisData.value }, series: [ { name: 温度, data: tempData.value }, { name: 湿度, data: humiData.value } ] }) })这里有一个性能细节setOption默认会进行diff计算这在数据量小的时候无所谓但数据量大时反而有额外开销。对于实时刷新的场景我推荐在setOption时传入第二个参数不替换、第三个参数设为true也就是chart.setOption(option, false, true)告诉ECharts不要做merge直接替换数据能明显提升刷新速度。3.2 双Y轴的设计与刻度联动温度和湿度是两个维度的数据量纲不同。温度通常在-20到50摄氏度之间波动湿度通常在0到100%RH之间。如果画在同一个Y轴上要么温度曲线很扁要么湿度曲线溢出边界。所以这里必然要用双Y轴——左边显示温度右边显示湿度。一个常见的误区是以为设置了两个yAxis就行但实际上双Y轴的刻度比例如果不联动会误导观看者对数据波动的判断。比如温度变化了1度湿度变化了1%如果两个Y轴的刻度范围不一样曲线在视觉上的波动幅度可能完全相同这就造成了误导。ECharts提供了yAxis的scale属性来让Y轴从0开始自适应但我更推荐的做法是使用axisPointer的联动让鼠标悬停时十字光标同时在两个Y轴上显示对应值axisPointer: { link: [{ xAxisIndex: all }] }同时设置两个yAxis的min和max根据当前缓冲区的数据动态调整保证曲线在可视区域内占比合理不要太满也不要太空。我在项目中用了一个简单的规则Y轴范围是当前缓冲区中最大最小值的1.5倍向外扩展50%的余量。这样设计的目的不只是美观更重要的是让温度曲线和湿度曲线在各自的数据范围内呈现真实的波动幅度。用户如果发现温度曲线上窜下跳是因为温差确实大湿度曲线平缓是因为湿度确实稳定——屏幕上看到的是数据的真实映射。3.3 大数据量下的ECharts性能优化除了限制缓冲区大小ECharts本身还提供了一些针对大数据量的渲染优化选项。我在项目中做了三件事。第一是开启渐进渲染。在series配置里设置progressive: 1000表示每帧最多渲染1000个数据点剩下的分批次渲染这样即使数据量达到5000个点也不会造成明显的卡顿。第二是关闭动画。实时数据场景下动画没有意义反而会带来性能开销在setOption时通过animation: false关掉。第三是使用Canvas渲染器而不是SVG渲染器。Canvas在数据点多、频繁重绘的场景下性能表现远好于SVG。这一点在选型时就要想清楚切换到SVG模式在Web端是灵活的但实时大数据场景还是Canvas靠谱。4. 告警模块与历史数据存储4.1 阈值告警的前后端分工温湿度监控系统的告警功能逻辑上分成两部分阈值判断放在服务端还是前端我的建议是放在服务端。原因很简单告警的可靠性不能依赖前端保持在线。用户在夜间可能关闭了浏览器页面如果告警只在前端判断那这段时间的异常数据就白白丢了。服务端拿到采集数据后先做阈值判断如果超限就通过WebSocket推送告警消息同时写入数据库留档。告警消息的类型定义和实时数据区分开{ type: alert, level: warning, timestamp: 2025-01-15 14:30:00, message: 温度超过阈值当前值28.5℃上限28℃, data: { temperature: 28.5, humidity: 60.1 } }前端接收到告警消息后做三件事弹窗提示、在告警列表顶部插入一条新记录、如果当前有硬件条件可以播放提示音。这里要注意的是前端不能对告警消息做仅展示一次这种处理因为用户刷新页面后历史告警列表需要从后端重新拉取最近未确认的告警仍然要显示成未读状态。4.2 历史数据的按时间分片查询历史数据存储我用的MySQL 时间字段索引。温湿度数据是高频写入的如果不做分片单表数据量增长会很快。我在设计表结构时预留了按月分表的逻辑CREATE TABLE temperature_humidity_log_202501 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, timestamp DATETIME NOT NULL, temperature DECIMAL(5, 2) NOT NULL, humidity DECIMAL(5, 2) NOT NULL, INDEX idx_timestamp (timestamp) )前端查询历史曲线时通过时间范围参数请求后端接口后端根据月份自动路由到对应的物理表。查询SQL里强制带上时间范围条件确保走索引。这里有个经验不要把历史查询和实时展示混在一起。实时展示走的是WebSocket推送的数据历史查询走的是HTTP接口拉取。两者在交互形式上完全不同混在一起会导致状态混乱。我在项目里就是设计了一个切换开关默认是实时模式用户点击历史回放按钮后切换到指定时间段的静态曲线。切换时需要把WebSocket连接短暂断开或者暂时忽略实时推送的消息否则实时数据会把历史曲线的渲染结果冲掉。4.3 历史数据聚合查询的性能优化如果在历史记录页直接查询原始数据比如用户想查看过去一个月的数据变化曲线一次查询可能返回几万条记录前端渲染很吃力。我做的优化是把原始数据按时间粒度聚合。查询时间范围超过一天的按小时聚合显示平均值查询时间范围超过一个月的按天聚合显示平均值。这样数据量被压缩到几十个点以内渲染速度大幅提升而且对于温湿度这种缓慢变化的环境数据一天的均值曲线已经能说明问题了。聚合查询的SQL可以写成SELECT DATE_FORMAT(timestamp, %Y-%m-%d %H:00:00) AS time_bucket, ROUND(AVG(temperature), 2) AS avg_temperature, ROUND(AVG(humidity), 2) AS avg_humidity FROM temperature_humidity_log_202501 WHERE timestamp BETWEEN ? AND ? GROUP BY time_bucket ORDER BY time_bucket前端拿到聚合数据后用面积图类型展示比实时曲线的折线图视觉上更有层次感也能明显区分当前值和历史均值。5. 实战中的典型问题与排查链路5.1 浏览器页面切入后台后WebSocket被冻结这是一个非常隐蔽但影响很大的问题。测试时一切正常但真实使用时用户可能会把浏览器标签页切到后台去做别的事情过一段时间再切回来发现页面上的温湿度数据和当前时间差了好几分钟。原因在于浏览器的节流策略当标签页处于后台时浏览器会限制计时器和网络请求的频率WebSocket虽然连接没有断开但onmessage回调的触发被浏览器挂起了。这个问题没法在前端完全解决只能尽量缓解。我的做法是页面重新获得焦点时检查最后一次收到数据的时间戳如果与当前时间差超过30秒立即从服务端拉取最近一段时间的快照数据来补齐。document.addEventListener(visibilitychange, () { if (!document.hidden) { const lastTime lastReceivedTime.value if (Date.now() - lastTime 30000) { fetchLatestSnapshot() } } })5.2 多页面/多标签页导致的消息互踢另一个常见问题发生在同一浏览器打开多个监控页面时。每个页面都会建立独立的WebSocket连接如果服务端在鉴权时把同一个用户名的新连接当成顶掉旧连接就会导致另一个页面断开重连频繁闪断。排查这个问题用的是排除法。第一个阶段先关掉所有浏览器插件问题依然存在第二阶段用Postman模拟WebSocket连接发现连接稳定不掉线第三阶段才确认问题出在服务端的鉴权逻辑——同一用户多次连接时强制关闭旧连接。解决方式是服务端为同一个用户保留多个连接用客户端生成的唯一ID做区分前端每次建立连接时生成一个UUID作为连接标识。这样用户可以安全地在多个标签页同时打开。这个问题的根因在服务端但排查过程的起点是前端为什么老重连所以前端同事也要对WebSocket连接的生命周期有完整的认知才能定位到正确方向。这也说明了一个道理实时系统的排查不能只盯着前端链路里任何一环都可能成为瓶颈。5.3 Vite热更新导致的WebSocket连接泄漏开发阶段还有一个让人烦躁的问题每次修改代码触发Vite热更新页面上的WebSocket连接就会报错一次提示WebSocket is closed before the connection is established或者直接进入重连循环。这个问题本质上是Vite热更新机制导致的。热更新会重新执行模块如果useWebSocket里创建的连接没有在模块销毁时正确关闭旧连接就会泄漏。HMR时会卸载旧模块实例但WebSocket对象还挂在旧的作用域里浏览器有一个内置的清理延迟导致新旧连接短暂共存。我的解决方式是在HMR边界处显式处理if (import.meta.hot) { import.meta.hot.dispose(() { // 关闭WebSocket连接 close() }) }这样每次热更新时旧模块被销毁前会先关闭WebSocket连接避免连接泄漏和新旧连接打架。5.4 前端打包后的布局异常排查项目上线部署后遇到过一个典型问题本地开发环境一切正常打包部署到服务器后页面的图表容器高度变成0整个布局错乱。排查过程是这样的先看浏览器开发者工具的Elements面板发现图表所在的div高度为0。联想到ECharts初始化时如果容器不可见高度会计算成0然后去检查组件挂载时容器的可见状态。最终发现原因打包后的CSS加载顺序和开发环境不同导致组件mount完成时图表容器的样式还没生效高度被计算成了0。解决方式不复杂但值得记录setTimeout(() { chart.resize() }, 200)或者在ECharts初始化后监听容器尺寸变化const resizeObserver new ResizeObserver(() { chart.resize() }) resizeObserver.observe(containerEl)用ResizeObserver是更好的方案因为这个容器的高度可能在父级布局调整时再次变化监听尺寸变化持续有效地调整图表大小。5.5 部署环境Nginx的超时配置与WebSocket兼容最后说一个部署层面的问题。前端构建产物部署到Nginx时如果nginx.conf里没有配置WebSocket的升级响应头浏览器建立WebSocket连接时会被Nginx拦截报426错误或者直接502。Nginx配置需要加两个关键部分实测配置如下location /ws { proxy_pass http://backend_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }proxy_read_timeout尤其重要Nginx默认60秒没有数据传输就会断开连接如果没有心跳或者心跳间隔超过了这个值WebSocket连接会被Nginx杀掉。我项目中的心跳间隔是15秒远小于60秒所以正常情况下都不会被断。但从容灾角度考虑还是把超时时间调到3600秒给长时间没有任何数据上报的极端场景留出余量。部署完成后务必手动验证一下打开浏览器的开发者工具在Network面板里筛选WS请求确认连接状态是101 Switching Protocols同时送一个新数据看是否稳定接收。这一步不能省因为很多时候构建产物在本地正常到了服务器就出现连接失败基本都是Nginx代理配置不全导致的。6. 测试与调优让实时监控真正实时6.1 延迟测试的量化方法很多人在做完实时系统后不知道如何检验实时性只凭感觉说挺流畅的。我在项目中做了一套量化的延迟测试方案用来评估数据从服务端生成到前端渲染完成的时间差。测试思路很简单在数据消息中带上服务端的发送时间戳前端在接收到消息并完成渲染后记录浏览器当前时间两者相减就是端到端延迟。我在页面上加了一个性能面板实时显示最近100条消息的平均延迟和最大延迟。实测结果本机环境下WebSocket的端到端平均延迟在5到10毫秒之间加上ECharts渲染时间整体数据从服务端产生到用户看到大约在30到50毫秒以内。这个数据说明WebSocket方案完全能满足秒级实时的需求甚至能达到毫秒级感知的效果。如果是远程服务器部署网络延迟会增加但只要延迟稳定在200毫秒以内对于温湿度监控场景来说体验几乎是完全相同的。真正需要警惕的不是网络延迟而是数据断档——如果前端长时间收不到数据页面上的时间轴和当前时间慢慢拉开差距这种看起来还在动但是已经是旧数据的状态危害更大。6.2 监控面板自诊断功能的设计为了让系统在异常时能自我暴露问题我在前端加了一个轻量的自诊断模块定期检查以下指标当前连接状态是否正常isConnected是否为true距离最近一次消息到达的秒数最近5分钟内的平均消息频率WebSocket重连次数页面顶部有一个状态栏用颜色区分健康状态绿色表示正常黄色表示数据间隔超过预期但在可接受范围红色表示连接断开或数据长时间未更新。这个状态栏的效果在系统交付给客户后非常明显——以前客户反馈数据不对需要远程逐层排查现在页面自己就会发出提示定位问题的时间缩短了一大半。自诊断的逻辑不复杂核心就是在数据接收处记录lastReceivedTime然后设置一个定时器定期检查setInterval(() { const secondsSinceLastData (Date.now() - lastReceivedTime.value) / 1000 if (secondsSinceLastData 15) { connectionStatus.value warning } else if (secondsSinceLastData 60) { connectionStatus.value error } else { connectionStatus.value normal } }, 5000)阈值不是拍脑袋定的我在项目里设定的正常数据上报频率是5秒一次所以15秒没有数据说明链路可能存在问题60秒没有数据基本可以断定连接断开了。6.3 打包体积优化与性能实测最后做一次打包体积的检查和优化。我注意到项目中引入的Element Plus组件库被全量打包了体积占了不少。换成按需引入后把项目中用到的Button、Input、Table、Tag等组件单独引入import { ElButton, ElInput, ElTable, ElTag } from element-plus import element-plus/es/components/button/style/css import element-plus/es/components/input/style/css import element-plus/es/components/table/style/css import element-plus/es/components/tag/style/css最终构建产物对比数据是这样的项目全量引入按需引入JS体积约980KB约460KBGzip后约320KB约150KB首屏加载时间Fast 3G约4.2s约2.1s在温湿度监控这种功能相对聚焦的场景下首屏加载时间从4秒降到2秒对操作体验的提升是肉眼可见的。而且这一步只需要花十几分钟配置性价比极高。另一个值得做的优化是使用功能标记的构建方式Vite的define配置可以替换全局变量在开发环境展示调试面板在生产环境自动屏蔽。写到这里这个基于Vue的实时温湿度监控系统的核心内容基本都覆盖到了。最后再分享一个小技巧也是我在这个项目中后期才加上的给图表曲线加一个暂停实时刷新的按钮。这个按钮解决了一个很实际的场景——当系统出现告警时用户想仔细查看当前曲线走势但实时数据一直在滚动更新鼠标很难选中某一个数据点。暂停刷新后用户可以自由地缩放、悬停查看数据点处理完再恢复实时模式。这个功能代码量不多但实际使用频率非常高可以说是一个小修改带来的体验提升的典型例子。