前端实时数据通信:短轮询、长轮询/SSE与WebSocket选型指南

前端实时数据通信:短轮询、长轮询/SSE与WebSocket选型指南

1. 从“刷新”到“推送”:实时数据的前世今生

干了这么多年前端,我见过太多项目在“实时数据”这个需求上栽跟头。老板说“我要看实时销量”,产品经理说“用户消息要秒达”,听起来理所当然,但真做起来,你会发现从“定时刷新”到“真正实时”,中间隔着一整个技术选型的鸿沟。很多新手,甚至一些工作两三年的朋友,一提到实时数据,脑子里可能就只剩下“WebSocket”这一个选项,或者更糟,还在用setInterval暴力轮询,把服务器拖得半死不活。

今天,我们就用十分钟,把前端获取实时数据的三种主流方式——短轮询、长轮询(及SSE)、WebSocket——彻底掰扯清楚。这不仅仅是面试八股文,更是你日常开发中做技术选型时必须权衡的决策点。我会结合真实的踩坑经历,告诉你每种方案在什么场景下是“神器”,在什么场景下又是“毒药”。比如,你正在做一个后台数据监控面板,每秒要更新几十个图表,该用哪种?做一个在线聊天室,又该用哪种?这里面门道可多了。

2. 方案一:短轮询——简单粗暴的“刷新”按钮

短轮询(Short Polling)可能是最古老、最直观的“实时”方案了。它的逻辑简单到令人发指:前端就像个不耐烦的用户,每隔一段时间(比如2秒)就向后端发一次请求:“嘿,有数据更新吗?”不管后端有没有新数据,都会立即响应。没有新数据就返回空或者状态码。

2.1 工作原理与代码实现

它的本质是利用浏览器的定时器,周期性地发起普通的HTTP请求(通常是GET)。我们用最基础的fetchAPI就能实现。

// 一个简单的短轮询示例 function shortPolling(url, interval = 2000) { let isPolling = true; const poll = async () => { if (!isPolling) return; try { const response = await fetch(url); const data = await response.json(); console.log('轮询到数据:', data); // 处理数据,例如更新UI updateUI(data); } catch (error) { console.error('轮询请求失败:', error); // 错误处理:可以增加重试逻辑,或拉长轮询间隔 } finally { // 无论成功失败,都计划下一次轮询 if (isPolling) { setTimeout(poll, interval); } } }; // 启动轮询 poll(); // 返回一个停止轮询的函数 return () => { isPolling = false; console.log('短轮询已停止'); }; } // 使用示例 const stopPolling = shortPolling('/api/data', 2000); // 在组件卸载或需要停止时调用 // stopPolling();

为什么这样设计?这里有几个关键点:第一,用isPolling标志位控制轮询生命周期,防止组件卸载后请求还在继续,这是内存泄漏的常见坑。第二,错误处理在catch中,并且finally块确保无论成功失败都会安排下一次请求,保证了轮询的持续性。第三,返回一个清理函数,这是符合React Hooks或现代前端开发模式的好习惯。

2.2 适用场景与致命缺陷

短轮询并非一无是处,它在特定场景下依然有价值:

  • 数据变化不频繁,且对实时性要求不高:例如,每隔30秒或1分钟检查一次系统公告、版本更新信息。
  • 客户端环境受限:在一些老旧的浏览器或特殊环境(如某些嵌入式设备的Web界面)中,WebSocket或SSE可能不被支持,短轮询是保底方案。
  • 实现极其简单,快速原型:当你需要快速验证一个想法,不想在通信协议上花费时间时。

但是,它的缺陷在实时场景下是致命的:

  1. 高延迟(Latency):数据的“新鲜度”最多等于轮询间隔。你设2秒,数据就可能延迟2秒才到。这不是真正的实时。
  2. 高开销(Overhead):即使没有数据更新,也会产生大量无效的HTTP请求。每个请求都有完整的Header、TCP握手(HTTP/1.1下可能还有队头阻塞)、SSL握手(如果是HTTPS)开销。这对服务器和网络都是巨大的浪费。想象一下,1000个在线用户每2秒请求一次,服务器每秒就要处理500个请求,其中绝大部分是“空跑”。
  3. 服务器压力:这些无用的请求会毫无意义地占用服务器的连接数、CPU和I/O资源,在高并发下极易成为性能瓶颈。

实操心得:我曾维护过一个老旧的报表系统,用的就是5秒一次的短轮询。平时相安无事,一到业务高峰,服务器负载直接飙红。后来我们做的第一件事不是优化数据库,而是先把轮询间隔改成10秒,并增加了“无变化时跳过UI渲染”的逻辑,负载瞬间下降一半。教训是:如果你不得不用短轮询,一定要把间隔时间设得尽可能长,并在客户端做去重和节流渲染。

3. 方案二:长轮询与SSE—— “待命”与“单工广播”

为了克服短轮询的无效请求问题,更聪明的“长轮询”出现了。而SSE则是HTML5标准中为这种“服务器推送”模式量身定制的协议。

3.1 长轮询:挂起的请求

长轮询(Long Polling)可以理解为“聪明的短轮询”。前端发起一个请求,但这个请求会被后端“挂起”(Hold),直到后端有数据更新,或者等待超时(比如30秒)。一旦有数据或超时,后端立即响应,前端收到响应后,马上再发起下一个新的长轮询请求,如此循环。

function longPolling(url, timeout = 30000) { let isActive = true; const poll = async () => { if (!isActive) return; try { // 可以添加超时控制参数,但注意服务器端也需要配置相应的超时 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); const data = await response.json(); console.log('长轮询到数据:', data); updateUI(data); // 关键:立即发起下一次轮询 poll(); } catch (error) { if (error.name === 'AbortError') { console.log('长轮询请求超时,重新连接'); } else { console.error('长轮询请求失败:', error); } // 错误或超时后,延迟一段时间再重试,避免疯狂重连 setTimeout(poll, 2000); } }; poll(); return () => { isActive = false; }; }

为什么这样设计?这里使用了AbortController来实现客户端超时控制,这是一个比传统setTimeout更优雅的方式,能真正取消网络请求。超时后,我们延迟2秒再重连,这是为了避免在服务器临时故障时,客户端立即发起大量重连请求形成“重连风暴”。

长轮询减少了大量无用的请求,数据延迟可以做到很低(一旦服务器有数据就返回)。但它仍有缺点:每个连接在大部分时间是空闲的,但仍占用服务器资源(如一个线程或一个协程);连接不断建立和断开,仍有开销;并且实现复杂度比短轮询高,需要服务器端配合保持连接。

3.2 SSE:长轮询的“标准升级版”

SSE(Server-Sent Events)让长轮询模式标准化、更高效。它基于HTTP协议,允许服务器主动向客户端推送数据,但连接方向是单向的(服务器到客户端)。一旦连接建立,这个连接会一直保持,服务器可以随时通过这个连接发送数据片段。

// 前端代码:使用 EventSource API (非常简单!) function setupSSE(url) { const eventSource = new EventSource(url); // 监听默认事件(服务器未指定事件名时发送的数据) eventSource.onmessage = (event) => { const data = JSON.parse(event.data); console.log('收到SSE数据:', data); updateUI(data); }; // 监听自定义事件(服务器可以发送不同类型的事件) eventSource.addEventListener('stock-update', (event) => { console.log('股票更新:', event.data); }); eventSource.addEventListener('news-alert', (event) => { console.log('新闻提醒:', event.data); }); // 错误处理:连接出错时会触发,并自动尝试重连 eventSource.onerror = (error) => { console.error('SSE连接错误:', error); // 你可以在这里决定是否手动关闭 eventSource // eventSource.close(); }; // 返回关闭函数 return () => { eventSource.close(); }; } // 使用 const closeSSE = setupSSE('/api/sse-stream');

为什么SSE更优?首先,浏览器原生支持EventSourceAPI,使用极其简单。其次,连接是真正的持久化,省去了长轮询中反复建立连接的开销。第三,它自动支持断线重连。第四,服务器推送的数据格式是定义好的(data:event:id:等字段),更规范。

SSE的局限性:最大的限制就是单向通信。客户端无法通过这个连接向服务器发送数据。如果需要交互,你得额外使用普通的HTTP请求(如Fetch)。另外,它只支持文本数据(通常用JSON),不支持二进制流。对于不支持EventSource的旧浏览器(主要是IE),需要polyfill。

实操心得:SSE是实现“实时通知”、“实时报表”、“股票价格推送”这类场景的绝佳选择。我曾经用SSE重构了一个实时监控大屏,替换掉之前的短轮询。改造后,服务器连接数从每秒上千个骤降到与客户端数持平(每个用户一个持久连接),网络流量也大幅下降。但务必注意:SSE连接默认有重试机制,如果服务器崩溃重启,所有客户端会疯狂重连。生产环境一定要在服务端做平滑重启和连接迁移,或者在客户端实现带指数退避的智能重连。

3.3 长轮询与SSE的服务端实现要点

无论是长轮询还是SSE,都对服务端有要求:必须能够保持住HTTP连接并异步地返回数据。这在不同的后端框架中实现方式不同。

以Node.js (Express) 为例,实现一个简单的SSE端点:

// 服务器端 SSE 示例 (Express.js) app.get('/api/sse-stream', (req, res) => { // 1. 设置SSE必需的响应头 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', // 重要:允许跨域 if needed 'Access-Control-Allow-Origin': '*' }); // 2. 发送一个初始注释(可选),帮助建立连接 res.write(': 连接已建立\n\n'); // 3. 模拟定期发送数据 const intervalId = setInterval(() => { const data = { timestamp: new Date().toISOString(), value: Math.random() * 100 }; // 格式必须遵守: "data: " + JSON字符串 + "\n\n" res.write(`data: ${JSON.stringify(data)}\n\n`); // 注意:必须调用 res.flush() 以确保数据立即发送(取决于服务器和中间件) }, 1000); // 4. 客户端断开连接时清理 req.on('close', () => { console.log('客户端断开连接'); clearInterval(intervalId); res.end(); }); });

服务端关键点

  • 头部(Headers)Content-Type: text/event-stream是必须的。Cache-Control: no-cache防止缓存。Connection: keep-alive保持连接。
  • 数据格式:每条消息以data:开头,以两个换行符\n\n结束。可以发送event:定义事件类型,id:设置消息ID用于断线重连。
  • 连接管理:必须监听请求的close事件,及时清理定时器或释放资源,防止内存泄漏。
  • 性能:一个连接就是一个常驻的请求句柄。对于高并发,需要使用异步I/O和非阻塞框架(如Node.js、Go、Java NIO等),避免一个连接阻塞一个线程。

4. 方案三:WebSocket——全双工实时通信的王者

当你的应用需要真正的、双向的、低延迟的对话时,WebSocket是唯一的选择。它不是在HTTP上打补丁,而是在TCP之上建立了一个独立的、全双工(Full-Duplex)的通信通道。连接一旦通过HTTP握手升级建立,双方就可以在任何时刻互相发送数据,没有请求/响应模型的概念。

4.1 为什么是WebSocket?核心优势解析

想象一下在线游戏、协同编辑文档(如Google Docs)、实时聊天、股票交易终端。这些场景中,客户端和服务器需要频繁地、低延迟地互相“说话”。用轮询或SSE会非常别扭且低效。

  1. 真正的低延迟:数据准备好后立即发送,没有轮询间隔,延迟通常在毫秒级。
  2. 极低的协议开销:建立连接后,数据传输的头部开销极小(通常只有2-10字节),远小于HTTP头部。
  3. 双向通信:服务器可以主动推,客户端也可以主动发,沟通自如。
  4. 支持二进制和文本数据:可以传输Blob、ArrayBuffer等二进制数据,适合传输文件、音频、视频流片段。

4.2 客户端实现与核心API

浏览器通过WebSocket对象提供支持。

function setupWebSocket(url) { // 1. 创建WebSocket连接,协议是 ws:// 或 wss:// (加密) const socket = new WebSocket(url); // 2. 监听连接打开事件 socket.onopen = (event) => { console.log('WebSocket连接已打开'); // 连接建立后,可以发送一些初始化数据 socket.send(JSON.stringify({ type: 'auth', token: 'user-token-123' })); }; // 3. 监听收到消息事件 socket.onmessage = (event) => { // event.data 可能是字符串(文本)或 Blob/ArrayBuffer(二进制) let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); console.log('收到文本消息:', data); handleMessage(data); } else { // 处理二进制数据,例如图片 console.log('收到二进制数据,大小:', event.data.size); handleBinaryData(event.data); } }; // 4. 监听连接错误事件 socket.onerror = (error) => { console.error('WebSocket错误:', error); }; // 5. 监听连接关闭事件 socket.onclose = (event) => { console.log(`连接关闭,代码: ${event.code}, 原因: ${event.reason}`); // 可以根据关闭码决定是否重连 if (event.code !== 1000) { // 1000是正常关闭 console.log('连接异常断开,5秒后尝试重连...'); setTimeout(() => setupWebSocket(url), 5000); } }; // 发送消息的函数 const sendMessage = (payload) => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(payload)); } else { console.warn('WebSocket未连接,消息被丢弃:', payload); // 可以加入消息队列,待连接恢复后发送 } }; // 返回关闭和发送方法 return { close: () => socket.close(1000, '用户主动关闭'), send: sendMessage }; } // 使用示例 const wsManager = setupWebSocket('wss://api.example.com/ws'); // 发送消息 wsManager.send({ type: 'chat', content: 'Hello WebSocket!' }); // 关闭连接 // wsManager.close();

客户端关键点

  • 状态(readyState)CONNECTING (0),OPEN (1),CLOSING (2),CLOSED (3)。发送消息前一定要检查状态是否为OPEN
  • 重连策略:WebSocket连接可能因网络波动、服务器重启而断开。一个健壮的重连策略(如指数退避:1秒、2秒、4秒、8秒...)是必须的。
  • 心跳机制:为了防止中间网络设备(如代理、防火墙)断开空闲连接,需要定期(如每30秒)从客户端向服务器发送一个心跳包(ping),服务器回应pong。浏览器WebSocket API有ping/pong帧,但JavaScript层不能直接发送,通常用发送一个特定含义的文本消息来模拟。
  • 消息序列化:WebSocket只负责传输,消息格式需要自己定义。JSON是最常见的,但对于高频小消息,可以考虑更高效的序列化方式如MessagePack或Protobuf。

4.3 服务端实现与连接管理挑战

服务端实现WebSocket比HTTP复杂得多,因为它要管理大量的持久连接。以Node.js的ws库为例:

const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // 存储所有连接的客户端 const clients = new Set(); wss.on('connection', (ws, request) => { console.log('新的客户端连接'); clients.add(ws); // 你可以从请求头或URL中解析用户信息 // const userId = getUserIdFromRequest(request); // ws.userId = userId; ws.on('message', (message) => { console.log('收到客户端消息:', message.toString()); // 处理消息,例如广播给其他客户端 clients.forEach(client => { if (client !== ws && client.readyState === WebSocket.OPEN) { client.send(`有人说了: ${message}`); } }); }); ws.on('close', () => { console.log('客户端断开连接'); clients.delete(ws); // 清理与该客户端相关的资源 }); ws.on('error', (error) => { console.error('WebSocket错误:', error); }); // 发送欢迎消息 ws.send(JSON.stringify({ type: 'welcome', message: '连接成功!' })); });

服务端核心挑战

  1. 连接状态管理:你需要知道哪些用户在线,他们的连接对象是什么。这通常需要借助Redis等外部存储来在多个服务器实例间共享连接状态。
  2. 广播与群发:如何高效地将消息发给特定房间、群组或所有人?需要设计良好的数据结构(如Map<roomId, Set<ws>>)来管理订阅关系。
  3. 横向扩展:单台服务器有连接数上限。要支持百万连接,必须使用多台服务器。这就引入了新问题:如何将消息路由到正确服务器上的正确连接?通常需要引入消息队列(如Redis Pub/Sub, Kafka)和连接网关。
  4. 安全与认证:WebSocket握手阶段是HTTP,可以在此进行认证(如校验Token)。连接建立后,也需要防止未授权的消息注入。
  5. 资源清理:连接断开后,必须确保从内存中的客户端集合、订阅关系等数据结构中移除,防止内存泄漏。

踩坑实录:我曾负责一个在线客服系统,初期用单Node.js实例,当在线客服和用户数达到几千时,内存飙升,广播消息延迟明显。后来我们引入了Redis来存储连接映射关系和发布广播消息。每个Node实例只管理自己的连接,当需要跨实例广播时,就将消息发布到Redis的特定频道,其他实例订阅该频道并转发给自己管理的相关连接。这个架构升级的关键教训是:WebSocket应用一旦需要扩展,你的挑战就从“处理连接”变成了“管理状态和路由消息”。

5. 三种方案的综合对比与选型指南

光知道怎么用还不够,关键是知道什么时候该用哪个。下面这个表格从多个维度进行了对比:

特性短轮询长轮询 / SSEWebSocket
通信方向客户端单向请求主要是服务器向客户端推送 (SSE是单向)全双工,双向实时通信
协议基础HTTPHTTP (长轮询)/ HTTP (SSE)独立的WebSocket协议(基于TCP)
实时性差 (延迟=轮询间隔)好 (数据产生即推送)极好(毫秒级延迟)
服务器开销极高(大量无效请求)中等 (持久连接占资源)低 (一个连接双向通信)
客户端开销低 (简单)低 (SSE原生简单)中等 (需管理连接、心跳、重连)
数据格式任意 (HTTP支持均可)文本 (SSE标准为文本)文本与二进制
浏览器兼容完美好 (IE需polyfill for SSE)好 (IE10+)
典型场景不频繁的更新检查、兼容性要求极高实时通知、股票行情、新闻推送、监控数据流在线聊天、协同编辑、多人在线游戏、实时交易系统

5.1 决策流程图:我到底该选哪个?

面对一个实时数据需求,你可以遵循以下思路决策:

  1. 是否需要客户端主动、频繁地向服务器发送数据?

    • -> 基本锁定WebSocket。例如聊天(发消息)、游戏(发送操作指令)。
    • -> 进入第2步。
  2. 数据更新是否非常频繁(秒级或更短),且要求延迟极低?

    • -> 选择WebSocketSSE。如果只是服务器向客户端推送,SSE更简单。例如实时GPS位置追踪、高频股票报价。
    • -> 进入第3步。
  3. 数据更新频率中等(几秒到几分钟),且连接主要是服务器向客户端推送?

    • ->SSE是最佳选择。它实现简单,自动重连,资源消耗低于WebSocket。例如新邮件提醒、后台任务进度更新、体育比赛比分。
    • -> 进入第4步。
  4. 数据更新不频繁(几分钟以上),或对实时性要求极低,或需要兼容极度老旧的客户端?

    • -> 可以考虑短轮询。但务必设置合理的、尽量长的轮询间隔,并在客户端做好防抖和渲染优化。

一个简单的口诀:

  • “你问我答,定时刷新”-> 短轮询(迫不得已时用)。
  • “你有消息就喊我,我只听不说”-> SSE(推送通知、数据流首选)。
  • “我们随时畅聊,你来我往”-> WebSocket(交互式实时应用核心)。

5.2 混合使用与降级策略

在实际复杂项目中,经常需要混合使用这些技术。

  • 主通道+控制通道:一个在线文档编辑器,可能用WebSocket传输实时光标位置和文本增量(高频、双向),而用SSE或普通HTTP来接收系统公告或文档历史版本信息(低频、单向)。
  • 降级策略:为了兼容性,你的应用可以优先尝试建立WebSocket连接,如果失败(比如在严苛的企业防火墙后),则自动降级到SSE,再失败则降级到长轮询。许多成熟的库(如Socket.IO)内部就实现了这种优雅降级机制。

6. 进阶考量:生产环境必须面对的坑

掌握了基本用法,只是走出了第一步。要把实时功能稳定地上到生产环境,以下几个问题你必须心中有数。

6.1 连接稳定性与重连设计

网络是不稳定的。移动端切换WiFi/4G、服务器滚动更新、负载均衡器超时,都会导致连接中断。

  • 心跳保活:即使没有业务数据,也要定期(如25-30秒)通过连接发送“心跳”包,告诉中间网络设备这个连接是活跃的。对于WebSocket,可以用ping/pong帧(服务端发起)或发送特定消息模拟。
  • 智能重连:连接断开后,不要立即重连。应采用“指数退避”策略:第一次断开等1秒重连,第二次等2秒,第三次等4秒……直到一个最大值(如30秒)。这可以避免服务器故障恢复瞬间被海量重连请求击垮。
  • 连接状态同步:重连成功后,客户端需要告诉服务器“我回来了,我的最后一条消息ID是XXX”,以便服务器同步丢失期间的消息。SSE的last-event-id头和WebSocket的自定义握手协议可以用于此目的。

6.2 消息的可靠性与顺序性

TCP保证数据包不丢失、不重复、按序到达,但这是在传输层。在你的应用层,依然可能因为网络断开、程序崩溃导致消息丢失。

  • 消息确认(ACK)机制:对于关键消息(如支付成功通知),服务器发送后,需要等待客户端的确认回执。如果超时未收到,则重发。这需要为消息分配唯一ID。
  • 序列号与去重:为消息附加递增的序列号,客户端可以判断消息是否连续,并对重复消息(因重传导致)进行去重。
  • 离线消息存储:对于聊天应用,用户离线时的消息需要存储在服务器(如Redis或数据库),待其重连后按序推送。

6.3 安全与认证

一个开放的WebSocket或SSE端点是非常危险的。

  • 握手阶段认证:在WebSocket的HTTP Upgrade请求或SSE的初始请求中,必须携带身份认证信息(如JWT Token、Cookie)。服务器在建立连接前进行验证,失败则返回HTTP 401。
  • 消息级授权:即使连接建立,每次处理客户端发来的消息时,也要验证该客户端是否有权限执行此操作(例如,是否在发送消息的聊天室内)。
  • 防止DoS:限制单个IP的连接数、设置合理的消息速率限制(rate limiting),防止恶意客户端耗尽服务器资源。
  • WSS与HTTPS:生产环境务必使用wss://https://,对传输内容进行加密,防止中间人攻击和窃听。

6.4 性能与扩展性

当用户量上来后,单机瓶颈很快就会出现。

  • 连接分布:使用负载均衡器(如Nginx)将WebSocket连接分散到多个后端服务器实例。Nginx需要配置UpgradeConnection头部的转发。
  • 会话共享:用户可能连接到任意一个后端实例。你需要一个中心化的存储(如Redis)来记录“用户A在哪个服务器实例上”,以便实现跨实例的消息路由。
  • 发布/订阅模式:这是扩展的关键。每个服务器实例订阅一个全局的消息总线(如Redis Pub/Sub, Kafka)。当实例A需要发送消息给在实例B上的用户时,它只需将消息发布到总线,实例B收到后转发给本地连接。这样,服务器实例之间就解耦了。

7. 实战:从零搭建一个简单的实时应用

理论说再多,不如动手做一遍。我们用一个超简化的“简易股票价格看板”来串联SSE和WebSocket的使用。

7.1 场景定义与架构

  • 功能:一个页面显示几只虚拟股票的价格,价格每秒随机波动。所有连接的客户端看到的价格实时同步。
  • 架构
    • 后端:Node.js + Express。提供一个SSE端点/sse推送股票价格(模拟广播)。同时提供一个WebSocket服务器/ws,允许客户端发送“订阅/取消订阅某只股票”的指令。
    • 前端:一个HTML页面,通过SSE接收所有股票价格更新,并通过WebSocket发送控制指令。

7.2 后端代码实现

// server.js const express = require('express'); const WebSocket = require('ws'); const path = require('path'); const app = express(); const PORT = 3000; // 静态文件服务 app.use(express.static(path.join(__dirname, 'public'))); // 模拟股票数据 let stocks = { 'AAPL': { name: 'Apple', price: 175.50 }, 'GOOGL': { name: 'Alphabet', price: 145.80 }, 'MSFT': { name: 'Microsoft', price: 330.20 } }; // 1. SSE 端点 - 广播所有股票数据 app.get('/sse', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); // 每秒推送一次 const intervalId = setInterval(() => { // 模拟价格波动 for (const symbol in stocks) { stocks[symbol].price += (Math.random() - 0.5) * 2; // 随机涨跌 stocks[symbol].price = Math.max(stocks[symbol].price, 0.01); // 保持为正数 } const data = JSON.stringify({ type: 'update', data: stocks, timestamp: Date.now() }); res.write(`data: ${data}\n\n`); }, 1000); req.on('close', () => { clearInterval(intervalId); console.log('SSE客户端断开'); }); }); // 2. WebSocket 服务器 const wss = new WebSocket.Server({ noServer: true }); const subscribedClients = new Map(); // symbol -> Set of WebSocket connections wss.on('connection', (ws) => { console.log('WebSocket客户端连接'); ws.subscriptions = new Set(); // 记录此客户端订阅的股票代码 ws.on('message', (message) => { try { const { action, symbol } = JSON.parse(message); if (action === 'subscribe' && symbol) { if (!ws.subscriptions.has(symbol)) { ws.subscriptions.add(symbol); if (!subscribedClients.has(symbol)) { subscribedClients.set(symbol, new Set()); } subscribedClients.get(symbol).add(ws); console.log(`客户端订阅了 ${symbol}`); ws.send(JSON.stringify({ type: 'info', message: `已订阅 ${symbol}` })); } } else if (action === 'unsubscribe' && symbol) { if (ws.subscriptions.has(symbol)) { ws.subscriptions.delete(symbol); const clients = subscribedClients.get(symbol); if (clients) { clients.delete(ws); if (clients.size === 0) { subscribedClients.delete(symbol); } } console.log(`客户端取消订阅 ${symbol}`); ws.send(JSON.stringify({ type: 'info', message: `已取消订阅 ${symbol}` })); } } } catch (e) { console.error('解析WebSocket消息失败:', e); } }); ws.on('close', () => { // 清理该客户端的订阅 for (const symbol of ws.subscriptions) { const clients = subscribedClients.get(symbol); if (clients) { clients.delete(ws); if (clients.size === 0) { subscribedClients.delete(symbol); } } } console.log('WebSocket客户端断开'); }); }); // 将Express HTTP服务器与WebSocket服务器关联 const server = app.listen(PORT, () => { console.log(`服务器运行在 http://localhost:${PORT}`); }); server.on('upgrade', (request, socket, head) => { // 将所有 /ws 路径的HTTP升级请求交给WebSocket服务器处理 if (request.url === '/ws') { wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); } else { socket.destroy(); } });

7.3 前端代码实现

<!-- public/index.html --> <!DOCTYPE html> <html> <head> <title>简易股票看板</title> <style> table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } .up { color: green; } .down { color: red; } button { margin: 5px; } </style> </head> <body> <h1>实时股票价格看板</h1> <div> <label>订阅股票代码: </label> <input id="symbolInput" type="text" placeholder="例如: AAPL" /> <button onclick="subscribe()">订阅</button> <button onclick="unsubscribe()">取消订阅</button> <p id="wsStatus">WebSocket状态: 未连接</p> </div> <table id="stockTable"> <thead><tr><th>代码</th><th>名称</th><th>价格</th><th>趋势</th></tr></thead> <tbody></tbody> </table> <script> const stockTableBody = document.querySelector('#stockTable tbody'); const wsStatusEl = document.getElementById('wsStatus'); let ws; let lastPrices = {}; // 1. 建立SSE连接,接收广播数据 const eventSource = new EventSource('/sse'); eventSource.onmessage = (event) => { const { type, data: stocks } = JSON.parse(event.data); if (type === 'update') { updateStockTable(stocks); } }; eventSource.onerror = (err) => { console.error('SSE错误:', err); }; // 2. 建立WebSocket连接,用于发送控制指令 function connectWebSocket() { ws = new WebSocket(`ws://${window.location.host}/ws`); ws.onopen = () => { wsStatusEl.textContent = 'WebSocket状态: 已连接'; console.log('WebSocket已连接'); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); console.log('收到WebSocket控制消息:', msg); alert(`服务器消息: ${msg.message}`); }; ws.onclose = () => { wsStatusEl.textContent = 'WebSocket状态: 已断开,5秒后重连...'; console.log('WebSocket断开'); setTimeout(connectWebSocket, 5000); }; ws.onerror = (error) => { console.error('WebSocket错误:', error); }; } connectWebSocket(); // 更新表格的函数 function updateStockTable(stocks) { for (const [symbol, info] of Object.entries(stocks)) { let row = document.getElementById(`row-${symbol}`); if (!row) { row = document.createElement('tr'); row.id = `row-${symbol}`; row.innerHTML = ` <td>${symbol}</td> <td>${info.name}</td> <td class="price">${info.price.toFixed(2)}</td> <td class="trend">-</td> `; stockTableBody.appendChild(row); } else { const priceCell = row.querySelector('.price'); const trendCell = row.querySelector('.trend'); const oldPrice = lastPrices[symbol] || info.price; const newPrice = info.price; priceCell.textContent = newPrice.toFixed(2); if (newPrice > oldPrice) { trendCell.textContent = '↑'; trendCell.className = 'trend up'; } else if (newPrice < oldPrice) { trendCell.textContent = '↓'; trendCell.className = 'trend down'; } else { trendCell.textContent = '-'; trendCell.className = 'trend'; } } lastPrices[symbol] = info.price; } } // 通过WebSocket发送订阅/取消订阅指令 function subscribe() { const symbol = document.getElementById('symbolInput').value.trim().toUpperCase(); if (symbol && ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'subscribe', symbol })); } } function unsubscribe() { const symbol = document.getElementById('symbolInput').value.trim().toUpperCase(); if (symbol && ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'unsubscribe', symbol })); } } </script> </body> </html>

7.4 运行与观察

  1. 将后端代码保存为server.js,前端HTML保存为public/index.html
  2. 在终端运行node server.js
  3. 打开浏览器访问http://localhost:3000
  4. 你会看到股票价格每秒更新。打开多个浏览器标签,所有标签的价格变化是同步的(因为SSE广播)。
  5. 在输入框输入TSLA(一个模拟代码),点击“订阅”。后端会收到WebSocket指令,并在控制台打印日志。这里只是一个演示,实际订阅逻辑可以更复杂(例如只向订阅了某股票的客户端推送该股票的详细变化)。

这个例子虽然简单,但它清晰地展示了两种技术的分工:SSE负责高效、单向的广播(数据流),WebSocket负责双向的控制指令交互。在实际大型应用中,这种混合架构非常普遍。