跨域、SSE 与 WebSocket:从一次「请求被拦截」说起

前端发请求,浏览器控制台一片红;后端说「我接口没问题啊」。别急,这大概率不是代码写错了,而是你撞上了浏览器最忠实的保安------同源策略

这篇文章不打算把《HTTP 权威指南》抄一遍,而是把跨域这件事从「怎么绕过去」讲到「协议层到底发生了什么」。读完你能拿走三样东西:

  1. 六种跨域方案的适用场景与踩坑点
  2. SSE 和 WebSocket 的本质区别,以及为什么大模型流式输出选 SSE 不选 WS
  3. 一段逐行拆解的 WebSocket 双工通信代码,以及从建连到断开的完整生命周期

一、跨域到底在跨什么?

先明确一个概念:跨域是浏览器的行为,不是服务器的行为。

浏览器出于安全考虑,实施「同源策略」(Same-Origin Policy)。所谓同源,必须同时满足三个条件:

维度 要求
协议 相同(http / https)
域名 相同(www.a.com / www.b.com 不同)
端口 相同(:3000 / :8080 不同)

只要有一个不同,就是跨域。注意关键词:浏览器拦截的是「响应结果的读取」,而不是「请求的发送」。很多请求其实已经打到服务器了,只是浏览器不把结果交给你的 JS。

一句话总结:跨域不是 Bug,是浏览器在保护你的 Cookie 不被隔壁老王家的页面偷走。


二、六种跨域方案,按场景对号入座

1. CORS ------ 最正统的官方解法

CORS(Cross-Origin Resource Sharing,跨域资源共享)是 W3C 标准,核心是服务端通过响应头告诉浏览器:这个来源我允许

yaml 复制代码
Access-Control-Allow-Origin: https://your-frontend.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, Authorization

两个容易踩的坑:

  • 带 Cookie 时Access-Control-Allow-Origin 不能写 *,必须写具体域名,且前端要开 withCredentials: true
  • 预检请求(OPTIONS) :非简单请求(比如 Content-Type: application/json)会先发一个 OPTIONS 探路,服务端必须正确响应,否则正式请求根本发不出去。

适合:前后端分离、接口方愿意配合改响应头的场景。

2. Nginx 反向代理 ------ 生产环境最常用

原理很简单:让浏览器以为自己在请求同源地址

ini 复制代码
server {
    listen 80;
    server_name your-frontend.com;

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }

    location /api/ {
        proxy_pass http://backend:3001/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

前端 index.html 和 Nginx 同源,请求 /api/xxx 时 Nginx 内部转发到 :3001。浏览器全程不知道背后换了台机器,自然没有跨域问题。

这是「前端项目 + Nginx + 后端服务」架构下的标准答案,也是你笔记里最先写下的那条路。

3. Vite + Mock ------ 开发阶段的偷懒神器

开发时后端还没好?Vite 的 server.proxy 和 mockjs 能让你原地起飞:

javascript 复制代码
// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3001',
        changeOrigin: true,
        rewrite: path => path.replace(/^/api/, '')
      }
    }
  }
}

配合 mockjs 拦截请求返回假数据,前端不用等后端就能把页面跑通。开发用代理 + Mock,生产用 Nginx,这是最省心的组合。

4. JSONP ------ 历史产物,知道就行

JSONP(JSON with Padding)利用 <script> 标签不受同源策略限制的特性,通过回调函数拿数据。

xml 复制代码
<script>
  function handleData(data) { console.log(data); }
</script>
<script src="http://api.other.com/data?callback=handleData"></script>

只能发 GET,安全性差,现在基本被 CORS 取代。面试会问,项目别用。

5. postMessage ------ 窗口之间的对话

postMessage 解决的是不同窗口/iframe 之间的跨域通信,不是 AJAX 跨域:

javascript 复制代码
// 父窗口
iframe.contentWindow.postMessage('hello', 'https://child.com');

// 子窗口
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://parent.com') return; // 必须校验来源
  console.log(e.data);
});

常见于:微前端、嵌套 iframe、OAuth 弹窗回调。

6. WebSocket ------ 协议层的「免检通道」

这是本文的重点,也是你笔记里最值得深挖的部分。先记住结论:

WebSocket 协议不需要遵守同源策略,可以跨域通信。

为什么?因为同源策略是 HTTP 协议体系下的浏览器安全机制,而 WebSocket 在握手之后已经切换到了另一个协议 ,浏览器不再用那套规则约束它。服务端需要通过 Origin 头自行校验来源,这是安全责任从浏览器转移到了开发者身上。


三、HTTP 的单向性:为什么需要 SSE 和 WebSocket?

理解跨域之后,我们往协议层再走一步。

HTTP 本质上是一个「请求-响应」模型 :用户发起请求 → 服务器返回 → 连接断开。服务器是「伺服状态」,被动等着,无法主动向浏览器推送数据

这在很多场景下是不够的:

  • 聊天消息要实时到达
  • 股票行情要持续刷新
  • 大模型要一边生成一边吐字

于是有了两种补丁方案。

SSE:服务器单向流式输出

SSE(Server-Sent Events)建立在 HTTP 之上,响应头很关键:

yaml 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

服务器保持连接不断开,持续往浏览器推送文本流。前端用 EventSource 接收:

ini 复制代码
const es = new EventSource('/api/stream');
es.onmessage = (e) => console.log(e.data);

特点:单向,服务器 → 浏览器。 浏览器要发数据?对不起,得另开一个请求。

WebSocket:真正的双工通信

WebSocket 是独立于 HTTP 的协议,但握手阶段借用了 HTTP。整个建连过程分两步:

  1. 先发一个普通的 HTTP 请求:http://localhost:8080
  2. 服务器返回 101 Switching Protocols,协议切换为 socket,之后就是双向平等通信

顺便复习一下状态码:

范围 含义
1xx 通信中,还没完成
2xx 成功
3xx 跳转
4xx 客户端错误
5xx 服务端错误

101 就属于 1xx,表示「协议正在切换」。

QQ、微信、抖音弹幕、直播、在线状态------这些「双方都能随时发消息」的场景,都是 WebSocket 的地盘。它是真正意义上的双工通信,两边平等。


四、手撸一个 WebSocket 服务(逐行拆解版)

下面这段代码看着短,但每一行都有讲究。我们先看完整代码,再逐段解剖,最后串成完整流程

4.1 服务端完整代码

javascript 复制代码
// server.js ------ CommonJS 写法
const WebSocket = require('ws');
const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('WebSocket Server Running');
});

const wss = new WebSocket.Server({ server, path: '/ws' });

wss.on('connection', (ws) => {
  console.log('client connected');

  ws.on('message', (message) => {
    console.log(`Received: ${message}`);
    ws.send(`Server received: ${message}`);
  });
});

server.listen(8080, () => {
  console.log('WebSocket Server is running on port 8080');
});

4.2 逐段解析

① 引入模块

ini 复制代码
const WebSocket = require('ws');
const http = require('http');
  • http 是 Node 内置模块,用来起一个最基础的 Web Server。
  • ws 是社区库,负责在 HTTP Server 之上实现 WebSocket 协议(包括握手、帧解析、心跳等)。Node 原生没有 WebSocket 服务端能力,所以必须借助它。

注意这里用的是 CommonJS(require),对应 ES Module(import),前者是 Node 老语法,后者是新的。项目里统一风格就行。

② 创建 HTTP Server

javascript 复制代码
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('WebSocket Server Running');
});

这一步做了两件事:

  • 创建了一个 HTTP 服务器实例,但它还不是 WebSocket
  • 这个回调只处理普通 HTTP 请求 。如果你用浏览器直接访问 http://localhost:8080/,会看到那句 "WebSocket Server Running"。

为什么 WebSocket 需要先有 HTTP Server?因为 WebSocket 的握手阶段就是一次 HTTP 请求。没有 HTTP Server 做地基,Socket 服务没有地方「升级」。

③ 挂载 WebSocket 服务

ini 复制代码
const wss = new WebSocket.Server({ server, path: '/ws' });

这行是灵魂。它的含义是:

  • { server }:复用上面创建的 HTTP Server,不另外开端口
  • path: '/ws':只有当请求路径是 /ws 时才走 WebSocket 升级流程,其他路径仍走普通 HTTP。

wssWebSocketServer 的实例,你可以把它理解成一个「连接池管理器」,负责监听所有客户端连接。

④ 监听连接事件

javascript 复制代码
wss.on('connection', (ws) => {
  console.log('client connected');
  // ...
});

当某个客户端完成握手、成功升级协议后,connection 事件被触发。

  • ws这一个客户端专属的连接对象,不是全局的。
  • 每个新连接都会跑一次这个回调,互相独立。

⑤ 监听消息事件

javascript 复制代码
ws.on('message', (message) => {
  console.log(`Received: ${message}`);
  ws.send(`Server received: ${message}`);
});
  • message 事件:客户端发来消息时触发。
  • 回调参数 message 默认是 Buffer,如果内容是文本,toString() 一下更直观。
  • ws.send(...)服务端主动推消息。这就是双工的关键------服务端不需要等客户端先请求,随时能发。

⑥ 启动监听

javascript 复制代码
server.listen(8080, () => {
  console.log('WebSocket Server is running on port 8080');
});

启动 HTTP Server,监听 8080 端口。此时:普通 HTTP 请求走 createServer 的回调,/ws 路径走 WebSocket 升级流程。一个端口,两种协议,共用一台服务器。


4.3 客户端完整代码

xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebSocket Cross-Origin Demo</title>
</head>
<body>
  <h1>WebSocket Client</h1>
  <script>
    const ws = new WebSocket('ws://localhost:8080/ws');

    ws.onopen = () => {
      console.log('Connected to server');
      ws.send('Hello, from client!');
    };

    ws.onmessage = (event) => {
      console.log(`Received message: ${event.data}`);
    };

    ws.onerror = (error) => {
      console.error('error:', error);
    };

    ws.onclose = () => {
      console.log('Disconnected from server');
    };
  </script>
</body>
</html>

4.4 逐段解析

① 建立连接

ini 复制代码
const ws = new WebSocket('ws://localhost:8080/ws');
  • WebSocket 是 HTML5 原生构造函数,浏览器直接支持,不需要任何库
  • 协议前缀是 ws://(加密版本是 wss://),对应 HTTP 的 http:// / https://
  • 这一行同步返回一个实例 ,但连接是异步建立的,所以后面要用事件监听。

URL 的构成 ws://localhost:8080/ws

  • ws:// → 协议
  • localhost:8080 → 主机与端口
  • /ws → 路径,必须和服务端 path: '/ws' 一致,否则服务端拒绝升级。

② 连接成功

ini 复制代码
ws.onopen = () => {
  console.log('Connected to server');
  ws.send('Hello, from client!');
};
  • onopen 触发时机:握手完成、协议切换成功之后。
  • 只有在这个回调里 send 才安全,之前发会抛错。
  • ws.send() 支持字符串、Blob、ArrayBuffer 等类型。

③ 收到消息

javascript 复制代码
ws.onmessage = (event) => {
  console.log(`Received message: ${event.data}`);
};
  • 服务端每次 ws.send(),客户端 onmessage 就触发一次。
  • 数据在 event.data 里,这里是字符串。如果是二进制,event.data 会是 BlobArrayBuffer

④ 错误与关闭

ini 复制代码
ws.onerror = (error) => console.error('error:', error);
ws.onclose = () => console.log('Disconnected from server');
  • onerror:连接异常时触发(网络问题、握手失败等)。
  • onclose:连接关闭时触发。正常关闭和异常关闭都会走这里。
  • 实际项目中通常在 onclose 里做自动重连(比如 3 秒后重试),这是 WebSocket 相比 SSE 要自己补的一块。

五、完整流程:从建连到断开的全生命周期

把服务端和客户端串起来,一次完整的 WebSocket 会话分这几个阶段:

阶段 1:TCP 三次握手

浏览器先和 localhost:8080 建立 TCP 连接。这是所有网络通信的地基,跟协议无关。

阶段 2:HTTP Upgrade 请求(客户端发起)

浏览器发送一个特殊的 HTTP 请求:

makefile 复制代码
GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://localhost:3000

注意这里:

  • Upgrade: websocket 明确告诉服务器「我想升级协议」。
  • Origin 头是关键------服务端要靠它判断是否允许跨域。WebSocket 不走浏览器同源策略,安全责任在服务端。
  • Sec-WebSocket-Key 是随机字符串,用于握手校验。

阶段 3:服务器响应 101

服务器同意升级,返回:

makefile 复制代码
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • 101 状态码:协议切换成功。从这一刻起,这条 TCP 连接上跑的不再是 HTTP,而是 WebSocket。
  • 客户端 onopen 在这个响应到达后触发。

阶段 4:双向通信

csharp 复制代码
客户端                             服务端
   |                                 |
   |--- ws.send("Hello...") -------->|
   |                                 | connection 回调里的 ws.on('message') 触发
   |                                 | ws.send("Server received...")
   |<---- onmessage 触发 ------------|
   |                                 |

这条连接会一直保持,双方随时可以发消息,没有「请求-响应」的强制配对关系。这就是双工。

阶段 5:连接关闭

任一方调用 ws.close(),或者网络断开、心跳超时,都会进入关闭流程:

  1. 发一个 Close Frame (WebSocket 协议里的帧类型),带上关闭码(如 1000 正常关闭)。
  2. 对方回一个 Close Frame 确认。
  3. TCP 四次挥手,连接彻底断开。
  4. 客户端 onclose 触发,服务端 connection 回调内的 ws 对象失效。

在生产项目里,这一步需要配合心跳机制:客户端每隔 30 秒发一个 ping,服务端回 pong;连续几次没回应就主动重连。否则 Nginx 或防火墙可能悄悄掐掉空闲连接,你还一脸懵。


六、灵魂拷问:WebSocket 双工,为什么大模型流式输出不用它?

这是你笔记里最有价值的一个思考,值得单独拎出来讲。

大模型生成回答时,确实是「一边生成一边输出」,WebSocket 的双工能力看起来完全够用。但主流方案(OpenAI、Claude、国内各大模型)几乎都选了 SSE,原因有几个:

1. 场景本质是单向的。

LLM 流式输出只需要服务器 → 浏览器一个方向。用户发完 prompt 后,剩下的就是等。用 WebSocket 相当于为了喝杯水买了个双向传送门,能力过剩。

2. SSE 基于 HTTP,基础设施零改造。

SSE 就是一个普通的 HTTP 长连接,Nginx、CDN、网关、负载均衡全都认识它。WebSocket 需要额外配置代理升级(Upgrade 头)、心跳保活、连接管理,运维成本高出一截。

3. 自动重连。

EventSource 内置断线重连机制,WebSocket 得自己写。

4. 调试友好。

SSE 的响应就是纯文本流,curl 一下就能看;WebSocket 得专门工具抓包。

5. 成本与并发。

HTTP 连接的生命周期管理、连接池、超时策略都是现成的,SSE 可以直接复用。WebSocket 的长连接对服务端资源占用更敏感。

一句话:WebSocket 是双向对讲机,SSE 是广播喇叭。大模型只需要广播,用喇叭就够了,还便宜。

当然,如果你的场景是「用户边看边打断、边追问,服务端还要主动推通知」,那 WebSocket 才是正解。


七、一张图收尾

把今天的知识点串起来:

javascript 复制代码
跨域问题
├── CORS          → 服务端加响应头,正统解法
├── Nginx 反代    → 生产环境标配,浏览器无感知
├── Vite Proxy    → 开发环境偷懒
├── JSONP         → 历史遗物,仅作了解
├── postMessage   → 窗口/iframe 通信
└── WebSocket     → 协议层免检,可跨域但需自校验 Origin

实时通信
├── HTTP          → 单向,请求-响应,服务器不能主动推
├── SSE           → HTTP 之上,服务器单向流式,LLM 首选
└── WebSocket     → 独立协议,101 切换,双工,聊天/直播首选

WebSocket 生命周期
TCP 握手 → HTTP Upgrade(带 Origin) → 101 切换 → 双向收发 → Close Frame → TCP 挥手

最后送你三句可以带走的结论:

  1. 跨域不是要「消灭」的敌人,而是要「理解」的规则------选对方案,比硬刚浏览器聪明得多。
  2. 技术选型不是比谁更强,而是比谁更合适。WebSocket 能力更强,但 SSE 在 LLM 场景下更「刚刚好」。
  3. 理解 WebSocket 的关键不在 ws.send,而在 101 那一次协议切换------HTTP 负责把门打开,之后跑的就是另一套规矩了。
相关推荐
小此方7 小时前
Linux网络(十一):HTTP重定向与请求方法详解:从301/302状态码到GET/POST,再认识Fiddler抓包
linux·网络·http
喵喵锤锤你小可爱7 小时前
SerialHub:把串口变成 WebSocket 字节管道,浏览器和脚本直接读写(开源工具 SerialHub 实战)
websocket·rust·嵌入式·串口调试·开源工具
只睡四小时1 天前
内网穿透+WebSocket:Node 零依赖远程桌面实战
网络·websocket·网络协议
梦想平凡1 天前
百游棋牌源代码开发搭建教程(七):WebSocket房间同步与断线恢复
网络·websocket·网络协议
Lhappy嘻嘻1 天前
网络(六)|应用层 HTTP&HTTPS:URL、请求响应、Cookie 与证书
网络·http·https
AIFQuant1 天前
Python股票实时价格告警系统:WebSocket订阅与REST快照实战
开发语言·python·websocket·a股行情
小肥君1 天前
前端测试websocket
前端·websocket·状态模式
tachibana21 天前
WebSocket 和 SSE 通信的区别及局限性
网络·人工智能·websocket·网络协议·ai·llm·agent
AIFQuant2 天前
Python实时外汇行情接入实战:WebSocket与REST K线查询
开发语言·python·websocket