前端发请求,浏览器控制台一片红;后端说「我接口没问题啊」。别急,这大概率不是代码写错了,而是你撞上了浏览器最忠实的保安------同源策略。
这篇文章不打算把《HTTP 权威指南》抄一遍,而是把跨域这件事从「怎么绕过去」讲到「协议层到底发生了什么」。读完你能拿走三样东西:
- 六种跨域方案的适用场景与踩坑点
- SSE 和 WebSocket 的本质区别,以及为什么大模型流式输出选 SSE 不选 WS
- 一段逐行拆解的 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。整个建连过程分两步:
- 先发一个普通的 HTTP 请求:
http://localhost:8080 - 服务器返回 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。
wss 是 WebSocketServer 的实例,你可以把它理解成一个「连接池管理器」,负责监听所有客户端连接。
④ 监听连接事件
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会是Blob或ArrayBuffer。
④ 错误与关闭
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(),或者网络断开、心跳超时,都会进入关闭流程:
- 发一个 Close Frame (WebSocket 协议里的帧类型),带上关闭码(如
1000正常关闭)。 - 对方回一个 Close Frame 确认。
- TCP 四次挥手,连接彻底断开。
- 客户端
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 挥手
最后送你三句可以带走的结论:
- 跨域不是要「消灭」的敌人,而是要「理解」的规则------选对方案,比硬刚浏览器聪明得多。
- 技术选型不是比谁更强,而是比谁更合适。WebSocket 能力更强,但 SSE 在 LLM 场景下更「刚刚好」。
- 理解 WebSocket 的关键不在
ws.send,而在 101 那一次协议切换------HTTP 负责把门打开,之后跑的就是另一套规矩了。