跨域、SSE与WebSocket:实时通信全链路实战指南
做前端的兄弟应该都有过这种经历页面本地跑得好好的一联调后端接口就直接白屏报错控制台里红通通一行Access to XMLHttpRequest at http://api.example.com from origin http://localhost:8080 has been blocked by CORS policy。这时候八成就是跨域。跨域、SSE、WebSocket 这三件事看着是三个独立知识点在面试题里也经常被分开问但实际项目里它们经常是连在一起的你要接入一个实时消息推送前端调接口先撞上跨域选型时在 SSE 和 WebSocket 之间纠结连上了又要处理断线重连。这篇文章我把整条链路串起来讲从跨域原理到 CORS 配置从 SSE 消息推送到 WebSocket 双向通信每个环节都配上我实际踩过的坑和排查思路适合正在做实时通信功能、或者准备前端面试的朋友直接参考。1. 先搞明白跨域到底在跨什么1.1 同源策略浏览器为什么要管这么宽同源指的是三个维度完全一致协议http/https、域名、端口。三者只要有一个不一样浏览器就会判定为跨域。比如你本地开发跑在 http://localhost:8080后端接口在 http://api.example.com:9090端口不同就是跨域前端页面是 https 而接口是 http协议不同也是跨域。同源策略本质上是浏览器做的一道安全隔离。想象一下假如没有这道限制你在浏览任意一个网页时页面里藏着的脚本都能随意往你登录过的银行网站发请求、读返回结果那你的账号信息就完全暴露了。所以浏览器默认只允许同源的页面脚本互相访问资源跨域请求不是不能发而是发出去了但浏览器拦截了响应。这一点很重要——很多人以为跨域是后端拒绝了你其实请求可能已经到了服务器只是浏览器出于安全策略不允许前端拿到返回数据。理解了这一点跨域方案的设计思路就清晰了要么让浏览器认为这个响应是允许被当前页面读取的CORS要么绕开浏览器的这种管控JSONP、代理转发。后面讲的每一种方案本质上都是在和这条同源策略博弈。1.2 跨域报错长什么样怎么快速定位我自己见过的跨域报错五花八门最经典的几种Chrome 控制台直接弹出Access to XMLHttpRequest at ... has been blocked by CORS policy这是最常见的一种。有的项目会封装一层报错文案比如跨域访问被拒绝请检查浏览器配置!。这种往往是客户端拿到浏览器错误后做的统一提示看着吓人其实还是 CORS。还有一种是发 POST 请求时会先发一个 OPTIONS 预检请求preflight如果 OPTIONS 返回 403 或者没有正确响应浏览器会报Method OPTIONS is not allowed by Access-Control-Allow-Methods。快速定位的方法很固定打开 Network 面板先看这个请求到底发出去了没有、状态码是什么、响应头里有没有Access-Control-Allow-Origin。如果请求根本没出现在 Network 里那是前端代码问题如果请求出现了但响应头缺了 CORS 相关字段那是后端配置问题如果 OPTIONS 请求被网关或者 Nginx 拦了那是中间的代理层问题。按这个思路排查基本不会走偏。2. 跨域方案选型JSONP、CORS 还是代理2.1 JSONP老古董但还有场景JSONP 的核心原理是script 标签加载外部脚本不受同源策略限制。所以前端动态创建一个 script 标签把接口地址拼上 callback 参数服务器返回一段函数调用形式的 JavaScript 代码前端预先定义好这个回调函数就能拿到数据了。script function handleData(data) { console.log(data); } /script script srchttp://api.example.com/user?callbackhandleData/script后端如果支持 JSONP会返回类似于handleData({name: 张三})这样的内容浏览器加载这段脚本后自动执行 handleData。这个方案的优点是不需要后端改 CORS 配置兼容性极好远古浏览器都支持缺点是只能发 GET 请求没法处理异常状态码还容易引发 XSS 风险。现在新项目基本不用了但我在对接一些老系统、或者对方后端没法改 CORS 配置的时候还是会用它兜底。如果你在面试里被问到 JSONP核心就答三点原理script 不受同源限制、局限只能 GET、安全问题。2.2 CORS后端配置的三个关键 HeaderCORSCross-Origin Resource Sharing是现在最主流的跨域方案思路最简单粗暴后端在响应里告诉浏览器这个跨域请求我允许。核心就是三个响应头Access-Control-Allow-Origin允许哪个源访问可以是具体域名如 http://localhost:8080也可以是*。注意如果后面要带 cookie就不能用*必须写具体源。Access-Control-Allow-Methods允许哪些 HTTP 方法比如 GET, POST, PUT, DELETE, OPTIONS。Access-Control-Allow-Headers允许前端带哪些自定义头比如 Content-Type, Authorization。前端只要发了自定义头而这个头不在允许列表里预检就会失败。这三个头是新手最容易配错的地方。我见过好多次后端同学只配了 Allow-Origin结果前端一加 Authorization 头就报错其实就是 Allow-Headers 没配上。还有一个坑跨域请求如果带了 cookie 或者认证信息前端要设置withCredentials: true后端不能使用*通配符而且还要额外返回Access-Control-Allow-Credentials: true。这三个条件缺一不可少一个浏览器都会拦。2.3 开发环境代理与 Nginx 生产部署开发环境的跨域主流做法是用代理转发而不是直接改后端。以 Vue 项目为例vue.config.js 里配 devServer.proxymodule.exports { devServer: { proxy: { /api: { target: http://api.example.com:9090, changeOrigin: true, pathRewrite: { ^/api: } } } } };原理很好理解浏览器请求的是 http://localhost:8080/api/user这个请求是同源的不会被拦webpack-dev-server 收到后转手发给真实后端后端返回给 dev serverdev server 再转给浏览器。整个过程中浏览器只跟同源的 dev server 通信压根不存在跨域问题。因为代理发生在服务器侧浏览器的同源策略管不到。生产环境更常见的是 Nginx 反向代理配置思路一模一样location /api/ { proxy_pass http://api.example.com:9090/; proxy_set_header Host $host; }这里有个非常典型的场景Windows 上本地用 Nginx 部署了一个前端页面页面要调另一个服务端的接口很多人会问还需要配跨域吗。答案是只要页面和接口不是同一个源协议、域名、端口任一不同浏览器就会拦跟你是不是 Nginx 部署的没关系。所以要么在 Nginx 里配 proxy_pass 做转发要么在后端配 CORS二选一。把跨域理解成源的问题而不是部署方式的问题很多疑惑就解开了。3. 实时通信方案短轮询、SSE、WebSocket 怎么选3.1 先做对比再谈选型搞定了普通接口的跨域接下来是重头戏实时通信。实时通信的方案到现在就四类短轮询、长轮询、SSE、WebSocket。方案方向实现成本实时性典型场景短轮询前端定时请求单向低一般有延迟低频状态刷新长轮询前端请求挂起服务端有数据再响应中较好早期聊天场景SSE服务端单向推送低高消息通知、日志流、AI 流式输出WebSocket双向全双工中高高聊天、协作、实时音视频信令我做选型时习惯先问三个问题数据是单向还是双向实时性要求到底多高服务端改造成本多大如果只是服务端往客户端推消息比如通知、告警、AI 流式返回SSE 就够了没必要上 WebSocket如果业务是双向的客户端要频繁发消息、服务端也要主动推比如聊天室、多人协作编辑器那就直接 WebSocket。短轮询适合那种几分钟一次也无所谓的场景比如订单状态偶尔刷新一下。长轮询现在用得少主要是实现别扭、连接管理麻烦一般要么升级成 SSE 要么直接上 WebSocket。3.2 SSE单向推送的轻量方案SSEServer-Sent Events很多人不熟悉其实它比 WebSocket 简单得多。客户端用一个 EventSource 对象就能建立连接服务端返回的 Content-Type 是 text/event-stream然后持续往连接里写数据就可以了。SSE 最大的优势是自动重连和断点续传是协议自带的。连接意外断开浏览器会自动重新连接而且会带上Last-Event-ID这个头告诉服务端我上次收到哪一条了服务端可以从断点继续推。这些能力 WebSocket 全部要自己手写这也是我经常劝大家能上 SSE 就别上 WebSocket的原因。SSE 的消息格式是纯文本、按行解析基本单位是一个事件块用空行分隔id: 1 event: message data: 第一条数据 id: 2 data: 第二条数据data 是数据内容id 是事件编号event 是事件类型前端用 addEventListener 监听retry 是重连间隔。前端监听方式const es new EventSource(/api/stream); es.onmessage (e) console.log(e.data); es.addEventListener(notice, (e) { /* 自定义事件 */ });我上个月帮一个团队接 AI 对话的流式输出后端本来就是一句一句吐字用 SSE 简直天作之合前端代码几十行搞定还没有 WebSocket 那些心跳、重连的事。后面第 4 节我会把完整实例跑一遍。3.3 WebSocket双向全双工通信WebSocket 的出现是为了解决 HTTP 半双工、服务端没法主动推的问题。它的握手过程就是一次 HTTP 升级客户端发一个带Upgrade: websocket头的请求服务端返回101 Switching Protocols之后连接就变成全双工的 TCP 长连接两边可以随时互相发数据。和 SSE 相比WebSocket 有两个核心差异第一是双向客户端不仅能收还能随时发第二是它是真正的长连接服务端需要维护连接状态这就带来了连接管理、心跳保活、断线重连、鉴权等一系列问题。这些在面试里都是高频考点尤其是WebSocket 断线重连怎么做怎么处理 1006 错误这类问题。语音类实时交互场景比如用 Python 的 websockets 库写async def voice_socket(websocket)本质上也是靠 WebSocket 维持双向数据通道只是消息体换成了音频流连接管理的心跳、鉴权逻辑一个都跑不掉。1006 是一个很特殊的关闭码——它是非正常关闭的意思表示连接在没有收到正常关闭帧的情况下断了。常见原因有网络切换、服务端进程重启、连接空闲被网关回收、心跳超时没被及时发现。我在实战里遇到 1006 的次数不少排查思路我放到第 5 节详细讲。4. 实操记录完整跑通 CORS、SSE、WebSocket4.1 后端 CORS 配置实例先给一个后端 CORS 配置的实操样本。我拿最常见的 Spring Boot 和 Node.js 举例。Spring Boot 项目里最省事的方式是写一个 CorsFilterConfiguration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意addAllowedOriginPattern(*)和setAllowCredentials(true)可以同时使用这是 Spring Boot 的新写法如果用的是addAllowedOrigin(*)再开 AllowCredentials 会报错因为在旧版本里两者互斥。这个坑我在升级老项目时踩过升级后接口全部 CORS 报错定位了半天才发现是版本行为变了。Node.js 的 Express 项目就简单多了装一个 cors 中间件const cors require(cors); app.use(cors({ origin: [http://localhost:8080, https://example.com], methods: [GET, POST, PUT, DELETE, OPTIONS], allowedHeaders: [Content-Type, Authorization] }));还有 Nginx 层面的一种做法如果后端你改不了可以在 Nginx 上加响应头location /api/ { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, OPTIONS; add_header Access-Control-Allow-Headers Content-Type, Authorization; if ($request_method OPTIONS) { return 204; } }这里有个细节用$http_origin而不是写死域名并且要配合Access-Control-Allow-Credentials: true才能带 cookie。写死*的话一旦前端开了 withCredentials 就会被浏览器拒绝。4.2 SSE 场景实战服务端推送与前端消费SSE 服务端实现我用 Express 随手写一个const express require(express); const app express(); app.get(/api/stream, (req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: req.headers.origin }); let id 0; const timer setInterval(() { id; res.write(id: ${id}\n); res.write(data: 当前时间 ${new Date().toLocaleTimeString()}\n\n); }, 1000); req.on(close, () clearInterval(timer)); }); app.listen(3000);测试的时候不用急着写前端直接用 curl 就行curl -N http://localhost:3000/api/stream-N参数表示关闭缓冲否则 curl 会攒一批才输出看起来像没反应。这一步很多人忽略导致以为服务端没推数据。前端消费就一行创建连接const es new EventSource(http://localhost:3000/api/stream); es.onmessage (e) { console.log(收到:, e.data); };项目里真正要操心的反而是鉴权和超时。EventSource 不支持自定义请求头所以 SSE 鉴权一般有三种做法用 cookie跨域时要配 Allow-Credentials、把 token 拼在 URL query 上、或者用 fetch 的 ReadableStream 自己解析 SSE 流这样能加 Authorization 头。其中最常用的还是 cookie 或 query 传 token。至于超时浏览器和中间层Nginx、网关、负载均衡都会对空闲连接有超时回收机制表现就是 stream disconnected或者日志里出现idle timeout waiting for SSE。解决办法是服务端定期发一个注释行: keepalive让连接一直有事做同时把 Nginx 的 proxy_read_timeout 调大。4.3 WebSocket 实战前端封装、心跳与重连WebSocket 前端代码本身很简单const ws new WebSocket(ws://localhost:3000/ws); ws.onopen () ws.send(hello); ws.onmessage (e) console.log(e.data); ws.onclose (e) console.log(closed, e.code, e.reason);但裸写根本没法上生产因为你得处理心跳、自动重连、消息堆积、连接状态同步。我一般在项目里封装一个类核心逻辑就三块建立连接、定时心跳、断线重连。class ReconnectingWebSocket { constructor(url, options {}) { this.url url; this.reconnectTimes 0; this.maxReconnect options.maxReconnect || 5; this.heartbeatInterval options.heartbeatInterval || 30000; this.connect(); } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.reconnectTimes 0; this.startHeartbeat(); }; this.ws.onclose (e) { this.stopHeartbeat(); if (this.reconnectTimes this.maxReconnect) { this.reconnectTimes; setTimeout(() this.connect(), 1000 * this.reconnectTimes); } }; } startHeartbeat() { this.heartbeatTimer setInterval(() { this.ws.send(ping); }, this.heartbeatInterval); } stopHeartbeat() { clearInterval(this.heartbeatTimer); } }心跳是 WebSocket 保活的核心。因为浏览器和服务器之间可能隔着一堆网关这些网关通常会把空闲的 TCP 连接回收掉但是连接回收时前端不一定能立刻感知导致你发现消息收不到了但 onclose 一直没触发。解决方式就是前端周期性发一个心跳包ping服务端收到后回一个 pong两边都知道对方还活着。如果连续几次心跳没回应前端就该主动 close 重连。我封装心跳时一般还会在 onmessage 里重置一个最近收到消息时间只要还在收消息就不发心跳避免频繁刷消息时心跳包反而成了负担。这个细节常规教程里很少讲但实际跑起来会让日志干净很多。5. 高频问题与排查技巧实录5.1 跨域配置错误速查表把常见的 CORS 配置错误整理成一张表方便大家直接对号入座报错关键词大概率原因处理办法has been blocked by CORS policy响应头缺 Access-Control-Allow-Origin后端补 CORS 头或走代理Method OPTIONS is not allowedAllow-Methods 没包含 OPTIONS允许 OPTIONS 方法Request header field authorization is not allowedAllow-Headers 没包含 Authorization把 Authorization 加进允许头withCredentials 与通配符冲突用了 * 又开了凭证模式改成具体源并加 Allow-CredentialsResponse to preflight request doesnt pass access control check预检响应本身不合法检查网关/Nginx 是否拦截 OPTIONS有一个高频误区是跨域访问被拒绝请检查浏览器配置!。这种报错文案一看就是产品层统一提示浏览器配置一般没问题真正的锅还是后端 CORS 头没配全或者前端请求带了不允许的 header。遇到这种大而化之的报错永远先看 Network 面板别被提示词带偏。还有一类经典场景Vue 里配置了跨域代理后怎么看真实请求地址。很多人发现配了 devServer.proxy 之后接口地址写的是 /api/user但想知道后端实际收到的完整 URL。方法是看 Network 面板里那个请求的 Request URLdev server 会显示代理后的目标地址或者在服务端打日志看 req.url。注意不要把浏览器里显示的 localhost 当成真实请求地址那是代理前的样子。5.2 SSE 断连idle timeout 与 Last-Event-IDSSE 连接断开的报错最常见的就是stream disconnected before completion: idle timeout waiting for SSE。我遇到过一模一样的情况连接建立后一直没数据过了大约 30 秒被掐断后端啥错没有日志干干净净。原因就是中间网关空闲超时。HTTP 长连接空闲一段时间没有字节流动网关就会按策略断开。要解决最简单的是让服务端周期性输出注释行: keepalive前端即使收到纯注释也会重置连接空闲时间网关就不认为它空闲了。另一个思路是调大 Nginx 的 proxy_read_timeout这个参数默认 60 秒短视频项目够用SSE 长连接得调到 300 秒以上甚至更长。还有个我踩过的坑服务端还没来得及发第一条数据连接就被前端关掉了浏览器会无限重连后端日志刷屏。这种情况要检查是不是 CORS 又出问题了——EventSource 跨域发起失败时浏览器会一直重试看着像服务端没数据其实是压根没连上。先 curl 通再上 EventSource能省一大半排查时间。SSE 的好处是断线重连由浏览器自动完成而且会自动带上 Last-Event-ID 头。服务端如果做了事件编号记录就能从断点继续推用户不会丢消息。这一点我用过的几套消息系统里能做到的其实不多但它确实是 SSE 和 WebSocket 的一个关键区别。5.3 WebSocket 连接断开1006、onclose 和重连策略WebSocket 的 onclose 里有个 code 字段1006 是开发中最常遇到的奇怪值。它表示连接异常关闭也就是既没有收到正常的 close 帧也没有触发错误码。我总结下来最常见就几种服务端进程崩溃或被 kill连接被操作系统回收网关/负载均衡空闲超时把连接断了客户端长时间不发数据、也不做心跳被中间层判定为僵尸连接移动端网络切换Wi-Fi 切 4GTCP 状态错乱排查思路我一般这样走先看服务端日志有没有正常收到 close 或有异常堆栈再看服务端有没有心跳检测机制服务端定期 ping客户端回 pong最后看中间层Nginx、SLB的超时配置。如果服务端一直没有主动断开那 1006 多半是网络链路问题前端能做的就是做好自动重连。重连策略有几点经验指数退避加随机抖动避免所有客户端断开后同时重连造成惊群重连次数要设上限重连成功后要重建订阅状态连接打开期间不要重复发起重连。另外不要在 onerror 里直接重连因为 onerror 之后通常会紧跟 onclose在 onclose 里统一处理重连最干净。这个顺序我之前写错过同一根连接重连了两次服务端日志里全是重复的连接建立记录。还有热词里经常出现的那个组合onclose, code: 1006, reason: , reconnect: true。reason 为空是正常现象很多服务端关闭时根本不回 reason别因为 reason 空就以为是自己代码写错了。重点看 code 和周边日志。另外要单独提醒一个场景在浏览器 H5 页面里 WebSocket 连得好好的一打包成 App 就连接不上了。这类问题十有八九出在 URL 上——打包后的 WebView 里页面的源跟本地开发时不一样懒加载、混合内容校验、域名白名单都会有差异。如果是内网测试地址还得确认 App 有没有配网络权限、是不是 https 页面里用了 ws混合内容被 WebView 拦截。最直接的排查办法先拿手机浏览器打开同样的页面看能不能连能连就是打包配置问题不能连就是网络链路问题。做联调或者压测 WebSocket 服务时我习惯用 JMeter 装一个 WebSocket Sampler 插件它可以模拟真实客户端建立连接、发消息、收消息比自己在页面里点半天高效得多也能直接验证心跳间隔和断线重连逻辑。Netty 做 WebSocket 网关的场景下鉴权最好在握手阶段处理用 HTTP Header 或握手 URL 里的 token 校验校验不通过直接拒绝升级不要等连接建立后再踢人否则恶意连接会一直占着资源。Gin 的 WebSocket 也是同理Upgrade 前先走中间件鉴权。5.4 鉴权怎么做SSE 与 WebSocket 的 Token 认证实时连接的鉴权和普通 HTTP 请求不太一样核心问题是怎么在连接建立前完成身份校验。WebSocket 最简单new WebSocket 时传不了自定义 header但可以在 URL 后面拼 token比如ws://server/ws?tokenxxx服务端在握手阶段校验或者用浏览器 WebSocket API 的第二个参数 protocols 传子协议变相带 token更干净的方案是先用 HTTP 登录拿 token再用这个 token 二次校验。服务端拿到 token 后最好再验证一下 token 对应的用户连接权限不要只验签名。SSE 因为 EventSource 不支持自定义 header常见做法是 token 放 query或者依赖 cookie。cookie 方案做跨域时别忘了 withCredentials 以及后端 Allow-Credentials。用 token 放 query 有一个安全隐患URL 会出现在访问日志里token 可能泄。所以 query 传 token 更适合短时效 token过期时间别设太长。我自己的习惯是WebSocket 用子协议或 query 传 tokenSSE 优先 cookie实在不行才 query。并发连接数也要设限——客户端断线重连、多个 Tab 页同时开着连接数很容易翻倍后端要按用户维度控制最大连接数否则一个用户开十个页面服务端能给你开十路推送。前后端联调实时通信功能的时候我最后还有一个习惯准备一份 curl 命令集把 CORS、SSE、WebSocket 三种场景都覆盖到。curl 不受浏览器同源策略影响所以它只用来验证服务端行为是否正确而浏览器才是检验跨域是否放行的唯一标准。两个工具配合出了问题能快速分清是服务端 bug 还是配置问题。这套排查流程我用了很多年每次都能让我在混乱的联调现场少掉不少头发。