兄弟们,今天不聊虚的,把前端开发里最容易让人血压飙升的三件事一次性讲透:跨域、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、DELETE |
Access-Control-Allow-Headers | 允许的自定义请求头,如Content-Type、Authorization |
Access-Control-Allow-Credentials | 是否允许携带 Cookie,值为true |
Access-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里加一行 log:
configure: (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,其实 SSE(Server-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?token=xxx')。但 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解析不了数据流。检查后端返回的 Header,Content-Type必须是text/event-stream; charset=utf-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 上带 Token:
new WebSocket('wss://example.com/ws?token=xxx'),服务端从 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 这三座山,翻过去之后你会发现,它们的底层逻辑是相通的:都是在解决“数据从哪来、到哪去、怎么安全高效地流动”的问题。把这层想明白,知识点就不再是零散的了。