
兄弟们今天不聊虚的把前端开发里最容易让人血压飙升的三件事一次性讲透跨域、SSE、WebSocket。这三样东西是日常开发、面试、排查线上事故都绕不开的硬骨头尤其是最近好多朋友问到“CORS配置错误”“websocket 1006”“SSE idle timeout”这些具体报错说明光知道概念已经不够用了得上实战。这篇文章我会按“是什么、为什么、怎么用、踩了什么坑”的顺序把这套链路完整走一遍。不管你是刚转前端的新人还是写了几年业务想系统补一下实时通信这块的熟手都能在里面找到能直接抄作业的内容。顺带把面试里常问的那些点也覆盖到省得再去翻那些讲一半的八股文。1. 跨域问题先搞懂同源策略到底拦的是什么跨域这个问题被讨论了很多年但很多人其实没想明白一件事跨域限制不是服务器或者浏览器“不让你请求”而是浏览器为了保证安全主动拦下了“响应”。服务器其实已经把数据返回了只是浏览器不允许 JS 代码读取而已。1.1 同源策略的本质同源的定义很简单协议、域名、端口三者完全一致才叫同源。这句话背下来容易但实际判断时经常有人栽在端口上。比如http://localhost:8080调http://localhost:9090这就算跨域因为端口不同。再比如http://和https://之间的请求也是跨域协议不同。浏览器搞同源策略核心目的是防止恶意站点通过脚本偷偷读取你在其他网站的登录态或数据。想象一下如果没有这个限制你打开一个钓鱼页面页面里的 JS 就可以直接去请求你银行的接口读取你的账户信息那整个 Web 世界就崩了。所以这是浏览器层面的安全机制是“拦响应”不是“拦请求”。但这里有个非常容易混淆的点普通的表单提交、img标签加载图片、script标签加载脚本是不受同源策略限制的。这正是 JSONP 能存在的理论基础。而后端的服务器与服务器之间的请求完全不经过浏览器自然也就不存在跨域问题这也是为什么很多项目在联调阶段可以直接用接口工具或者后端代码去请求。1.2 前端最常见的跨域报错长什么样我见过太多人在群里贴报错截图问“为什么我的 axios 请求失败了”结果错误信息里清清楚楚写着Access to XMLHttpRequest at https://api.example.com/data from origin http://localhost:5173 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.这就已经很明确了浏览器没有在响应头里找到Access-Control-Allow-Origin。此时你要做的不是去改前端代码而是去查后端有没有正确配置 CORS 响应头。可惜很多人第一步就搞错方向各种奇技淫巧都试了一遍最后发现是后端少了一行配置。另外一个高频现象是“请求发出去了但控制台报错”。注意如果你的请求是简单请求GET、POST 且 Content-Type 为application/x-www-form-urlencoded、multipart/form-data或text/plain浏览器会直接发请求然后被拦截响应。但如果你的请求带了自定义 Header或者 Content-Type 是application/json浏览器会先发一个OPTIONS预检请求只有预检通过后才会发真正的请求。很多人发现后端明明配了 CORS但接口还是不通就是因为只处理了 GET/POST没处理OPTIONS。2. 跨域方案盘点从 JSONP 到 CORS 再到代理跨域方案说起来很多什么 JSONP、CORS、代理、postMessage、document.domain、window.name……但实际工作中真正高频用到的就三个CORS、前后端开发代理、Nginx 反向代理。JSONP 基本算历史遗留方案了只在一些老系统里还能见到。2.1 JSONP 的原理与局限性JSONP 利用的就是script标签不受同源策略限制这一点。前端动态创建一个 script 标签src 指向带callback参数的接口地址后端把数据包成一个函数调用返回比如handleResponse({name: 张三})。前端提前在全局定义一个同名函数等脚本加载完这个函数就被执行数据就拿到了。看着很巧妙但它的短板是绕不开的只能支持 GET 请求无法发送 POST、PUT、DELETE没有统一的错误处理机制加载失败你很难感知还有安全性问题依赖第三方的 JS 执行等于把信任完全交给对方。所以现在新项目基本不推荐 JSONP除非你接手的是那种十几年前的老系统后端没法改前端也不能上代理才不得不祭出这个方案。我记得早年在某些银行项目里见过类似场景那真是没得选的无奈。2.2 CORS 的正确配置与常见错误CORS 是现在的主流方式后端通过设置响应头来告诉浏览器“这个来源可以访问我的内容”。常用的响应头有这么几个响应头作用Access-Control-Allow-Origin指定允许访问的来源可以是具体域名或*Access-Control-Allow-Methods允许的 HTTP 方法如 GET、POST、PUT、DELETEAccess-Control-Allow-Headers允许的自定义请求头如Content-Type、AuthorizationAccess-Control-Allow-Credentials是否允许携带 Cookie值为trueAccess-Control-Max-Age预检请求的有效期单位秒一个老坑如果后端配置了Access-Control-Allow-Credentials: true那么Access-Control-Allow-Origin就不能写成*必须指定具体域名。有些后端开发不知道这个规则配了*和true浏览器直接拒绝。还有一点值得注意Access-Control-Allow-Headers如果不把前端传的自定义 Header 加进去预检也会失败。比如你的请求带了Authorization后端的允许列表里却没有它那浏览器在预检阶段就会报错Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.如果你用的是 Spring Boot推荐用CorsRegistry或者CrossOrigin注解来配如果用 Nginx则在 location 里加 add_header 指令。我见过很多次“跨域配置错误”的报错最后排查下来都是头信息没配全或者OPTIONS请求没有被正确放行。2.3 开发环境的代理方案与真实请求地址获取前后端分离开发时最舒服的方案是走代理让浏览器以为所有请求都是同源的。Vue 里配置 Vite 或 webpack-dev-server 的 proxy 就是典型做法。以 Vue 3 Vite 为例在vite.config.js里这样写export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })配置完之后前端代码里请求/api/user/list其实代理到了http://localhost:8080/user/list。但这里有一个高频问题“vue 配置跨域代理后如何获取我的真实的请求地址”原因很简单浏览器里你看到的请求地址是http://localhost:5173/api/user/list因为代理在中间做了转发浏览器不知道真实地址是什么。要排查代理是否生效有几个办法看 Network 面板里请求的状态码和响应内容如果返回了真实数据说明代理通了。在后端接口里打印收到的请求路径和 Host确认是否带上了前缀。在 Vite 的 proxy 配置里加configure钩子代理转发时把目标地址打出来。我自己的习惯是在configure里加一行 logconfigure: (proxy) { proxy.on(proxyReq, (proxyReq, req, res) { console.log(Proxying:, req.url, -, proxyReq.path); }); }这样每次请求都会在终端里打出真实转发的地址排查是不是代理路径重写出了问题就一目了然。还有个容易忽略的坑代理只在开发服务器层面生效如果你把前端项目打包后部署到 Nginx不配 Nginx那跨域问题会原样暴露。这也是很多人“本地好好的一上线就跨域”的原因。2.4 Nginx 生产环境的跨域解决方案生产环境里最常见的方式是用 Nginx 做反向代理把/api开头的请求转发到后端服务。这样做的好处是浏览器看到的始终是同一个域名和端口自然没有跨域问题。一个基础配置示例server { listen 80; server_name example.com; location /api/ { proxy_pass http://backend-server:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }注意proxy_pass后面的路径规则如果location /api/里写proxy_pass http://backend-server:8080/;那请求/api/user/list会被转发成/user/list因为location匹配的后缀被替换成 proxy_pass 里 path 的部分。如果你不想去掉/api前缀可以这样proxy_pass http://backend-server:8080;没有斜杠就不会替换路径。这个细节非常经典我见过好几个线上事故就是这里写错了导致后端的接口路由全部 404。如果你不想做反向代理而是直接给后端接口加 CORS 头Nginx 也能做。但要小心add_header在 location 内会继承 server 里的配置如果你在某层写了新的 add_header可能会有覆盖问题。一般建议在需要跨域的 location 里写全location /api/ { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods GET, POST, OPTIONS, PUT, DELETE; add_header Access-Control-Allow-Headers Content-Type, Authorization; if ($request_method OPTIONS) { return 204; } proxy_pass http://backend-server:8080/; }这算是生产环境里比较成熟的模板了。核心是把OPTIONS预检请求直接拦掉返回 204不需要让它打到后端。3. SSE适合单向推送的轻量方案说完跨域进入实时通信的环节。很多人一提到“实时通信”就想到 WebSocket其实 SSEServer-Sent Events在很多场景下反而是更优解。3.1 SSE 协议是怎么工作的SSE 的本质是服务端通过 HTTP 响应持续向客户端推送数据连接保持打开客户端通过EventSource对象接收消息。它和 WebSocket 最大的区别就是SSE 是单向的只有服务端能推客户端只能接收WebSocket 是双向的两边都能主动发。SSE 基于普通 HTTP所以它天然支持一些 WebSocket 很头疼的东西自动重连、断线识别、自定义事件类型。浏览器内置的EventSource会自动处理重连逻辑不用你手写心跳。服务端的响应格式有一定的规范它要求Content-Type必须是text/event-stream而且数据格式有严格约定。最基本的形式是data: {message: hello}注意每条消息后面必须有一个空行这个空行是消息终止符写错了客户端会一直收不到“完整消息”。这里有个前端容易踩的坑如果在服务端用了某些框架的日志输出往响应流里混入了非 SSE 格式的字符控制台就会报错而且你不知道为什么。3.2 SSE 的鉴权方式与连接生命周期SSE 的鉴权确实比 WebSocket 要麻烦一些因为EventSource这个 API 有个限制不能自定义请求头。你没法直接给它加AuthorizationHeader。那怎么办常见的方案有三种用 Cookie 鉴权如果前后端同域或者 CORS 配了credentials浏览器会自动带上 Cookie后端用 Session 或者 Token 从 Cookie 里解析。用查询参数鉴权创建连接时把 Token 拼在 URL 上比如new EventSource(/api/stream?tokenxxx)。但 Token 会暴露在浏览器历史记录和服务端日志里安全性稍弱适合内网或者非核心数据。先请求接口拿连接凭证先调一个普通接口后端返回一个一次性连接 Token短期有效前端拿这个 Token 拼 URL 建 SSE。这种方式相对安全也是我在生产项目里比较推荐的。连接生命周期这块特别容易出问题。SSE 本质上是一条长连接如果中间长时间没有数据推送某些代理服务器比如 Nginx会自动断开空闲连接。等你再想推送时客户端已经断了但服务端不知道继续往断掉的连接里写数据就会报错。热搜词里那个idle timeout waiting for sse说的就是这事。解决思路一般是在服务端加一个心跳机制每 15 到 30 秒发一个注释行或空数据。Nginx 端也可以调大proxy_read_timeout默认是 60 秒建议调到 300 秒以上同时配置proxy_buffering off确保数据不被缓冲能及时推给客户端。3.3 SSE 常见报错与排查技巧第一个常见报错是控制台一直重连状态码看起来是 200但onerror一直被触发。这种情况八成是响应的Content-Type不对前端EventSource解析不了数据流。检查后端返回的 HeaderContent-Type必须是text/event-stream; charsetutf-8否则浏览器会认为这不是 SSE 流。第二个是idle timeout。上面说了要么是代理把空闲连接断了要么是服务端没发心跳。排查时先用 curl 直接请求一下接口看能不能持续拿到数据curl -N http://localhost:8080/api/stream如果 curl 能持续输出数据而浏览器里断了那基本就是代理层的问题。如果 curl 也是断的那就是服务端的问题。第三个坑是 SSE 在部分移动端 WebView 里的兼容性。EventSource的兼容性整体不错但某些古老的安卓 WebView 可能不支持自定义事件所以如果你用了addEventListener(customEvent)可能在低版本设备上不生效。稳妥做法是统一走onmessage或者加一层降级判断。4. WebSocket真正的双向实时通信聊完 SSE再来看 WebSocket。如果说 SSE 是服务端单向广播的利器那 WebSocket 就是聊天、游戏、协同编辑、语音对话这些场景的通行方案。4.1 WebSocket 的握手与数据帧机制WebSocket 和普通 HTTP 并不是竞争关系它其实是借用了 HTTP 协议完成了一次“握手”然后升级成自己的协议。握手过程简单说客户端发一个GET请求带上Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key等 Header。服务器收到后算出一个Sec-WebSocket-Accept返回给客户端。这个计算算法是固定的把客户端发来的 Key 加上一个固定 GUID做 SHA-1 哈希再 Base64 编码。然后连接就从 HTTP 切换成了 WebSocket。握手完成后数据就不再走 HTTP 的文本格式了而是走 WebSocket 自己的数据帧格式。每一帧包含 FIN是否最后一帧、Opcode数据类型文本/二进制/关闭帧、Mask掩码标志、Payload length数据长度等字段。前端平时开发根本不用关心这些细节但面试时如果被问到“WebSocket 和 HTTP 的关系”你能讲清楚握手过程和帧结构已经比大多数人强了。一个我在实际项目中遇到的细节WebSocket 的 URL 用的是ws://或wss://协议wss是ws TLS和https一样需要证书。如果你部署在 HTTPS 页面里却去连ws://的接口浏览器会直接拦截报一个类似Mixed Content的错误。这就是热搜里“websocket 运行到 h5 可以连接打包为 app 连接不了”可能的坑之一H5 页面也许跑在 http 环境下而打包后的 App特别是 Android WebView 或微信内置浏览器强制了 HTTPS 或证书校验导致连接被拒。4.2 WebSocket 使用与鉴权实战前端使用 WebSocket 很简单const ws new WebSocket(wss://example.com/ws); ws.onopen () { console.log(连接已建立); ws.send(JSON.stringify({ type: join, room: frontend })); }; ws.onmessage (event) { const data JSON.parse(event.data); console.log(收到消息:, data); }; ws.onclose (event) { console.log(连接关闭:, event.code, event.reason); }; ws.onerror (error) { console.error(WebSocket 错误:, error); };需要注意onclose里的code和reason是排查问题的关键线索。code 1000表示正常关闭1006表示异常关闭、没有收到关闭帧这个码最坑因为几乎所有异常情况都会表现为1006。具体可能是网络断了、服务端崩溃、代理超时、甚至证书问题你必须结合服务端日志才能定位。鉴权方面WebSocket 就没有 SSE 那么憋屈了因为它没有EventSource的限制。常见做法有两种握手 URL 上带 Tokennew WebSocket(wss://example.com/ws?tokenxxx)服务端从 URL 参数里取 Token 校验。这种方法简单直观但 Token 会出现在日志里建议使用短期有效的 Token。握手时带自定义 Header浏览器里很难自定义 Header但在微信小程序、App、或者某些原生环境中可以。或者先用普通 HTTP 接口比如登录拿到临时 Ticket再在握手连接里通过子协议或首次消息发送鉴权信息。Netty 里做 WebSocket 鉴权一般是在HttpRequestHandler或者自定义的ChannelInboundHandler里在握手升级前拦截请求、解析 Header 或 Query 参数校验不通过就返回 401。Gin 框架里则是在websocket.Upgrade之前先走一个普通的中间件做鉴权。这块面试也常问最好能说出一种具体的实现思路。4.3 高频坑onclose code 1006 与作为服务端的排查路径1006这个错误码在开发中太常见了我甚至见过有人专门写脚本批量测试 WebSocket 连接最后全是1006。这里把我的排查思路整理一下先明确一点前端收到1006只说明连接异常关闭了但关闭的原因是什么浏览器不告诉你。你必须从服务端日志入手。常规排查路径是看服务端有没有对应的连接对象。如果连接压根没建立成功说明握手阶段就失败了。检查是否使用了wss、证书是否过期、域名是否匹配。看服务端是否在某个时间点主动 close 了连接。有些服务端框架会在心跳超时后主动断开如果服务端日志里有类似ReadTimeout或IdleStateHandler触发的记录那基本就是心跳没对上。看中间代理层的配置。如果用 Nginx 代理 WebSocket必须设置Upgrade和Connection头并且调大proxy_read_timeout。如果你的连接稳定在某个时间点比如 60 秒断开十有八九是代理超时。Nginx 配置 WebSocket 代理的要点location /ws { proxy_pass http://backend-server:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里的Connection upgrade是关键少这一行WebSocket 的握手就过不去。proxy_read_timeout默认 60 秒意味着超过 60 秒没有数据来往Nginx 就会断开连接。如果你没做心跳或者心跳间隔大于 60 秒就会周期性遇到1006。4.4 WebSocket 服务端实现要点我看热搜词里有 Gin、Netty、Go 语音长连接说明不少人在折腾服务端 WebSocket。这里简单说几个共通的要点。一是心跳机制。WebSocket 连接本身不会自动探测连接是否存活TCP 层断开可能需要很久才能被感知。所以服务端通常会给自己加一个定时任务比如每 30 秒发一个 ping 帧客户端回 pong 帧如果连续几次没收到 pong就把这个连接判定为死连接主动关闭释放资源。Go 的gorilla/websocket库建议用SetReadDeadline配合SetPongHandler实现心跳。Netty 则可以直接用IdleStateHandler。二是并发写问题。WebSocket 连接底层其实是一个 TCP 连接同一个连接不能被多个 goroutine 或线程同时写数据否则会出数据交错的问题。Go 里就是给每个连接配一个写锁或者用一个 writer goroutine 统一处理发送。这个小细节能避免很多线上偶发 bug。三是连接数管理。服务端要维护一个连接管理器连接池支持注册、注销、按房间/用户维度推送。很多实时业务比如语音长连接、聊天室本质上就是往这个连接池里发消息。连接泄漏是常见故障要么是服务端没处理客户端异常断开比如1006之后没清理连接要么是客户端没有做重连后的资源释放。5. 面试与选型跨域、SSE 与 WebSocket 的边界这部分针对那些准备面试的朋友也帮大家把这些技术在选型上的边界理清楚。5.1 前端面试高频题速答先把热搜里出现的高频题目整理成一个速查表面试题核心回答要点跨域是什么如何解决同源策略是浏览器安全机制拦响应不拦请求解决方案有 CORS、代理、JSONP、Nginx 反代等JSONP 的原理script 标签不受同源限制用 callback 参数让后端返回可执行函数CORS 预检是什么非简单请求会先发 OPTIONS后端必须正确处理预检SSE 和 WebSocket 的区别SSE 单向、基于 HTTP、自动重连WebSocket 双向、独立协议WebSocket 断线 1006 怎么排查从服务端日志、代理超时、心跳机制、证书等角度入手WebSocket 鉴权怎么做URL 参数、Cookie、首次消息鉴权、握手前中间件鉴权什么时候用 SSE什么时候用 WebSocket实时行情、日志推送用 SSE聊天、游戏、协作用 WebSocket还有一个容易被问到的点WebSocket 和 HTTP 的关系。千万别说“WebSocket 是建立在 TCP 上的”要强调它经历了 HTTP Upgrade 握手之后再走自己的协议。5.2 方案选型的判断标准很多新人容易陷入“实时就该用 WebSocket”的误区。我个人的判断标准很简单如果只需要服务端主动给客户端推送数据且推送频率不算极端比如航司价格变更、IMAP 邮件提醒、实时日志滚动SSE 是优选。它实现成本低兼容性靠eventsource-polyfill可以兜底还能自动重连。如果是强交互场景比如在线协作、聊天、游戏同步、语音通话那必须上WebSocket因为客户端需要持续向服务端发数据SSE 做不到。如果推送和请求不是一个通道但服务端希望兼容 HTTP 2.0 的多路复用还可以考虑HTTP/2 Server Push不过现实中用得少这里不展开。补充一句SSE 其实可以配合自定义EventSource改造来发送一些简单的心跳信息但真正需要上行数据的场景老老实实用 WebSocket。5.3 不要忽视的跨时钟域概念热搜词里出现了“跨时钟域处理”“CDC”这些词乍一看像是硬件或者 FPGA 领域的术语。前端开发者可能觉得这是另一个世界。但有意思的是在性能优化、可视化和异步事件处理的语境下“时钟域”这个类比偶尔也会被人借用用来形容前端不同模块之间事件同步或“心跳频率”不一致的问题。为避免歧义这里只把这个词当成一个对比前端里不同异步时序比如宿主环境的事件循环、WebSocket 心跳、SSE 重连计时器同样需要对齐否则也会出现“数据不同步”的现象。当然这不属于前端日常主战场面试如果被问到承认不熟、表明有了解并转到自己的领域可能更稳妥。总结一点自己的体会做前端这几年踩过最多坑的地方就是“你以为连上了但它其实早就断了”。SSE 和 WebSocket 的连接管理永远不要只看建立时的成功率要持续关注连接的存活状态做好心跳、重连和错误恢复这比写业务逻辑还重要。最后再分享一个小技巧排查 WebSocket 问题时用浏览器的 Network 面板切换到 WS 标签页里面能看到每一帧的收发情况。如果帧的内容和你的预期不符比如发的是文本却是二进制帧那八成是服务端把数据打包格式搞错了。这种问题肉眼很难发现但要会利用工具去拆解帧内容。跨域、SSE、WebSocket 这三座山翻过去之后你会发现它们的底层逻辑是相通的都是在解决“数据从哪来、到哪去、怎么安全高效地流动”的问题。把这层想明白知识点就不再是零散的了。