跨域与实时通信:CORS、SSE、WebSocket原理与实战
2026/9/16 3:06:44 网站建设 项目流程

从跨域到 WebSocket:前端跨域方案、SSE 与双向实时通信详解

先讲个真事。我之前维护一个后台管理系统,前端跑在localhost:8080,后端接口在localhost:3000,页面一打开,控制台直接甩过来一句“跨域访问被拒绝,请检查浏览器配置!”。那段时间天天有人问我“是不是你浏览器设置有问题?”,实际上浏览器只是按规则办事,真正的问题是后端响应头没配对,或者前端请求方式越过了同源策略允许的边界。后来做实时消息推送,又经历了从轮询到 SSE 再到 WebSocket 的完整演进路径,踩过idle timeout waiting for sse,也排查过WebSocket onclose code: 1006,今天就把这些经验一次性写清楚。

这篇文章适合正在做前后端分离项目的开发、准备前端面试的同学,以及那些已经被跨域和 WebSocket 折磨过但始终没系统梳理过原理的人。我会把同源策略、CORS、JSONP、反向代理、SSE、WebSocket 握手与鉴权、断线重连、消息可靠性这些点串成一条线讲,尽量做到原理、代码、避坑三者都有。

1. 先搞明白:同源策略到底挡了什么,又放过了什么

1.1 同源的定义与“拦截”的本质

同源策略是浏览器最核心的安全模型,所谓“源”由三部分组成:协议、域名、端口。三者完全一致才叫同源。http://localhost:8080http://localhost:3000端口不同,不同源;http://a.comhttps://a.com协议不同,也不同源。

很多人有个误解,觉得跨域是服务端限制了你的请求,所以拼命去改前端配置。实际上请求是发出去了,服务端也正常处理了,只是浏览器在接收到响应后,发现响应头里没有允许当前源访问的声明,于是把响应拦下来不交给 JavaScript,并且打印出类似No 'Access-Control-Allow-Origin' header is present on the requested resource的报错。这就是为什么你用 curl 直接请求后端接口能拿到数据,浏览器里却报跨域——拦截发生在浏览器这一层,而不是网络层面。

理解了这一点,“跨域访问被拒绝,请检查浏览器配置!”这句话的误导性就很明显了:绝大多数跨域问题不需要动浏览器配置,需要调整的是服务端响应头、前端代理,或者把请求方式改成浏览器允许的形式。

1.2 被拦截的与漏网的:为什么会有 JSONP

同源策略不是把跨域请求一刀切全禁掉,它重点拦截的是“跨域读取响应内容”。具体来说:

  • fetchXMLHttpRequest默认受同源策略限制,跨域请求没有服务端明确授权,响应数据不可读。
  • <script><img><link>这类带src/href的标签不受同源策略限制,原因是早期互联网需要加载外部脚本、图片、样式表。
  • 表单提交(form的 POST)通常允许发送,但不允许跨域读取返回的页面内容。
  • Cookie、LocalStorage 按域隔离,跨域默认读不到对方的存储。

正是“script 标签不受同源限制”这个特性催生了 JSONP。它的思路很简单:前端动态创建一个<script>src指向一个带callback参数的后端接口,后端把数据包在回调函数里返回,浏览器当作脚本执行,前端预先定义好的全局函数就收到了数据。JSONP 的问题也很明显:只能发 GET 请求,没有标准错误处理,依赖全局函数,现在实际项目里很少作为首选方案,但很多老系统(尤其是 PHP 后端)还在用,所以面试和存量项目维护时都会遇到。

1.3 从报错信息判断是哪一类跨域问题

不同场景的报错信息其实能直接告诉你问题出在哪,我列几个最常见的:

报错信息问题阶段常见原因
No 'Access-Control-Allow-Origin' header is present响应被拦截服务端没设置 CORS 响应头,或没匹配到当前 Origin
Response to preflight request doesn't pass access control check预检请求失败服务端没有正确处理 OPTIONS 请求,或没有返回 Allow-Methods/Headers
The value of the 'Access-Control-Allow-Origin' header ... must not be the wildcard '*'携带凭证时出错Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin: *同时出现
Content-Type is not allowed by Access-Control-Allow-Headers预检失败请求头里有自定义头或非简单 Content-Type,但服务端没声明允许

我一直建议团队排查 CORS 问题时先用浏览器开发者工具看 Network 面板里的请求记录。如果看到一条OPTIONS请求,那是预检;如果预检成功但主请求还是报错,那就要看主请求的响应头。另外现在有些浏览器还会拦截“私有网络访问”(Public Network Access,旧称 PNA),比如公网页面请求内网接口,这种会多出额外限制,别被它误导出方向。

2. CORS 实操与踩坑:从预检请求到 credentials 与 nginx 配置

2.1 CORS 是怎么工作的:简单请求与预检请求

CORS(跨源资源共享)是现在的跨域主流方案,它的规则可以概括为:浏览器帮你把“源”信息带上,由服务端决定是否允许。

并不是所有跨域请求都会触发预检。浏览器把请求分为简单请求和非简单请求。简单请求必须同时满足几个条件:方法是GET/HEAD/POST;Content-Type 是application/x-www-form-urlencodedmultipart/form-datatext/plain;请求头里没有自定义头。如果条件不满足,浏览器会先发一条OPTIONS预检请求,询问服务端:我要跨域发一个PUT请求,带Authorizationapplication/json,你允不允许?服务端通过响应头回答允许后,浏览器才发真正的请求。

预检机制可以类比成寄快递:普通包裹直接投递,特殊物品或贵重物品呢,快递公司先打电话问收货人能不能收,确认了再送。“打电话”这个过程就是预检,看起来多了一次请求,但能避免真正发起大请求之后才发现被拒。

服务端处理预检时,重点是识别OPTIONS请求并返回这几个头:

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With Access-Control-Allow-Origin: https://your-frontend.com Access-Control-Max-Age: 86400

其中Access-Control-Max-Age表示预检结果可以缓存多长时间,单位秒。设置合理的话,同一浏览器短时间内对同一接口不会反复发 OPTIONS,能明显减少请求次数。

2.2 credentials 与通配符的冲突:最常见的坑

跨域请求如果带上 Cookie 或 HTTP 认证信息,前端请求要设置credentials: 'include',服务端响应必须带上Access-Control-Allow-Credentials: true,而且此时Access-Control-Allow-Origin不能是*,必须明确指定具体源。

这个规则坑过很多人,包括我自己。刚开始图省事,后端直接写Access-Control-Allow-Origin: *,前端一加withCredentials: true,浏览器立刻报错。解决方式有两种:如果不需要带 Cookie,就把前端的 credentials 去掉,继续用*;如果需要带 Cookie,后端得动态读取请求头里的Origin并原样返回,同时设置Vary: Origin,方便代理缓存。

Access-Control-Allow-Origin: https://your-frontend.com Access-Control-Allow-Credentials: true Vary: Origin

动态回显 Origin 还有个额外风险要注意:如果你不加校验就回显任意 Origin,等于允许所有网站带着你的 Cookie 发起跨域请求,这在某些场景下可能造成 CSRF 风险。稳妥一点的做法是维护一个允许的域名列表,只有匹配才回显。

2.3 nginx 跨域配置实战

生产环境用 nginx 做反向代理是常见的跨域方案,我之前整理过一个可以直接套用的配置块:

location /api/ { # 处理预检请求 if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always; add_header Access-Control-Max-Age 86400 always; add_header Access-Control-Allow-Credentials true always; return 204; } # 普通请求 proxy_pass http://backend-server:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 实际请求也补上 CORS 响应头 add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; add_header Vary Origin always; }

几个细节说一下。always参数确保即使是 4xx/5xx 响应也带上 CORS 头,否则很多错误场景前端根本看不到真实状态码。Access-Control-Allow-Origin $http_origin是为了配合 credentials 不能用星号,直接回显请求来源。if ($request_method = OPTIONS)属于 nginx if 的合法用法,前提是 location 里没有其他会冲突的指令,实际测试下来没问题。

也有团队喜欢用map做域名白名单:

map $http_origin $cors_origin { ~^https://(www\.)?example\.com$ $http_origin; default ""; } server { location /api/ { if ($cors_origin) { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true always; } } }

这样不是白名单里的域名就不会收到 CORS 头,进一步降低风险。注意if里加自定义头会有 nginx 对 add_header 的生效范围限制,要把所有 add_header 都写在if里或者后面重新声明,否则可能出现头不生效的情况。

2.4 除了 CORS,还有哪些跨域手段

JSONP:前面已经讲过,适合老系统接口和只能 GET 的场景。一个示例写法如下:

function jsonp(url, callbackName) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = `${url}?callback=${callbackName}`; window[callbackName] = (data) => { resolve(data); delete window[callbackName]; script.remove(); }; script.onerror = reject; document.body.appendChild(script); }); }

开发环境代理:Vite 和 webpack-dev-server 都支持 proxy,原理是让本地开发服务器转发请求到后端,浏览器只跟同源的开发服务器通信,从而规避跨域。Vite 配置大概是:

// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } }

changeOrigin很关键,它会把请求头里的 Host 改成目标域名,有些后端按 Host 做校验,不设置就会出错。

生产环境一般用 nginx 把/api反向代理到后端,原理一样。所以“Windows 上装了 nginx 部署网页还需要跨域吗”这个问题,答案取决于你怎么部署:如果前端和后端在同一个 nginx 下通过不同 location 转发,浏览器看的是同一个源,就不用额外处理 CORS;如果仍然是两个独立服务,那就还是要配。

其他偏门方案还有postMessage+iframedocument.domain+ iframe(仅限同主域不同子域)、window.name等,各有历史背景,但现在的实际项目用得很少,了解即可。

3. 从轮询到 SSE:为什么服务端推送会选中 SSE

3.1 轮询的问题与 SSE 的定位

早期做“伪实时”功能,最土的办法是前端用setInterval每 1 秒请求一次接口。这种方式实现简单,但问题很扎心:大部分请求返回的数据根本没变化,白白浪费带宽;实时性也取决于轮询间隔,间隔太短服务器撑不住,太长用户又觉得不够实时。

后来又有人搞长轮询(Long Polling),让服务端挂起请求,有数据再返回,请求结束立刻再发一个。实时性提升了,但服务端为了挂起连接需要维护大量请求状态,复杂度高,连接频繁重建成本也不低。而且 HTTP 协议本身是“请求-响应”模式,这些方案本质都是在半双工的信道上硬模拟服务端主动推送,怎么做都有点别扭。

SSE(Server-Sent Events)就是在这个背景下被设计出来的:它基于普通 HTTP,服务端把响应流保持打开,持续向下推送文本数据。客户端一个EventSource对象就能接收,不需要额外引入第三方库,天然支持自动重连和事件 ID 续传。代价是它是单向的,客户端到服务端仍然要走普通 HTTP 请求。

3.2 SSE 协议格式与 EventSource 用法

SSE 的 MIME 类型是text/event-stream,服务端返回的内容按行解析,基本格式是字段: 值。最常见的字段:

  • data:数据内容,多行 data 会被拼接成一个完整消息。
  • event:事件类型,默认是message,也可以用自定义事件名。
  • id:事件 ID,客户端重连时会通过Last-Event-ID带上。
  • retry:重连间隔毫秒数。

一个简单的 SSE 响应长这样:

HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive retry: 5000 id: 1 event: message data: {"message": "hello"}

前端使用非常轻量:

const es = new EventSource('/api/events?token=xxx'); es.onopen = () => console.log('SSE 已连接'); es.onmessage = (event) => { const data = JSON.parse(event.data); // 处理推送数据 }; es.addEventListener('order-paid', (event) => { // 处理自定义事件 order-paid }); es.onerror = (err) => { // 注意:这里可能因为连接正常结束或网络异常触发 };

要注意的是,EventSourceonerror不代表一定失败,服务端主动关闭连接时也会触发,然后浏览器会按retry设置的间隔自动重连。所以不要在 onerror 里写太过重度的逻辑,尤其不要手动创建新的 EventSource 导致连接重复建立。

3.3 SSE 的鉴权、超时与代理问题

SSE 最容易被嫌弃的一点,就是浏览器原生EventSource不支持自定义请求头,所以常用的鉴权手段只剩两种:把 token 放在 URL 查询参数里,或者依赖 Cookie。Cookie 方式要求接口和前端同域或用 CORS 带着凭证,token 放 URL 虽然简单,但会被 nginx 等访问日志记录下来,有泄露风险。实战中更稳妥的做法是:前端先请求一个短时有效的临时票据,再用 EventSource 带上票据连接;或者服务端把 SSE 接口放在需要 Cookie 会话的路由下。

部署时最经典的报错就是热搜里的那句:stream disconnected before completion: idle timeout waiting for sse。这通常不是应用代码问题,而是中间代理层把“长时间没有数据”误判成了空闲连接,直接掐断。比如 nginx 默认的proxy_read_timeout是 60 秒,如果你的 SSE 流里有 60 秒以上的静默期,连接就会被挂断。解决方法是调大超时并关闭缓冲:

location /events/ { proxy_pass http://backend-server:3000; proxy_set_header Connection ''; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }

proxy_buffering off很重要,如果开着缓冲,nginx 会把服务端的数据攒一批再发给浏览器,SSE 的实时性就没了,甚至会因为缓冲导致连接表现异常。

另外还有一个被忽略的点:SSE 长连接会占住浏览器的连接池。HTTP/1.1 下同一个域名最多同时开 6 个左右的 TCP 连接,如果页面里同时打开多个 SSE 连接,再加上其他请求,很容易出现资源排队。HTTP/2 有了多路复用会好很多,但部署时仍建议为 SSE 配置独立域名或路径。

3.4 SSE 与 WebSocket 怎么选

选型时我一般直接拉一个对比表:

维度SSEWebSocket
方向服务端单向推送到客户端全双工双向
协议HTTP(text/event-stream)独立的 ws/wss 协议,握手走 HTTP Upgrade
数据格式文本为主,原生不支持二进制文本和二进制都支持
自动重连浏览器内置,带 Last-Event-ID 续传没有内置,需要自己实现
鉴权复杂度受限于不能自定义 Header可以在握手阶段做多种鉴权
服务端实现普通 HTTP 接口即可需要专门处理 Upgrade 和帧解析
适用场景通知、行情、日志流、AI 流式输出聊天、协同编辑、游戏、实时控制台

选型原则可以很粗暴:只需要服务端单向推,SSE 就够用;需要客户端也实时发数据,才考虑 WebSocket。AI 会话那种流式输出用 SSE 尤其合适,很多大模型 SDK 底层就是 SSE。

4. WebSocket 握手、鉴权与连接稳定性:从 1006 到自动重连

4.1 从 HTTP Upgrade 到 101 Switching Protocols

WebSocket 不是凭空出现的协议,它借用了 HTTP 的握手机制。客户端发起一个带升级头的 HTTP 请求:

GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://your-frontend.com

服务端验证通过后,返回:

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Accept的值不是随便生成的,它由Sec-WebSocket-Key加上固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接后做 SHA-1,再 Base64 编码得到。这个设计主要是为了证明服务端确实理解 WebSocket 协议,而不是某个中间层误转发。前端开发不需要自己实现这段,但理解了握手原理,遇到网关类问题(比如防火墙或代理没放行 Upgrade 请求)就知道从哪排查。

握手完成后,连接进入全双工模式。WebSocket 数据以帧为单位传输,每帧包含 FIN、opcode、payload length、mask 等字段。客户端发给服务端的帧必须做掩码(mask),服务端发给客户端的不能掩码,这是协议强制要求。平时我们用ws.send()根本感知不到这些,但面试官问起“WebSocket 和普通 TCP 长连接有什么区别”,能说出帧协议和掩码规则会是一个明显的加分点。

4.2 常见的 close code 与 1006 的完整排查链路

WebSocket 关闭时带一个 code 和 reason,下面几个高频出现:

close code含义常见触发场景
1000正常关闭调用 close() 且双方完成关闭握手
1001服务端掉线或页面跳转服务端进程退出、浏览器导航离开
1006异常关闭,无关闭帧网络中断、代理掐断、心跳超时、进程崩溃
1008策略违规服务端校验 token 失败后主动关闭
1011服务器内部错误后端抛异常导致连接被强制销毁

其中1006是出现频率最高也最让人头疼的,因为它不会带任何原因文本,看起来就像断网了。我排查过几个线上问题,总结出如下检查顺序:

  1. 先看服务端日志,进程有没有崩溃、有没有抛出未捕获异常、GC 是否长时间停顿。
  2. 看代理层日志,nginx/负载均衡有没有主动断开连接,像proxy_read_timeout配置过小就会在连接空闲时被回收。
  3. 看客户端网络状态,移动端切换 WiFi/4G、锁屏太久、NAT 会话超时,TCP 连接会被中间设备静默丢弃,此时客户端往往要等很久才能感知。
  4. 抓包看有没有 TCP RST 或 FIN,RST 基本说明中间有设备干预了。

如果服务端和代理都正常,那 1006 大概率来自网络链路问题,所以客户端不能依赖操作系统立刻感知断开,必须靠心跳兜底。

4.3 WebSocket 鉴权:三种主流姿势

WebSocket 的鉴权本质上发生在握手阶段。因为握手就是一次 HTTP 请求,理论上可以复用普通接口的各种鉴权方式,常见的做法有三种。

URL 参数方式:前端把 token 拼在连接地址上,比如wss://api.example.com/ws?token=xxx。实现简单,但 token 会出现在 nginx 访问日志、浏览器历史、跳转 Referer 里,所以这种 token 最好设置短有效期。服务端在握手处理器里直接从 query 参数取 token 校验。

子协议方式:握手请求头Sec-WebSocket-Protocol: access_token, chat,服务端校验通过后,在响应头里原样返回其中一个子协议,表示接受。这种方式 token 在 Header 里,比 URL 参数安全一些,也不是每个后端框架都支持得顺手。如果服务端没有对子协议做处理,浏览器会直接报错,要注意。

首次消息鉴权:先建立连接,客户端第一条消息发{ "type": "auth", "token": "xxx" },服务端校验,失败则返回错误并关闭连接。好处是握手不会被鉴权逻辑拖慢,也方便做 token 过期后的重连;坏处是连接已经建立了,未鉴权连接会占用服务端资源,存在被刷的风险。所以生产环境我一般建议网关或握手阶段先做一个基础校验,首次消息再做业务级校验,两层配合。

不同后端框架在实现上差别不小。Spring 的HandshakeInterceptor可以在建立连接前拿到ServerHttpRequest,直接取 token 校验;Netty 的HttpRequestHandler里也可以解析请求头,校验失败就返回 403,不走后续 pipeline;Gin 的话,因为websocket.Upgrade可以从*gin.Context里拿到*http.Request,写个中间件校验 Cookie 或 Header 都行。基本思路都是“握手前拦截”,能不用首条消息就别用,资源干净。

4.4 心跳与断线重连:前端该做的兜底

为什么 WebSocket 一定要做心跳?因为 TCP 长连接在空闲时会被 NAT 网关、运营商设备悄悄回收,客户端看着连接还在,实际上数据已经发不出去了。心跳的作用是定期发送小数据包,维持链路活跃,同时让双方确认对端还活着。

常见的心跳机制有两种层次:

  • WebSocket 协议层 Ping/Pong 帧:客户端发 Ping,服务端回 Pong,这是协议内置的控制帧,浏览器 WebSocket API 不开放发送 Ping 的接口,所以前端想用只能借助库,或者在后端定时主动 Ping 前端。
  • 应用层心跳:前端定时发{ "type": "ping" },服务端回{ "type": "pong" },后端也能判断连接活性。这是最通用、最容易双端联调的做法。

我通常会做一个心跳管理器:连接打开后每 30 秒发一次 ping;如果在 10 秒内没收到 pong,就主动关闭连接触发重连;收到服务端主动关闭或网络错误,也进入重连流程。重连策略用指数退避加随机抖动,避免大量客户端同时重连打爆服务端。

5. 前端工程化实践:Vue 中的 WebSocket 封装与消息可靠性

5.1 为什么不能每次 new WebSocket 就完事

业务里有十几个页面都需要实时数据,如果每个组件都直接new WebSocket(),会出现几个问题:连接实例分散在多处,没法统一管理;组件销毁时忘记 close 会泄漏连接;断线重连逻辑写多份,改一个地方漏一个地方;收到消息后不知道分发给哪个页面。

所以前端项目里 WebSocket 一定要封装成单例服务,再配合事件总线把消息分发到各组件。简单说就是:连接只建一条,所有人都往这条连接上注册自己的处理器。

5.2 一个 Vue 3 可用的封装示例

下面是我在 Vue 3 项目里常用的一种封装思路,用类管理连接,用回调集合分发消息。

// websocket.js class WSClient { constructor(options = {}) { this.url = options.url || ''; this.heartbeatInterval = options.heartbeatInterval || 30000; this.reconnectBaseDelay = options.reconnectBaseDelay || 1000; this.maxReconnectDelay = options.maxReconnectDelay || 30000; this.listeners = new Map(); this.status = 'closed'; // closed | connecting | open this.reconnectAttempts = 0; this.connect(); } connect() { this.status = 'connecting'; this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.status = 'open'; this.reconnectAttempts = 0; this.startHeartbeat(); }; this.ws.onmessage = (event) => { let msg; try { msg = JSON.parse(event.data); } catch (e) { msg = { type: 'raw', data: event.data }; } this.dispatch(msg.type, msg); }; this.ws.onclose = (evt) => { this.stopHeartbeat(); this.status = 'closed'; // 如果 1006 或非正常关闭,进入重连 if (evt.code !== 1000) { this.scheduleReconnect(); } }; this.ws.onerror = (err) => { // onerror 后通常紧跟着 onclose,这里只记录日志 }; } dispatch(type, payload) { const handlers = this.listeners.get(type) || []; handlers.forEach((fn) => fn(payload)); } on(type, handler) { if (!this.listeners.has(type)) { this.listeners.set(type, []); } this.listeners.get(type).push(handler); return () => this.off(type, handler); } off(type, handler) { const handlers = this.listeners.get(type) || []; const idx = handlers.indexOf(handler); if (idx > -1) handlers.splice(idx, 1); } send(type, payload) { this.ws.send(JSON.stringify({ type, payload, requestId: Date.now() })); } startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.send('ping', {}); }, this.heartbeatInterval); } stopHeartbeat() { clearInterval(this.heartbeatTimer); clearTimeout(this.reconnectTimer); } scheduleReconnect() { const delay = Math.min( this.reconnectBaseDelay * 2 ** this.reconnectAttempts + Math.random() * 300, this.maxReconnectDelay ); this.reconnectAttempts += 1; this.reconnectTimer = setTimeout(() => this.connect(), delay); } } export default new WSClient({ url: 'wss://api.example.com/ws' });

组件里使用就很干净:

import ws from '@/utils/websocket'; const off = ws.on('order-paid', (msg) => { // 更新订单状态 }); onUnmounted(() => off());

整体逻辑包括:统一连接、事件订阅、心跳、指数退避重连。注意onmessage里我只做 JSON 解析和事件分发,不在那里写业务逻辑,这样模块职责清晰,测试也方便。

5.3 消息可靠性与幂等:WebSocket 没有自动 ACK

WebSocket 是一个可靠传输协议,但这里的“可靠”只保证数据不乱序、不丢在 TCP 层。一旦连接断开,断线期间的消息如何补偿,协议本身不管。所以业务层面要做可靠性设计:

  • 客户端收到消息后主动回一个确认帧,比如{ "type": "ack", "requestId": "xxx" }
  • 服务端一段时间没收到 ack,就走重推或标记消息未读;
  • 客户端重连成功后,带上自己最后一条消息的游标或时间戳,向服务端拉取断线期间的增量消息;
  • 处理命令类消息时,前端用requestId去重,避免重复执行,因为重推可能产生重复消息。

这套“至少一次投递 + 幂等消费”的模型,是消息类系统的基础。跟后端同事对协议时,一定要把 ack、requestId、游标这几个字段留好,否则后面功能做深了很难补。

另外,如果你在 WebSocket 里传大文件,我会劝你慎重。WebSocket 虽然支持二进制分片,但把大文件切成大量帧传输会占用连接带宽,影响其他实时消息,而且浏览器限制、断点续传、进度都很难做。像“前端用 worker 上传大文件”这种场景,用 HTTP 分段上传才是更合理的方向,WebSocket 做好实时通知就够了。

5.4 H5 能用、打包成 App 连不上是怎么回事

这个问题的出现频率非常高,几乎每次移动端适配都能碰到。现象是:浏览器里ws://192.168.x.x:8080/ws能连,打包成 App 后连接失败。常见原因有这些:

  • 明文流量限制:Android 9 开始默认禁止明文 HTTP/WS 流量,如果你的 App 使用的 WebView 或原生网络库没有开启usesCleartextTrafficws://会被直接拦截,换成wss://就正常。
  • 证书问题wss://的证书如果是自签名或被系统不信任,App 内会拒绝连接,需要配置信任证书或使用正规证书。
  • 地址问题:打包后localhost指向手机自己,不是开发机,必须改成局域网 IP 或线上域名,而且手机和服务器要在同一网络。
  • 权限问题:部分平台需要网络权限,或 iOS 的本地网络权限未打开。
  • 服务器绑定地址:本地 WebSocket 服务只绑定了127.0.0.1,设备当然连不上,要确认监听0.0.0.0

排查时先看 App 控制台报错类型:是 DNS 失败、TLS 失败,还是连接超时。然后用手机浏览器直接访问同一个地址,逐步缩小范围。最容易忽略的还是wswss混用问题,页面是 HTTPS,WebSocket 就必须是wss,否则很多浏览器会直接阻止混合内容。

6. 面试八股与实战经验:这些细节才是加分项

6.1 面试官问跨域,想听到什么

前端面试里“跨域”几乎是必考题。初级答案通常是背出几种方案:CORS、JSONP、代理、postMessage。但面试官真正想听的是你有没有理解同源策略的边界,以及不同方案的取舍。

我建议按这个逻辑组织答案:先说明同源策略是浏览器安全模型,目的是防止恶意网站读取另一网站的数据;然后说跨域请求其实发到了服务端,浏览器只是拦截了响应;接着讲 CORS 是标准方案,要区分简单请求和预检请求,重点说预检的触发条件和响应头;再补充 JSONP 只能 GET、代理适合开发环境、nginx 反向代理适合生产环境;最后如果有条件,提一句Access-Control-Allow-Credentials和通配符不能共存这个细节。这样既完整又有亮点。

6.2 面试官问 WebSocket、SSE,加分回答长这样

面试中还经常把 WebSocket 和 SSE 放在一起问。“用 WebSocket 还是 SSE”这个问题,加分的回答不是背定义,而是给选型判断:

WebSocket 能做到全双工,但协议更重、服务端实现更复杂、断线重连要自己写;SSE 基于 HTTP,服务端可以用普通接口实现,浏览器还内置了自动重连和 Last-Event-ID 续传,缺点是单向、文本为主。如果只是服务端推送通知、日志流、AI 流式输出,SSE 就够用;聊天、协同、实时控制台这种需要双方频繁互动的场景,才需要 WebSocket。

这段回答最大的价值是表明你有自己的判断,而不是看见实时通信就无脑 WebSocket。我还见过加分回答提到 SSE 的超时问题:“SSE 在代理层容易踩 idle timeout,要调大proxy_read_timeout并关缓冲。”这属于没有实际部署过就很难讲出来的细节。

6.3 几个高频坑位的小结

  • CORS 预检失败不一定是后端没配,也可能是中间层把 OPTIONS 请求拦截了。
  • Access-Control-Allow-Origin: *和 credentials 永远不能一起用。
  • nginx 里所有 add_header 会受if作用域影响,多写几个注意别被覆盖。
  • SSE 不适合需要双向通信的场景,也不要滥用 EventSource 的 onerror 做重连,浏览器已经有重连机制。
  • WebSocket 关不掉连接,先查 1006 的链路而不是盲目改服务端代码。
  • 移动端 WebSocket 失败,第一个怀疑点永远是wswss混用以及明文流量限制。

我自己处理跨域和 WebSocket 问题时的习惯是:先把浏览器 Network 面板的请求记录截图保存,再去看服务端日志,最后才动手改代码。很多问题表面是技术问题,实际是通信链路里的代理配置、服务器监听地址、证书这些不起眼的小细节。把这些记下来,比背一百个 API 都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询