文章目录
-
- 一、跨域解决方案全景
-
- [1.1 跨域从哪来:同源策略](#1.1 跨域从哪来:同源策略)
- [1.2 主流方案一览](#1.2 主流方案一览)
- [二、HTTP 之外的协议:从"一问一答"到"全双工"](#二、HTTP 之外的协议:从"一问一答"到"全双工")
-
- [2.1 传统 HTTP:单向的"请求-响应"](#2.1 传统 HTTP:单向的"请求-响应")
- [2.2 SSE:服务器可以"不断说",但只能单向](#2.2 SSE:服务器可以"不断说",但只能单向)
- [2.3 WebSocket:双工通信,双方平等](#2.3 WebSocket:双工通信,双方平等)
- [三、WebSocket 协议原理](#三、WebSocket 协议原理)
-
- [3.1 ws 库:WebSocket 协议的实现](#3.1 ws 库:WebSocket 协议的实现)
- [3.2 连接建立:`ws://localhost:8080/ws` 分两步](#3.2 连接建立:
ws://localhost:8080/ws分两步) - [3.3 顺一遍 HTTP 状态码家族](#3.3 顺一遍 HTTP 状态码家族)
- [3.4 基于事件机制的双向通信](#3.4 基于事件机制的双向通信)
- [四、实战:手写一个可跨域的 WebSocket Demo](#四、实战:手写一个可跨域的 WebSocket Demo)
-
- [4.1 服务端 server.js](#4.1 服务端 server.js)
- [4.2 客户端 index.html](#4.2 客户端 index.html)
- [4.3 运行与验证](#4.3 运行与验证)
- [五、WebSocket 为什么可以跨域](#五、WebSocket 为什么可以跨域)
-
- [5.1 HTTP 的跨域限制](#5.1 HTTP 的跨域限制)
- [5.2 WebSocket 不受同源策略约束](#5.2 WebSocket 不受同源策略约束)
- [5.3 但别拿 WebSocket 当跨域工具](#5.3 但别拿 WebSocket 当跨域工具)
- [六、延伸思考:WebSocket 双工,为何不用于 LLM 的流式输出?](#六、延伸思考:WebSocket 双工,为何不用于 LLM 的流式输出?)
- 全文总结
- 核心知识点复盘
- 常见问题/避坑指南
做前端绕不开"跨域",但很多人只背了几个 CORS 响应头,并没有理解跨域背后的通信模型:HTTP 是"一问一答"的单向协议,SSE 是"服务器单向推送",WebSocket 则是"双方平等的双工通信"。本文沿着这条演进线,从跨域方案全景讲到 WebSocket 协议原理与实战,最后回答一个有意思的问题:LLM 流式输出为什么不用 WebSocket。
一、跨域解决方案全景
1.1 跨域从哪来:同源策略
浏览器的同源策略(Same-Origin Policy) 规定:只有协议、域名、端口三者完全相同的两个 URL,才算"同源",才允许自由通信。
| URL A | URL B | 是否同源 | 原因 |
|---|---|---|---|
http://a.com/api |
http://a.com/user |
✅ | 协议、域名、端口都相同 |
http://a.com |
https://a.com |
❌ | 协议不同(http vs https) |
http://a.com |
http://a.com:3001 |
❌ | 端口不同 |
http://a.com |
http://b.com |
❌ | 域名不同 |
这个限制是浏览器加的,目的是安全:如果没有它,任意网站的脚本都能带着你的 Cookie 去请求银行网站、读取返回数据,隐私和资金都会出问题。
⚠️ 关键认知:跨域只发生在浏览器。服务器之间互相调接口没有"跨域"一说,这为后面的解决方案埋下伏笔。
1.2 主流方案一览
| 方案 | 一句话原理 | 适用场景 |
|---|---|---|
| nginx 反向代理 | 让请求"看起来"同源 | 生产环境,最常用 |
| vite 代理 / mockjs | 开发服务器转发请求 | 本地开发 |
| jsonp | 借 <script> 标签绕过限制 |
老接口,只支持 GET |
| CORS | 服务器声明"允许谁访问" | 标准方案,最主流 |
| postMessage | 窗口间的跨文档通信 | iframe / 弹窗 |
| WebSocket | 独立协议,不受同源策略限制 | 实时通信 |
下面逐个简要说清原理,重点的 WebSocket 放到后文展开。
① nginx 反向代理
思路:前端项目 index.html 由 nginx 托管,浏览器发出的请求都发给 nginx(同源),nginx 再把 /api 转发到 :3001 的真实后端。浏览器全程只跟 nginx 说话,根本感知不到跨域。
nginx
server {
listen 80;
# 前端静态资源
location / {
root /usr/share/nginx/html;
}
# /api 开头的请求转发给 3001 端口的 Node 服务
location /api/ {
proxy_pass http://localhost:3001/;
}
}
② vite + mockjs(dev 环境)
开发期用 vite 的 server.proxy 做同样的事,原理和 nginx 一致------由开发服务器转发,绕开浏览器限制:
javascript
// vite.config.js
export default {
server: {
proxy: {
'/api': 'http://localhost:3001'
}
}
}
③ jsonp(json with padding)
<script> 标签加载 JS 不受同源策略限制。前端事先定义好全局函数,让接口返回一段"调用这个函数、把数据当参数"的 JS 代码,数据就"垫"进来了:
javascript
// 前端
window.handleData = (data) => console.log(data);
const script = document.createElement('script');
script.src = 'http://api.b.com/user?callback=handleData';
document.body.appendChild(script);
// 服务端返回:handleData({"name":"爱因斯坦"})
缺点明显:只支持 GET、错误处理弱、有安全风险,如今基本只在大厂老接口里还能见到。
④ CORS(Cross-Origin Resource Sharing,跨域资源共享)
标准答案:跨域请求照发,服务器在响应头里声明"我允许这个来源访问",浏览器看到声明就放行:
javascript
// Node 服务端只需一行
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
⑤ postMessage
window.postMessage 解决的是窗口之间 的跨域通信(iframe 嵌套、window.open 弹窗),和接口请求的跨域是两回事:
javascript
// 父页面发给 iframe
iframe.contentWindow.postMessage('hello', 'https://child.com');
// iframe 内接收
window.onmessage = (e) => console.log(e.data);
⑥ WebSocket
前五种都是在 HTTP 的框框里"绕",而 WebSocket 直接换了协议------它天然不受同源策略约束,是本文的主角。
二、HTTP 之外的协议:从"一问一答"到"全双工"
要理解 WebSocket 的价值,得先看清 HTTP 的通信模型,以及它的一次次"补丁"。
2.1 传统 HTTP:单向的"请求-响应"
HTTP 是单向传输协议:
- 用户发起请求
- 服务器反馈(响应)
- 断开连接
注意"伺服"(serve)这个词的本义------服务器就像餐厅服务员,你不下单,它绝不会主动上菜。在纯 HTTP 模型里,服务器无法主动向浏览器推送任何数据。想要新消息?只能浏览器定时轮询,又慢又浪费。
2.2 SSE:服务器可以"不断说",但只能单向
SSE(Server Sent Events) 打破了"响应完就没事了"的模式:服务器可以不断向浏览器推送数据------也就是流式输出。
它的实现基于一条 HTTP 长连接,响应头固定三件套:
http
Content-Type: text/event-stream; // 声明这是事件流
Cache-Control: no-cache; // 禁止缓存,数据必须实时到达
Connection: keep-alive; // 保持连接,别发完就断
但 SSE 依然是单向的:只能服务器 → 浏览器推,浏览器不能借这条连接回传数据。适合股票行情、AI 文字流式输出这类"我发你看"的场景。
2.3 WebSocket:双工通信,双方平等
QQ、微信这类应用底层用的是 Socket 协议 ,它是双工 (全双工)通信:不再是 HTTP 那种只有浏览器端发数据,服务器也可以主动发,双方平等。
这带来质变:
- 在线状态:服务器随时知道你还在不在线,能主动喊你
- 实时场景:聊天、直播、多人游戏
- 落到 Web 端,这套协议就是 WebSocket
抖音的实时弹幕、腾讯的网页版 IM、哔哩哔哩的直播间、AI 应用的实时语音对话------背后都是 WebSocket:两边都可以发送数据,平等对话。
三种模型对比:
| 维度 | HTTP | SSE | WebSocket |
|---|---|---|---|
| 通信方向 | 单向(请求→响应) | 单向(服务器→浏览器) | 双向 |
| 连接 | 一次请求一次连接 | 长连接 | 长连接 |
| 服务器能否主动发 | ❌ | ✅ | ✅ |
| 浏览器能否中途发 | 只能发起新请求 | ❌ | ✅ |
| 典型场景 | 普通页面 | AI 流式输出、行情 | 聊天、弹幕、直播 |
三、WebSocket 协议原理
3.1 ws 库:WebSocket 协议的实现
Node.js 里用得最多的是 ws 库,它是 WebSocket 协议的实现,服务端和客户端都能写。WebSocket 协议本身是 HTML5 新增的标准,ws 库让我们在 Node 里直接落地。
3.2 连接建立:ws://localhost:8080/ws 分两步
客户端连接地址长这样:
ws://localhost:8080/ws
ws:// 对应 HTTP 的 http://(加密版是 wss:// 对应 https://)。这次连接的建立分两步:

第一步:HTTP 握手。 浏览器先发起一个普通 HTTP 请求连接服务器(Web Server 找到),带上特殊请求头 Upgrade: websocket,意思是"我想升级协议"。这一步只需要一次。
第二步:101 切换协议。 服务器同意后返回 101 状态码(Switching Protocols),从此这条 TCP 连接上跑的不再是 HTTP,而是 Socket 协议。基于 HTTP Web Server 的 socket 服务就此建立,双向通信开始。
⚠️ 也就是说:WebSocket 的"出生证"是 HTTP 给办的,但出生之后就自立门户了------这也是它能跨域的根源(第五节详述)。
3.3 顺一遍 HTTP 状态码家族
101 这个状态码很多人陌生,正好借机把状态码体系过一遍:
| 分类 | 含义 | 常见例子 |
|---|---|---|
| 1XX | 还在通信中,没有完成 | 100 Continue、101 Switching Protocols |
| 2XX | 成功 | 200 OK |
| 3XX | 跳转/重定向 | 301 永久跳转、302 临时跳转、304 缓存有效 |
| 4XX | 客户端错误 | 404 资源不存在、403 没权限 |
| 5XX | 服务器错误 | 500 内部错误、502 网关错误 |
101 属于 1XX 非常合理:握手还在"通信过程中"------服务器在说"请求我收到了,接下来咱俩换个协议继续聊",这不是最终的业务响应,而是一个过程状态。
3.4 基于事件机制的双向通信
协议切换完成后,双向通信完全基于事件机制驱动,双方对称:
- 客户端事件:
onopen(连上)、onmessage(收到消息)、onerror(出错)、onclose(关闭) - 服务端事件:
connection(有人连上)、message(收到消息)、close - 双方都可以随时调用
send()发送数据------这就是"双工"在代码层的体现
四、实战:手写一个可跨域的 WebSocket Demo
光说不练假把式。我们用 ws 库写一个完整 Demo:客户端发消息,服务端收到后原样回复,跑通"双向通信"的最小闭环。
4.1 服务端 server.js
javascript
// commonjs 老的模块规范, esm 新的
const WebSocket = require('ws');
const http = require('http'); // node 内置的 http 模块
// 第一步:先要把 http server 启动
// WebSocket 握手要借道 HTTP,所以先有 Web Server
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('WebSocket Server Running!');
});
// 第二步:基于 http server 再搭建 socket 协议
// path: '/ws' 表示只有 /ws 路径的请求才会升级为 WebSocket
const wss = new WebSocket.Server({ server, path: '/ws' });
// 监听事件:有人连接
wss.on('connection', (ws) => {
console.log('Client connected');
// 监听该客户端发来的消息 ------ 双工:服务器也能收
ws.on('message', (message) => {
console.log(`Received message: ${message}`);
// 主动给这个客户端回消息 ------ 双工:服务器也能发
ws.send(`Server received: ${message}`);
});
});
// 监听 http server 端口
server.listen(8080, () => {
console.log('listening on http://localhost:8080');
});
关键步骤解析:
- 先建 HTTP server:对应 3.2 节的第一步,WebSocket 握手借道 HTTP
new WebSocket.Server({ server, path: '/ws' }):把 WebSocket 服务挂到 HTTP server 上,指定只有/ws路径触发协议升级connection事件 :每次有客户端握手成功就触发一次,参数ws是这个客户端的专属连接对象ws.on('message'):收到该客户端的消息;ws.send()回消息------服务端主动发数据,这在纯 HTTP 里做不到
4.2 客户端 index.html
html
<!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>
// html5 支持
// 页面是 https:// 时用 wss://;http:// 页面用 ws://
const ws = new WebSocket('ws://localhost:8080/ws');
ws.onopen = () => {
console.log('Connected to WebSocket server');
// ⚠️ send 必须放在 onopen 里!
// new WebSocket() 之后连接是异步建立的,
// 握手没完成就 send 会报 InvalidStateError
ws.send('Hello from client!');
};
// 收到服务端消息
ws.onmessage = (event) => {
console.log('Received message:', event.data);
};
// 出错
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// 连接关闭
ws.onclose = () => {
console.log('Disconnected from WebSocket server');
};
</script>
</body>
</html>
关键步骤解析:
new WebSocket('ws://localhost:8080/ws'):发起连接(异步),地址的 path 要和服务端path: '/ws'对上send必须在onopen回调里 :脚本执行到new WebSocket()时连接还在握手中(CONNECTING 状态),此时调用send()会抛错。onopen就是"连接就绪"的信号- 四个事件回调把连接全生命周期覆盖,任何问题都能在控制台看到痕迹
4.3 运行与验证
bash
cd demo
pnpm install # 安装 ws
node server.js # 看到 listening on http://localhost:8080
用任意方式打开 index.html(甚至直接双击以 file:// 打开也行,原因见下一节),控制台依次输出:
Connected to WebSocket server
Received message: Server received: Hello from client!
服务端终端输出:
Client connected
Received message: Hello from client!
一次完整的双向通信:客户端主动发 → 服务端主动回,双方平等,这正是双工。
五、WebSocket 为什么可以跨域
5.1 HTTP 的跨域限制
回顾 1.1 节:HTTP(S) 跨域是指不同域名、不同端口、不同协议 之间发请求,浏览器因为安全问题(同源策略)会拦截跨域请求------准确说是放行请求、拦截响应。
5.2 WebSocket 不受同源策略约束
WebSocket 协议不需要遵守同源策略,可以跨域通信。 原因有二:
- 握手虽然是 HTTP,但浏览器不把 WebSocket 握手当作普通资源请求做 CORS 检查 ------它不是
XMLHttpRequest/fetch - 握手成功后跑的是独立的 Socket 协议,压根不在 HTTP 的管辖范围内
所以上面 Demo 里,不管页面部署在哪个域、哪个端口,ws://localhost:8080/ws 都能直接连上,不需要任何响应头配置。
5.3 但别拿 WebSocket 当跨域工具
注意这句话的分寸:WebSocket 可以跨域,但还是用于实时交流,不去做常规的跨域解决。原因:
- 杀鸡用牛刀:为了调个 HTTP 接口维护一条长连接,成本远高于 CORS 或反向代理
- 生态不同:HTTP 的鉴权、缓存、重试等基建都用不上
- 安全责任转移:没有浏览器帮你把关,服务端必须自己校验握手请求的
Origin头,拒绝陌生来源,否则会有"跨站 WebSocket 劫持"风险(恶意网站让用户浏览器连你的 ws 服务)
一句话:WebSocket 能跨域是它的属性,不是它的用途。
六、延伸思考:WebSocket 双工,为何不用于 LLM 的流式输出?
这是个很自然的疑问:LLM 一边生成一边输出,WebSocket 双向也能传,为什么 ChatGPT 们几乎都用 SSE?四个理由:
| 维度 | SSE | WebSocket |
|---|---|---|
| 语义匹配 | "一次请求、一次分块到达的响应",正是 LLM 的模式 | 全双工能力浪费了------对话期间客户端并没有持续向服务器发数据 |
| 基础设施 | 就是 HTTP,CDN、网关、负载均衡、鉴权全部天然兼容 | 长连接需要心跳保活、粘性会话、专门的网络配置 |
| 断线恢复 | EventSource 自动重连,配合 Last-Event-ID 续传 |
重连、补消息全部要自己写 |
| 资源成本 | 对话结束连接即断,服务器无状态包袱 | 每个用户占一条长连接,服务端要维护大量连接状态 |
LLM 流式输出的本质是服务器单向推送,SSE 恰好量身定做;WebSocket 的"双向"能力在这里完全用不上,反而带来运维复杂度。
什么时候 LLM 场景会真正用上 WebSocket?双向音频:实时语音对话里,你边说边传音频流上去,AI 边生成边传下来,两个方向同时进行------这才轮到双工协议登场。
选型口诀:单向推送用 SSE,双向互动上 WebSocket。
全文总结
本文从"跨域"出发,走完了一条通信模型的演进线:
- 跨域的根源是浏览器的同源策略(协议+域名+端口三者全同才算同源),且跨域只是浏览器行为
- 跨域方案全景 :nginx 反向代理和 vite 代理靠"转发"制造同源假象;jsonp 钻
<script>的空子;CORS 让服务器声明放行;postMessage 解决窗口间通信;WebSocket 换协议天然跨域 - HTTP 是单向 的请求-响应协议,服务器无法主动推送;SSE 打开了单向推送 的口子;WebSocket 实现双工,双方平等收发
- WebSocket 连接分两步 :先 HTTP 握手找到 Web Server,再以
101 Switching Protocols切换协议,之后基于事件机制双向通信 - WebSocket 能跨域是因为握手不受 CORS 检查、切换后是独立协议,但它只该用于实时场景
- LLM 流式输出选 SSE 而非 WebSocket:单向需求用单向协议,语义、成本、运维全面占优
核心知识点复盘
| 知识点 | 关键结论 |
|---|---|
| 同源定义 | 协议 + 域名 + 端口三者完全相同 |
| 跨域本质 | 浏览器行为,服务器之间无跨域 |
| nginx 反代 | 浏览器请求同源 nginx,nginx 转发真实后端 |
| jsonp | 借 <script> 不受同源限制,只支持 GET |
| CORS | 服务器响应头声明放行,标准方案 |
| HTTP 通信模型 | 单向:请求→响应→断开,服务器不能主动发 |
| SSE 三件套 | text/event-stream + no-cache + keep-alive |
| WebSocket 握手 | 两步:HTTP 找到服务器 → 101 切换协议 |
| 101 状态码 | 属于 1XX"通信中",表示切换协议的过程状态 |
| 状态码分类 | 1XX 通信中 / 2XX 成功 / 3XX 跳转 / 4XX 客户端错 / 5XX 服务端错 |
| WS 事件机制 | 客户端 onopen/onmessage/onerror/onclose,服务端 connection/message |
| WS 跨域 | 不受同源策略约束,但服务端需自行校验 Origin |
| send 时机 | 必须在 onopen 之后,否则 CONNECTING 状态报错 |
| LLM 流式选型 | 单向推送选 SSE,双向互动才上 WebSocket |
常见问题/避坑指南
Q1:为什么接口在 Postman 里正常,浏览器里就跨域失败?
A:跨域是浏览器的限制,Postman 没有同源策略。解决方案选 CORS 或反向代理,别在客户端上找补。
Q2:jsonp 为什么死了?
A:只支持 GET、无法处理错误、<script> 加载任意返回内容有 XSS 风险,CORS 普及后没有了存在价值。
Q3:WebSocket 连接时 send 报 InvalidStateError?
A:连接还在 CONNECTING 状态就调用了 send。把发送逻辑放进 onopen 回调。
Q4:new WebSocket('ws://localhost:8080/ws') 连不上?
A:按顺序排查:① 服务端 node server.js 起了没;② 端口对不对;③ path 是否和服务端 path: '/ws' 一致;④ 控制台有没有红色语法错误------脚本里任何一行 JS 语法错误都会让整段脚本不执行,连接根本不会发起。
Q5:页面必须部署到服务器上才能测 WebSocket 吗?
A:不需要。因为 WebSocket 不受同源策略约束,file:// 打开的页面也能直接连 ws://。但建议养成用 http://localhost 打开页面的习惯,避免和 fetch 的行为混淆。
Q6:101 状态码是错误吗?
A:不是。1XX 表示"还在通信中,没有完成",101 的意思是"协议切换中",是 WebSocket 握手成功的标志,在 Network 面板看到 101 应该高兴。
Q7:WebSocket 服务怎么做安全防护?
A:至少做两件事:握手阶段校验请求头 Origin 是否在白名单;对消息内容做校验和频率限制。没有浏览器同源策略的保护,服务端要自己把关。
Q8:到底什么时候用 SSE、什么时候用 WebSocket?
A:记住一句话:单向推送用 SSE(AI 输出、行情、通知),双向互动上 WebSocket(聊天、弹幕、协同编辑、实时语音)。
到这里,"跨域为什么存在、有哪些解法、WebSocket 凭什么跨域、实时通信该怎么选型"这条链路就完整了。下次面对"跨域报错"或"实时需求"时,先判断通信方向,再选协议,思路会清晰很多。