尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

WebSocket实时推送:从零构建列车到站信息大屏系统

WebSocket实时推送:从零构建列车到站信息大屏系统 这可能是很多通勤族每天都会看到的画面列车即将到站广播里传出下一站信息站台大屏同步刷新倒计时和拥挤度。看似简单的几行文字背后其实是一整套“数据采集 — 状态计算 — 实时推送 — 大屏渲染”的链路。今天这篇文章就用一个有趣的代号「下一站——至冬」作为示例站名从零搭建一个列车到站信息展示系统把这条链路完整跑通。先给出一个明确判断这类系统的难点往往不在前端动画也不在“能不能显示”而在三件事——数据是否准时、状态是否可靠、异常时能否自愈。如果只做表面展示半天就能写完但如果要面向真实的运营场景就需要把数据源、推送通道、前端状态管理一起设计好。这篇文章会从后端模拟数据源开始逐步实现 WebSocket 实时推送、前端大屏渲染、断线重连最后给出排查思路和工程化建议。读完这篇文章你可以独立实现一版可运行的“列车到站信息展示系统”也能理解实时数据链路中容易被忽略的细节。整套代码基于 Node.js 和纯前端实现不需要额外安装数据库适合用来学习实时推送、状态机设计和大屏开发。1. 这条实时链路真正难在哪里很多人第一次接触这类系统会以为“列车到站显示”就是每隔几秒请求一次接口然后把数据渲染到页面上。如果数据量小、实时性要求不高这样做确实够用。但真实场景下这种实现方式有很明显的问题。第一轮询的实时性不可控。假设 5 秒轮询一次列车可能已经在 2 秒前到站页面却要等到下一轮请求才能更新。如果缩短到 1 秒服务器压力又会明显上升。第二状态展示不是简单的数据堆砌。列车有“即将到达”“停站中”“已离站”等多种状态前端需要根据连续的数据变化判断当前应该显示什么而不是盲目覆盖老数据。第三大屏场景对稳定性要求更高。运营大厅的屏幕可能连续运行数天网络抖动、后端重启、内存泄漏都会变成肉眼可见的事故。因此这篇文章采用WebSocket 主动推送的架构后端负责生成列车状态数据通过长连接把更新推送给所有客户端前端负责接收消息、维护状态、渲染界面并在连接断开时自动重连。这样既保证了实时性也把网络开销控制在一个合理范围。从架构层面看这个系统和常见的“消息推送系统”“实时大屏项目”“IoT 设备监控面板”是同一个套路。理解了“列车到站”也就理解了这一类实时展示系统的最简骨架。2. 核心概念数据源、推送通道和状态机在写代码之前先对齐几个关键概念。第一次接触实时系统的读者容易把这些概念混淆。2.1 数据源状态从哪来在真实场景里列车位置来自信号系统或定位设备列车的计划时间来自运行图二者经过计算后得到“还有几分钟到达”“是否已经开始停站”等结果。本文将用 Node.js 定时生成模拟数据模拟一列列车的完整运行过程从“距离本站较远”到“即将到站”再到“停站中”“已离站”。这里的关键是上游数据只负责提供事实状态判断应该在服务端完成。前端拿到“当前时间”“预计到达时间”之后不需要自己推导列车状态而是直接使用服务端计算好的状态字段。这样不同客户端的显示逻辑才能保持一致。2.2 推送通道WebSocket 和轮询的区别先看一个对比对比项定时轮询WebSocket 推送实时性取决于轮询间隔数据变化后立即推送服务器压力间隔越短压力越大只在有数据变化时发送消息实现复杂度简单普通 HTTP 接口即可需要维护长连接适合场景低实时性、低频数据实时大屏、在线协作、行情展示WebSocket 的本质是在客户端和服务端之间建立一条持久连接双方都可以随时发送消息。对于“列车状态变化”这种事件驱动场景它比轮询更合适。本文前端使用浏览器自带的WebSocket对象后端使用ws这个 Node.js 库不引入额外框架便于看清原理。2.3 状态机列车状态如何流转列车在站台的显示状态可以抽象成一个简单的状态机运营中运行中 - 即将到站 - 停站中 - 已离站 - 下一轮开始每个状态对应不同的 UI 表现arriving距离本站还有几分钟显示倒计时boarding已经到站正在上下客显示“停站中”departed列车离站显示“已离站下一站是……”状态机的好处是避免前端收到乱序或重复数据后出现逻辑混乱。即使后端在某次推送中漏掉了中间状态前端也应该能够根据当前状态和数据字段做合理修正。3. 环境准备与项目结构本文的示例代码不需要复杂环境只需要安装 Node.js。需要说明本文不将 Node.js 版本写死建议使用目前主流的 LTS 版本例如 Node.js 16 及以上。如果你的机器上已经装了其他版本只要能够正常运行npm命令即可。前端部分不需要额外构建工具直接使用浏览器打开 HTML 文件。3.1 项目目录train-arrival-system/ ├── package.json ├── server/ │ ├── train-service.js # 模拟列车数据源 │ └── ws-server.js # WebSocket 推送服务 └── web/ ├── index.html # 大屏页面 ├── style.css # 页面样式 └── app.js # 前端逻辑3.2 初始化项目mkdir train-arrival-system cd train-arrival-system npm init -y npm install ws安装完成后package.json中会自动出现ws依赖。接下来开始编写后端代码。4. 后端模拟列车数据源与推送服务4.1 第一步编写模拟数据源文件路径server/train-service.js这个模块负责生成一列车的状态数据。为了让演示效果明显这里把列车运行周期压缩到 30 秒左右10 秒“即将到站”8 秒“停站中”5 秒“已离站”之后重新开始下一轮。// 文件路径server/train-service.js const EVENT_BUS {}; const STATE { ARRIVING: arriving, BOARDING: boarding, DEPARTED: departed }; function createTrain(id, lineName, terminalName, nextStation) { return { trainId: id, lineName: lineName, terminalName: terminalName, currentStation: 至冬, nextStation: nextStation, state: STATE.ARRIVING, updatedAt: Date.now() }; } function publish(train) { if (typeof EVENT_BUS.onUpdate function) { EVENT_BUS.onUpdate(train); } } function startTrainLoop(id, lineName, terminalName, nextStation) { const train createTrain(id, lineName, terminalName, nextStation); const timer setInterval(() { if (train.state STATE.ARRIVING) { train.state STATE.BOARDING; train.updatedAt Date.now(); publish(train); return; } if (train.state STATE.BOARDING) { train.state STATE.DEPARTED; train.updatedAt Date.now(); publish(train); return; } if (train.state STATE.DEPARTED) { train.state STATE.ARRIVING; train.updatedAt Date.now(); publish(train); } }, 10000); return { train, stop() { clearInterval(timer); } }; } module.exports { STATE, startTrainLoop, setOnUpdate(callback) { EVENT_BUS.onUpdate callback; } };这段代码的核心逻辑是用定时器驱动状态流转。每 10 秒执行一次列车依次从“即将到站”切到“停站中”再到“已离站”然后再回到“即将到站”。数据源通过publish方法把最新状态交给外部处理外部可以是 WebSocket 服务也可以是日志输出。这种“数据源与推送通道分离”的设计方便后续替换为真实数据。4.2 第二步编写 WebSocket 推送服务文件路径server/ws-server.js推送服务负责两件事接收前端连接以及把数据源产生的状态变化广播给所有客户端。// 文件路径server/ws-server.js const { WebSocketServer } require(ws); const trainService require(./train-service); const PORT 8080; const wss new WebSocketServer({ port: PORT }); // 保存所有连接的客户端 const clients new Set(); function broadcast(message) { const payload JSON.stringify(message); for (const client of clients) { if (client.readyState client.OPEN) { client.send(payload); } } } wss.on(connection, (socket) { clients.add(socket); console.log(客户端已连接当前连接数, clients.size); // 推送一条当前状态快照便于新连接的页面立即渲染 socket.send(JSON.stringify({ type: snapshot, trains: trainService.getSnapshot ? trainService.getSnapshot() : [] })); socket.on(close, () { clients.delete(socket); console.log(客户端已断开当前连接数, clients.size); }); }); trainService.setOnUpdate((train) { broadcast({ type: trainUpdate, train }); }); // 为了让新连接能拿到快照这里需要维护一个全局列车列表 const runningTrains []; // 改造 train-service暴露 getSnapshot 方法 const originalStart trainService.startTrainLoop; trainService.startTrainLoop (...args) { const result originalStart.apply(trainService, args); runningTrains.push(result.train); return result; }; trainService.getSnapshot () runningTrains; // 启动一列演示列车 trainService.startTrainLoop(T-001, 2号线, 冰封港, 璃月港); console.log(WebSocket 服务已启动ws://localhost:${PORT});这里有一个需要注意的细节后加入的客户端要能立即看到当前状态。如果不做快照推送新页面打开后可能要在下一次数据变化时才能显示内容体验上会有一段空白。因此服务端在连接建立时会先推送一个snapshot消息再推送后续trainUpdate消息。4.3 数据源与推送服务的边界可能有读者会问为什么不把定时器直接写在 WebSocket 服务里原因很简单真实项目中数据源和推送服务往往是不同的模块。数据源可能来自消息队列、信号系统接口或数据库变更捕获推送服务只负责把“上游给到的更新”转发给前端。把两者拆开后续替换数据源时不需要改动推送逻辑。为了演示效果上面的ws-server.js对train-service做了一点“增强”补了一个快照能力。实际项目中更推荐在数据源模块内部维护列车列表并直接提供getSnapshot()方法而不是在启动后临时替换函数。下面的代码展示一种更干净的方式。// 文件路径server/train-service.js增强版片段 const runningTrains []; function startTrainLoop(id, lineName, terminalName, nextStation) { const train createTrain(id, lineName, terminalName, nextStation); runningTrains.push(train); const timer setInterval(() { // 状态流转逻辑同前 }, 10000); return { train, stop() { clearInterval(timer); const index runningTrains.indexOf(train); if (index ! -1) { runningTrains.splice(index, 1); } } }; } function getSnapshot() { return runningTrains.map((train) ({ ...train })); } module.exports { STATE, startTrainLoop, getSnapshot, setOnUpdate(callback) { EVENT_BUS.onUpdate callback; } };增强后的模块把runningTrains维护在模块内部getSnapshot返回时做一次浅拷贝避免外部直接修改内部对象。这个做法在工程上是值得养成的习惯。5. 前端接收推送并渲染“下一站”大屏前端部分用三个文件完成index.html负责页面结构style.css负责样式app.js负责接收 WebSocket 消息并更新界面。5.1 页面结构文件路径web/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title至冬站列车到站信息/title link relstylesheet hrefstyle.css / /head body div classscreen h1 classscreen-title列车即将到站 · 下一站——至冬/h1 div idtrain-list classtrain-list div classempty-tip正在等待列车数据.../div /div div idconnection-status classconnection-status连接中.../div /div script srcapp.js/script /body /html页面布局比较简单标题、列车信息列表、连接状态提示。连接状态放在页面底部是为了在调试时能够快速判断 WebSocket 是否正常。5.2 页面样式文件路径web/style.cssbody { margin: 0; font-family: PingFang SC, Microsoft YaHei, sans-serif; background: #0b1020; color: #eaeaea; } .screen { max-width: 900px; margin: 0 auto; padding: 24px; } .screen-title { text-align: center; font-size: 28px; letter-spacing: 2px; } .train-list { margin-top: 24px; display: flex; flex-direction: column; gap: 16px; } .train-card { background: #161d33; border-left: 4px solid #4fc3f7; border-radius: 8px; padding: 16px 20px; transition: opacity 0.3s ease; } .train-card.departed { border-left-color: #888; opacity: 0.6; } .train-card .train-header { display: flex; justify-content: space-between; align-items: center; } .train-card .train-line { font-size: 20px; font-weight: 600; } .train-card .train-state { font-size: 14px; padding: 4px 12px; border-radius: 12px; background: #1c253f; } .train-card .train-state.arriving { background: #0d47a1; } .train-card .train-state.boarding { background: #b26a00; } .train-card .train-state.departed { background: #37474f; } .train-card .train-info { margin-top: 12px; font-size: 16px; line-height: 1.6; } .connection-status { margin-top: 32px; text-align: center; color: #8a93a8; font-size: 14px; } .empty-tip { text-align: center; color: #8a93a8; padding: 40px 0; }样式的关键点在于不同状态使用不同的左侧边框和背景色让运营人员一眼就能判断目前列车的状态。离站后的卡片降低透明度可以避免占用过多视觉注意力。5.3 前端逻辑文件路径web/app.jsconst trainListEl document.getElementById(train-list); const connectionStatusEl document.getElementById(connection-status); let socket null; let reconnectAttempts 0; const MAX_RECONNECT_ATTEMPTS 5; function setConnectionStatus(text) { connectionStatusEl.textContent text; } function renderTrains(trains) { if (!trains || trains.length 0) { trainListEl.innerHTML div classempty-tip正在等待列车数据.../div; return; } trainListEl.innerHTML trains.map((train) { const stateText { arriving: 即将到站, boarding: 停站中, departed: 已离站 }[train.state] || train.state; const stateClass train.state || ; const nextStationText train.state departed ? 下一站${train.nextStation} : 下一站预告${train.nextStation}; return div classtrain-card ${stateClass} div classtrain-header span classtrain-line${train.lineName} · ${train.trainId}/span span classtrain-state ${stateClass}${stateText}/span /div div classtrain-info 当前站${train.currentStation} br/ ${nextStationText} br/ 更新时间${new Date(train.updatedAt).toLocaleTimeString()} /div /div ; }).join(); } function handleMessage(event) { let message; try { message JSON.parse(event.data); } catch (error) { console.error(消息解析失败, error); return; } if (message.type snapshot) { renderTrains(message.trains); return; } if (message.type trainUpdate) { // 更合理的做法是维护一个状态 Map这里为了演示直接重新拉取快照 return; } } function connect() { setConnectionStatus(正在连接服务器...); const protocol location.protocol https: ? wss : ws; const url ${protocol}://${location.hostname}:8080; socket new WebSocket(url); socket.onopen () { reconnectAttempts 0; setConnectionStatus(已连接实时数据服务); }; socket.onmessage handleMessage; socket.onerror (error) { console.error(WebSocket 错误, error); setConnectionStatus(连接异常正在尝试重连...); }; socket.onclose () { setConnectionStatus(连接已断开); if (reconnectAttempts MAX_RECONNECT_ATTEMPTS) { reconnectAttempts 1; const delay 1000 * reconnectAttempts; setTimeout(connect, delay); } else { setConnectionStatus(重连失败请刷新页面); } }; } connect();上面这段逻辑中handleMessage对trainUpdate的处理比较简单。更好的做法是在内存中维护一个Map以trainId为 key 保存所有列车的最新状态收到trainUpdate时更新 Map再重新渲染。此外还要与快照合并。下面给出一个改进版本的核心逻辑。// 文件路径web/app.js增强版片段 const trainMap new Map(); function saveToMap(train) { trainMap.set(train.trainId, { ...train, updatedAt: Date.now() }); } function renderAllTrains() { const trains Array.from(trainMap.values()); renderTrains(trains); } function handleMessage(event) { let message; try { message JSON.parse(event.data); } catch (error) { console.error(消息解析失败, error); return; } if (message.type snapshot) { trainMap.clear(); (message.trains || []).forEach(saveToMap); renderAllTrains(); return; } if (message.type trainUpdate) { saveToMap(message.train); renderAllTrains(); } }使用Map的好处是即使同一列列车的消息乱序到达也能根据trainId归并到同一条记录不会出现重复卡片。前端代码在渲染前对updatedAt做了重新赋值这里要提醒一下实际项目中不要用本地时间覆盖服务端时间因为本地时钟可能不准。正确做法是同一套系统内统一以服务端时间为准前端只负责展示。6. 运行结果与效果验证6.1 启动服务进入项目目录分别启动 WebSocket 服务端。node server/ws-server.js正常情况下控制台输出WebSocket 服务已启动ws://localhost:80806.2 打开前端页面因为前端页面直接打开index.html时location.hostname是本地地址WebSocket 连接地址为ws://localhost:8080所以直接用浏览器打开即可。也可以用简单静态服务器托管比如cd web npx serve .然后访问http://localhost:3000。6.3 观察预期效果页面打开后会先显示“正在等待列车数据...”随后变成一张列车卡片。列车状态按照 10 秒间隔变化前 10 秒状态为“即将到站”左侧边框为蓝色中间 10 秒状态为“停站中”左侧边框为橙色最后 5 到 10 秒状态为“已离站”卡片变灰透明度降低如果你同时打开两个浏览器窗口会看到两个页面的状态变化基本同步差值应该很小。这说明 WebSocket 广播机制生效了。6.4 验证断线重连模拟一次服务端重启在终端按CtrlC停掉ws-server.js观察页面底部提示。此时状态文字会从“已连接实时数据服务”变成“连接异常正在尝试重连...”。重新执行node server/ws-server.js后页面应该自动恢复连接并重新收到快照数据。如果这一步没有生效首先检查浏览器控制台是否报错再确认重连逻辑中的MAX_RECONNECT_ATTEMPTS是否被耗尽。7. 常见问题与排查方法以下是这个示例项目中容易遇到的问题也覆盖了同类实时展示系统的常见坑。问题现象可能原因排查方式解决方案页面一直显示“正在等待列车数据”WebSocket 连接未建立成功打开浏览器控制台查看网络请求中是否有 WebSocket 连接记录确认服务端已启动端口号是否一致是否有防火墙拦截连接短暂建立后立即断开后端抛异常导致进程崩溃查看服务端终端日志检查是否有未捕获错误为 WebSocket 的error和close事件增加日志避免静默退出两个页面显示的状态不一致新页面打开时没有收到快照在连接回调中确认是否收到snapshot消息服务端在连接建立后先推送快照再推送增量更新页面频繁渲染性能下降每次消息都重建整张 DOM打开 Performance 面板观察渲染耗时使用文档片段或虚拟滚动只在数据变化时更新对应卡片数据出现重复卡片没有按trainId做归并检查前端状态是否使用 Map 保存以trainId为 key 维护本地状态长时间运行后页面不再更新网络链路断开但前端未感知在服务端记录连接断开日志客户端监听close事件增加心跳检测例如每隔 30 秒发送一次 ping显示时间和实际时间偏差大前端使用了本地时间比较服务端时间和本地时间统一使用服务端返回的时间戳只在展示层做格式化这里特别要说明心跳机制。WebSocket 连接在长时间空闲时可能被中间设备静默断开而客户端和服务端都感知不到。常规做法是客户端每隔一段时间发送一个ping服务端响应pong如果超过阈值没有收到响应则主动重连。示例代码为了保持简洁没有加入心跳但在工程化版本里这是必须的。8. 最佳实践与工程化建议8.1 用状态机而不是散落的 if 判断列车状态看起来只有三种但如果项目继续扩展会出现“晚点”“提前离站”“临时跳停”等情况。没有状态机约束前端就会散落大量if判断后期很难维护。建议在服务端定义状态机明确每个状态允许流转到哪些状态。例如“停站中”只能流转到“已离站”或“紧急暂停”不能直接回到“即将到站”。前端的职责只是渲染不做业务状态判断。8.2 统一时间语义系统所有时间字段都应该使用时间戳或统一的 UTC 格式不推荐跨端传输“2025-01-01 12:00:00”这样的本地时间字符串。原因很简单客户端和服务端可能处于不同时区即使同一时区系统时钟也可能存在偏差。在服务端生成updatedAt前端只负责格式化展示是最稳妥的做法。8.3 快照加增量的数据同步策略新客户端接入时需要一份全量快照接入后只需要接收增量更新。这是实时系统的通用套路。快照解决“新连接无数据”的问题增量解决“连接后持续更新”的问题。两者的顺序不能反也不能只做其中一项。8.4 断线重连要设计退避策略示例代码的重连间隔是1000 * retryCount这是一个最简单的指数退避变体。实际项目中可以加入随机抖动避免多个客户端同时重连造成服务端压力。重连次数的上限也要合理设置失败后给出明确提示而不是无限重试。8.5 日志与监控不能省至少要在服务端记录以下内容客户端连接和断开事件广播的消息数量异常和错误堆栈每个客户端的连接时长对于大屏项目还需要增加一种可视化监控方式如果超过一定时间没有收到列车状态更新页面应该主动显示“数据源异常”的提示而不是停留在最后一次正常状态让人误以为系统还在正常工作。8.6 关于大屏刷新策略很多大屏项目有“整点刷新页面”的习惯但这会带来一个新的问题刷新瞬间所有客户端同时重连服务端需要处理突发连接高峰。如果业务允许更推荐通过 WebSocket 持续更新数据而不做整页刷新。前端只更新变化的 DOM 节点避免整屏闪烁。8.7 安全与权限提醒如果这套系统要接入真实的实时数据源需要注意以下几点前端通过 WebSocket 连接时应校验来源避免任意页面都能接入。如果需要登录认证可以在连接 URL 中携带短时效令牌服务端在connection事件中校验。不要在前端代码中硬编码数据库连接信息、服务端密钥等敏感内容。如果是在生产环境直接对接信号系统或业务数据库应先在测试环境验证并提前设计好回滚方案。这些内容不是本示例的核心但在真实项目中往往决定系统能否上线。9. 总结与下一步可以深入的方向这篇文章用一个“下一站——至冬”的趣味示例实现了从模拟数据源到 WebSocket 推送再到前端大屏渲染的完整链路。你至少掌握了三件事第一数据源、推送通道和前端展示如何分层设计第二实时系统的状态机怎么定义快照加增量怎么配合使用第三断线重连、数据归并和日志监控这些工程细节为什么重要。下一步如果你想把这套示例升级为更接近真实生产的系统可以从这几个方向入手把模拟数据源替换为真实接口或消息队列例如 Kafka、MQTT。增加多列车的并发管理测试大量客户端同时连接时的性能表现。将前端改为 Vue 或 React 工程使用状态管理库维护列车数据。为服务端增加心跳机制和客户端自动重连的完整实现。建议先把这篇文章中的代码跑通再逐步加入自己的场景需求。中途遇到问题优先从 WebSocket 连接状态、服务端日志和浏览器控制台三个方向排查大部分问题都能在这三个地方找到线索。收藏备用下次做实时数据展示项目时可以直接参考这份最小实现。
返回列表