ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

跨域与实时通信:CORS、SSE、WebSocket原理与实战

跨域与实时通信:CORS、SSE、WebSocket原理与实战 从跨域到 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:8080和http://localhost:3000端口不同不同源http://a.com和https://a.com协议不同也不同源。很多人有个误解觉得跨域是服务端限制了你的请求所以拼命去改前端配置。实际上请求是发出去了服务端也正常处理了只是浏览器在接收到响应后发现响应头里没有允许当前源访问的声明于是把响应拦下来不交给 JavaScript并且打印出类似No Access-Control-Allow-Origin header is present on the requested resource的报错。这就是为什么你用 curl 直接请求后端接口能拿到数据浏览器里却报跨域——拦截发生在浏览器这一层而不是网络层面。理解了这一点“跨域访问被拒绝请检查浏览器配置”这句话的误导性就很明显了绝大多数跨域问题不需要动浏览器配置需要调整的是服务端响应头、前端代理或者把请求方式改成浏览器允许的形式。1.2 被拦截的与漏网的为什么会有 JSONP同源策略不是把跨域请求一刀切全禁掉它重点拦截的是“跨域读取响应内容”。具体来说fetch、XMLHttpRequest默认受同源策略限制跨域请求没有服务端明确授权响应数据不可读。script、img、link这类带src/href的标签不受同源策略限制原因是早期互联网需要加载外部脚本、图片、样式表。表单提交form的 POST通常允许发送但不允许跨域读取返回的页面内容。Cookie、LocalStorage 按域隔离跨域默认读不到对方的存储。正是“script 标签不受同源限制”这个特性催生了 JSONP。它的思路很简单前端动态创建一个scriptsrc指向一个带callback参数的后端接口后端把数据包在回调函数里返回浏览器当作脚本执行前端预先定义好的全局函数就收到了数据。JSONP 的问题也很明显只能发 GET 请求没有标准错误处理依赖全局函数现在实际项目里很少作为首选方案但很多老系统尤其是 PHP 后端还在用所以面试和存量项目维护时都会遇到。1.3 从报错信息判断是哪一类跨域问题不同场景的报错信息其实能直接告诉你问题出在哪我列几个最常见的报错信息问题阶段常见原因No Access-Control-Allow-Origin header is present响应被拦截服务端没设置 CORS 响应头或没匹配到当前 OriginResponse to preflight request doesnt pass access control check预检请求失败服务端没有正确处理 OPTIONS 请求或没有返回 Allow-Methods/HeadersThe value of the Access-Control-Allow-Origin header ... must not be the wildcard *携带凭证时出错Access-Control-Allow-Credentials: true与Access-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/POSTContent-Type 是application/x-www-form-urlencoded、multipart/form-data或text/plain请求头里没有自定义头。如果条件不满足浏览器会先发一条OPTIONS预检请求询问服务端我要跨域发一个PUT请求带Authorization和application/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如果仍然是两个独立服务那就还是要配。其他偏门方案还有postMessageiframe、document.domain iframe仅限同主域不同子域、window.name等各有历史背景但现在的实际项目用得很少了解即可。3. 从轮询到 SSE为什么服务端推送会选中 SSE3.1 轮询的问题与 SSE 的定位早期做“伪实时”功能最土的办法是前端用setInterval每 1 秒请求一次接口。这种方式实现简单但问题很扎心大部分请求返回的数据根本没变化白白浪费带宽实时性也取决于轮询间隔间隔太短服务器撑不住太长用户又觉得不够实时。后来又有人搞长轮询Long Polling让服务端挂起请求有数据再返回请求结束立刻再发一个。实时性提升了但服务端为了挂起连接需要维护大量请求状态复杂度高连接频繁重建成本也不低。而且 HTTP 协议本身是“请求-响应”模式这些方案本质都是在半双工的信道上硬模拟服务端主动推送怎么做都有点别扭。SSEServer-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?tokenxxx); es.onopen () console.log(SSE 已连接); es.onmessage (event) { const data JSON.parse(event.data); // 处理推送数据 }; es.addEventListener(order-paid, (event) { // 处理自定义事件 order-paid }); es.onerror (err) { // 注意这里可能因为连接正常结束或网络异常触发 };要注意的是EventSource的onerror不代表一定失败服务端主动关闭连接时也会触发然后浏览器会按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方向服务端单向推送到客户端全双工双向协议HTTPtext/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 ProtocolsWebSocket 不是凭空出现的协议它借用了 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: s3pPLMBiTxaQ9kYGzzhZRbKxOoSec-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是出现频率最高也最让人头疼的因为它不会带任何原因文本看起来就像断网了。我排查过几个线上问题总结出如下检查顺序先看服务端日志进程有没有崩溃、有没有抛出未捕获异常、GC 是否长时间停顿。看代理层日志nginx/负载均衡有没有主动断开连接像proxy_read_timeout配置过小就会在连接空闲时被回收。看客户端网络状态移动端切换 WiFi/4G、锁屏太久、NAT 会话超时TCP 连接会被中间设备静默丢弃此时客户端往往要等很久才能感知。抓包看有没有 TCP RST 或 FINRST 基本说明中间有设备干预了。如果服务端和代理都正常那 1006 大概率来自网络链路问题所以客户端不能依赖操作系统立刻感知断开必须靠心跳兜底。4.3 WebSocket 鉴权三种主流姿势WebSocket 的鉴权本质上发生在握手阶段。因为握手就是一次 HTTP 请求理论上可以复用普通接口的各种鉴权方式常见的做法有三种。URL 参数方式前端把 token 拼在连接地址上比如wss://api.example.com/ws?tokenxxx。实现简单但 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不走后续 pipelineGin 的话因为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 没有自动 ACKWebSocket 是一个可靠传输协议但这里的“可靠”只保证数据不乱序、不丢在 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 失败还是连接超时。然后用手机浏览器直接访问同一个地址逐步缩小范围。最容易忽略的还是ws和wss混用问题页面是 HTTPSWebSocket 就必须是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 失败第一个怀疑点永远是ws和wss混用以及明文流量限制。我自己处理跨域和 WebSocket 问题时的习惯是先把浏览器 Network 面板的请求记录截图保存再去看服务端日志最后才动手改代码。很多问题表面是技术问题实际是通信链路里的代理配置、服务器监听地址、证书这些不起眼的小细节。把这些记下来比背一百个 API 都有用。
返回列表