在前端开发中,"跨域"几乎是绕不开的话题。
刚开始学习跨域时,我们通常会接触 CORS、Nginx 反向代理、JSONP 等方案;但随着业务逐渐涉及实时聊天、消息推送、大模型流式输出,又会接触 SSE 和 WebSocket。
这些技术看起来彼此独立,实际上它们都围绕着两个核心问题:
- 浏览器为什么会限制某些网络请求?
- 浏览器和服务器之间应该采用什么方式通信?
本文从浏览器的同源策略开始,一步步理解跨域、SSE 和 WebSocket,并通过一个 Node.js WebSocket Demo 理解 WebSocket 的工作过程。
一、什么是跨域?
浏览器中存在一个非常重要的安全机制:
Same-Origin Policy,同源策略。
浏览器会限制网页访问不同"源"的资源。
那么什么叫"同源"?
一个 URL 可以大致拆成:
ruby
协议://域名:端口/路径
例如:
arduino
http://localhost:5173
其中包含:
协议:http
域名:localhost
端口:5173
只有下面三个部分完全一致时,浏览器才认为两个地址是同源的:
- 协议 Protocol
- 域名 Host
- 端口 Port
例如:
arduino
http://localhost:5173
http://localhost:3000
虽然域名相同,但端口不同,因此属于跨域。
再比如:
arduino
http://example.com
https://example.com
协议不同,同样属于跨域。
以及:
arduino
https://www.example.com
https://api.example.com
Host 不同,也属于跨域。
因此可以简单理解为:
协议、域名、端口,只要有一个不同,就属于不同源。
二、为什么浏览器要限制跨域?
跨域本身并不是网络层面"请求发不出去"。
很多时候,HTTP 请求其实已经发送到服务器,服务器也成功返回了数据。
真正进行限制的是:
浏览器。
浏览器为了安全,通过同源策略限制网页读取其他源的数据。
假设没有这个限制:
你登录了某银行网站,浏览器中保存着登录 Cookie。
此时再访问一个恶意网站,如果恶意页面可以任意访问银行接口并读取返回的数据,那么攻击者就可能窃取用户的重要信息。
所以,同源策略本质上是一层非常重要的浏览器安全边界。
但现代 Web 应用又经常需要:
arduino
前端:
http://localhost:5173
后端:
http://localhost:3000
这种架构天然就是跨域的。
于是我们需要一些专门的跨域解决方案。
三、常见跨域解决方案
常见方案主要包括:
javascript
Nginx 反向代理
Vite 开发代理
CORS
JSONP
postMessage
除此之外,WebSocket 虽然不是普通 HTTP 跨域解决方案,但它也经常涉及跨 Origin 通信。
下面分别来看。
四、Nginx 反向代理
Nginx 是实际项目中非常常见的一种解决方案。
假设前端页面部署在:
arduino
https://www.example.com
后端服务实际上运行在:
arduino
http://localhost:3001
如果前端直接请求:
rust
fetch('http://localhost:3001/users');
显然会产生跨域问题。
这时候可以让前端统一请求:
scss
fetch('/api/users');
然后通过 Nginx:
bash
location /api/ {
proxy_pass http://localhost:3001/;
}
浏览器看到的是:
arduino
https://www.example.com/api/users
页面也是:
arduino
https://www.example.com
对于浏览器来说,这是一个同源请求。
真正访问后端服务器的是 Nginx:
浏览器
↓
Nginx
↓
Node / Java / Go 后端
因此:
Nginx 代理并不是"让浏览器允许跨域",而是通过服务器转发,让浏览器看到的请求仍然是同源的。
五、Vite Proxy:开发环境中的反向代理
在开发 Vue、React 等前端项目时,我们通常不会专门启动一个 Nginx。
这时候可以直接使用 Vite 的代理功能。
例如:
arduino
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
}
前端依然请求:
scss
fetch('/api/users');
请求流程为:
arduino
Browser
↓
Vite Dev Server
↓
http://localhost:3001
原理和 Nginx 非常类似。
区别主要是:
javascript
Vite Proxy
↓
开发环境使用
Nginx Proxy
↓
生产环境使用
六、CORS:最常见的跨域解决方案
CORS 全称:
sql
Cross-Origin Resource Sharing
即:
跨源资源共享。
它是目前浏览器处理 HTTP 跨域请求最标准的方案。
CORS 的关键并不在前端,而在:
服务器响应头。
例如服务器返回:
arduino
Access-Control-Allow-Origin: http://localhost:5173
意思就是:
我允许
http://localhost:5173这个网页读取我的响应。
如果允许所有 Origin:
makefile
Access-Control-Allow-Origin: *
浏览器收到响应后,会检查这些 Header。
如果服务器允许当前 Origin,那么浏览器就允许 JavaScript 获取响应内容。
七、CORS 的本质
很多初学者容易认为:
CORS 是让跨域请求可以发送。
实际上并不完全准确。
更准确地说:
CORS 是服务器告诉浏览器:"这个 Origin 有权限读取我的响应。"
流程大致是:
vbscript
Browser
│
│ HTTP Request
↓
Server
│
│ HTTP Response
│ Access-Control-Allow-Origin
↓
Browser
│
├── 允许 → JavaScript 获得响应
│
└── 不允许 → 浏览器阻止 JavaScript 读取
因此跨域限制的执行者依然是浏览器。
八、JSONP
JSONP 全称:
javascript
JSON with Padding
它是一种比较古老的跨域方案。
它利用了:
xml
<script>
标签可以加载其他域资源的特点。
例如:
xml
<script src="https://api.example.com/user?callback=handleUser"></script>
服务器不是返回普通 JSON:
json
{
"name": "Tom"
}
而是返回:
css
handleUser({
name: 'Tom'
});
页面提前定义:
javascript
function handleUser(data) {
console.log(data);
}
这样就可以获取数据。
但是 JSONP 有明显限制:
基本只能使用 GET 请求。
随着 CORS 普及,现代项目中已经很少使用 JSONP。
九、postMessage
还有一种特殊场景:
两个不同源的页面需要通信。
例如:
css
父页面
↓
iframe
父页面和 iframe 不同源时,不能随意读取对方 DOM。
此时可以使用:
javascript
window.postMessage()
发送:
php
iframe.contentWindow.postMessage(
{
type: 'login',
token: 'xxx'
},
'https://example.com'
);
接收:
javascript
window.addEventListener('message', (event) => {
console.log(event.data);
});
因此 postMessage 更适合:
css
iframe 通信
跨窗口通信
父子页面通信
而不是普通 API 请求。
十、普通 HTTP 通信有什么特点?
传统 HTTP 最典型的模式是:
arduino
Client → Server
Server → Client
用户首先发起请求,然后服务器返回响应。
例如:
scss
fetch('/api/users')
服务器返回:
json
{
"name": "Tom"
}
一次请求对应一次响应。
请求结束后,本次通信基本就完成了。
这套模式有几个非常重要的优点:
简单
无状态
容易缓存
容易做负载均衡
容易进行分布式部署
所以绝大多数 Web API 都采用 HTTP。
但是它有一个明显问题:
如果服务器产生了新的数据,怎么主动告诉浏览器?
例如:
聊天室有新消息
股票价格变化
用户在线状态改变
直播产生新弹幕
AI 不断生成新的 Token
于是就出现了各种实时通信方案。
十一、SSE:服务器单向流式推送
SSE 全称:
arduino
Server-Sent Events
即:
服务器发送事件。
SSE 基于 HTTP,但它不会像普通 HTTP 请求一样立即结束连接。
服务器会保持连接:
kotlin
Browser
│
│ HTTP Request
↓
Server
│
├── data 1
├── data 2
├── data 3
├── data 4
└── ...
服务器可以不断向客户端发送数据。
十二、SSE 的核心响应头
典型 SSE 响应通常会包含:
yaml
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
其中最关键的是:
vbnet
Content-Type: text/event-stream
表示服务器返回的是一个持续产生事件的数据流。
服务端可以不断写入:
kotlin
data: hello
data: world
data: !!!
浏览器持续接收。
十三、为什么大模型喜欢使用 SSE?
现在调用 ChatGPT、Claude 或其他 LLM 时,经常可以看到这样的效果:
你
↓
发送 Prompt
AI
↓
我
↓
是一
↓
个
↓
AI
↓
助手......
内容不是等整个答案生成完成后一次返回,而是:
一边生成,一边返回。
这就是流式输出。
从通信需求来看,它通常是:
客户端:发送一次 Prompt
服务器:持续返回 Token
也就是说:
arduino
Client → Server
↓
Server → Client
Server → Client
Server → Client
Server → Client
请求阶段主要由客户端发一次消息。
后续主要是服务器持续输出。
因此这实际上是:
单向流式通信。
SSE 非常适合这种场景。
十四、WebSocket 是什么?
如果业务需要真正的:
客户端和服务器都随时可以主动发送消息
就需要考虑 WebSocket。
WebSocket 是 HTML5 引入的重要 Web 通信能力。
普通 HTTP 更像:
arduino
Client
↓
Server
↓
Client
而 WebSocket 建立连接之后:
arduino
Client ⇄ Server
双方地位更加平等。
客户端可以随时:
scss
ws.send(...)
服务器也可以随时:
scss
ws.send(...)
因此 WebSocket 被称为:
全双工通信。
十五、WebSocket 的典型应用
WebSocket 特别适合真正的实时互动场景,例如:
QQ / 微信聊天
聊天室
在线游戏
协同编辑
实时通知
直播互动
实时弹幕
股票行情
设备状态
用户在线状态
例如聊天室中:
arduino
Alice → Server
Bob → Server
Server → Alice
Server → Bob
任何一个用户发送消息后,服务器可以立即主动把消息推送给其他客户端。
十六、WebSocket URL
HTTP 使用:
arduino
http://
https://
WebSocket 则使用:
arduino
ws://
wss://
例如:
bash
ws://localhost:8080/ws
生产环境通常使用加密版本:
arduino
wss://example.com/ws
它们可以大致对应理解为:
http → ws
https → wss
十七、WebSocket 是如何建立连接的?
一个非常容易产生误解的地方是:
WebSocket 并不是从第一步开始就完全脱离 HTTP。
WebSocket 建立连接时,会先进行一次 HTTP Upgrade 握手。
浏览器首先访问:
bash
ws://localhost:8080/ws
建立过程大致如下:
vbscript
Browser
│
│ HTTP Upgrade Request
↓
Server
│
│ 101 Switching Protocols
↓
WebSocket Connection
服务器返回:
101 Switching Protocols
表示:
同意把 HTTP 连接升级为 WebSocket 协议。
随后双方开始进行 WebSocket 数据通信。
十八、HTTP 状态码简单回顾
这里顺便回顾一下 HTTP 状态码分类:
1xx 信息性响应
2xx 请求成功
3xx 重定向
4xx 客户端错误
5xx 服务端错误
例如:
101 Switching Protocols
属于 1xx。
它并不是普通意义上的业务请求完成,而是在告诉客户端:
协议正在切换。
常见状态码还有:
vbscript
200 OK
301 Moved Permanently
302 Found
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
500 Internal Server Error
十九、使用 Node.js 实现一个 WebSocket Server
下面使用 ws 库实现一个简单 WebSocket 服务。
安装:
csharp
pnpm add ws
服务端代码:
javascript
const WebSocket = require('ws');
const http = require('http');
// 创建普通 HTTP Server
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain'
});
res.end('WebSocket Server Running!');
});
// 在 HTTP Server 基础上创建 WebSocket Server
const wss = new WebSocket.Server({
server,
path: '/ws'
});
// WebSocket 连接事件
wss.on('connection', (ws) => {
console.log('Client connected');
// 接收客户端消息
ws.on('message', (message) => {
console.log(`Received message: ${message}`);
// 给客户端发送消息
ws.send(`Server received: ${message}`);
});
});
server.listen(8080, () => {
console.log('Listening on http://localhost:8080');
});
这里非常关键的一点是:
ini
const server = http.createServer(...)
首先创建 HTTP Server。
然后:
php
const wss = new WebSocket.Server({
server,
path: '/ws'
});
把 WebSocket 服务挂载到这个 HTTP Server 上。
也就是说:
arduino
HTTP Server
│
└── /ws
↓
WebSocket
二十、浏览器连接 WebSocket
浏览器原生提供:
WebSocket
API,因此不需要额外安装库。
HTML:
xml
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>WebSocket 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.log('Error:', error);
};
// 连接关闭
ws.onclose = () => {
console.log(
'Disconnected from server'
);
};
</script>
</body>
</html>
客户端主要监听四类事件:
lua
open
message
error
close
这也是 WebSocket 非常典型的:
事件驱动模型。
二十一、整个 Demo 的通信过程
运行:
vbscript
node server.js
服务器监听:
arduino
http://localhost:8080
页面执行:
arduino
const ws = new WebSocket(
'ws://localhost:8080/ws'
);
首先完成:
HTTP Upgrade
服务器返回:
101 Switching Protocols
随后 WebSocket 建立成功。
触发:
ws.onopen
客户端发送:
csharp
Hello from client!
服务器:
csharp
ws.on('message', ...)
收到消息。
然后服务器:
scss
ws.send(...)
返回:
arduino
Server received: Hello from client!
客户端通过:
ws.onmessage
收到数据。
最终整个通信过程:
arduino
Client Server
│ │
│──── HTTP Upgrade ──────────→│
│ │
│←── 101 Switching Protocols ─│
│ │
│====== WebSocket 建立 =======│
│ │
│──── Hello from client ─────→│
│ │
│←──── Server received... ────│
│ │
│──── message ────────────────→│
│←── message ─────────────────│
│ │
建立之后,双方就可以不断发送消息。
二十二、WebSocket 能不能跨域?
WebSocket 和传统 fetch/XMLHttpRequest 的 CORS 机制并不完全相同。
浏览器建立 WebSocket 时可以连接不同 Origin 的 WebSocket Server,例如:
arduino
页面:
https://frontend.example.com
连接:
arduino
wss://socket.example.com
是完全可能的。
WebSocket 握手请求中通常会带上:
Origin
服务器可以检查 Origin,决定是否接受连接。
因此不能简单理解成:
"WebSocket 完全不受任何跨域安全限制。"
更准确的说法是:
WebSocket 不使用普通 HTTP CORS 那套 Access-Control-Allow-Origin 检查机制,但服务端仍然应该校验 Origin。
否则很容易产生安全问题。
因此即使 WebSocket 能够实现跨 Origin 通信,也不应该为了绕过 CORS,把普通 HTTP API 全部改成 WebSocket。
选择通信协议的重点应该是:
业务通信模型。
而不是:
能不能绕过跨域。
二十三、既然 WebSocket 是双向的,为什么 LLM 流式输出经常不用它?
这是一个非常值得思考的问题。
既然 WebSocket:
arduino
Client ⇄ Server
既可以上传,又可以下载。
那么显然也可以实现:
用户发送 Prompt
↓
服务器持续发送 Token
技术上当然没有问题。
但很多 LLM API 仍然更喜欢:
HTTP + SSE
为什么?
原因一:LLM 文本生成通常只需要单向流
一次普通聊天请求实际上是:
arduino
Client
↓
发送 Prompt
Server
↓
Token
↓
Token
↓
Token
↓
Token
生成过程中,客户端通常不需要不断给服务器发送新数据。
因此:
SSE
已经足够。
使用 WebSocket 反而显得更复杂。
原因二:SSE 基于 HTTP
SSE 本身建立在 HTTP 之上。
因此可以直接利用成熟的 Web 基础设施:
Nginx
CDN
HTTP 鉴权
Cookie
JWT
负载均衡
日志系统
API Gateway
对于传统 Web 后端来说更加自然。
原因三:接口模型更简单
LLM API 很自然地可以设计成:
bash
POST /chat
请求:
json
{
"message": "什么是闭包?",
"stream": true
}
响应:
kotlin
data: 闭
data: 包
data: 是
data: ...
仍然保持:
一次请求
一次响应
只是这个响应是逐渐返回的。
因此整个 API 设计依然符合 HTTP 的思维方式。
二十四、什么时候 LLM 会使用 WebSocket?
如果是更加实时的 AI 场景,WebSocket 就很有价值。
例如:
实时语音 AI
实时音视频
AI 陪聊
持续语音识别
实时字幕
多模态实时交互
比如语音聊天:
css
User
│
│ audio chunk
↓
AI
│
│ audio chunk
↓
User
双方都需要持续发送数据:
sql
User → AI → User → AI
这时:
WebSocket
就比普通 SSE 更合适。
所以可以这样理解:
markdown
LLM 文本流
↓
通常 SSE
实时语音 AI
↓
通常更适合 WebSocket
二十五、HTTP、SSE、WebSocket 如何选择?
最后可以用一个表格总结三种通信方式:
| 特性 | HTTP | SSE | WebSocket |
|---|---|---|---|
| 通信方式 | 请求-响应 | 服务器 → 客户端 | 双向 |
| 是否长连接 | 通常否 | 是 | 是 |
| 实时性 | 一般 | 高 | 高 |
| 双工通信 | 否 | 否 | 是 |
| 基于 HTTP | 是 | 是 | 握手阶段是 |
| 实现复杂度 | 低 | 较低 | 较高 |
| 普通 API | ✅ | ❌ | 通常不需要 |
| AI 流式输出 | ❌ | ✅ | ✅ |
| 消息通知 | 一般 | ✅ | ✅ |
| 聊天室 | 不推荐 | 不推荐 | ✅ |
| 在线游戏 | 不推荐 | 不推荐 | ✅ |
| 实时语音 | 不推荐 | 不推荐 | ✅ |
最简单的判断方法就是:
普通 CRUD
↓
HTTP
服务器持续推送
↓
SSE
客户端和服务器持续互相发送
↓
WebSocket
二十六、总结
理解跨域和实时通信时,可以把整个知识体系串成一条线。
首先,因为浏览器存在:
同源策略
协议、域名、端口不同,就可能产生跨域。
对于普通 HTTP API,可以使用:
javascript
CORS
Nginx Proxy
Vite Proxy
解决。
特殊页面通信可以使用:
postMessage
JSONP 则属于历史较久的跨域方案,现在已经较少使用。
随着业务需要服务器持续返回数据,又出现了:
SSE
它的特点是:
arduino
Server → Client
非常适合:
LLM 流式输出
消息通知
状态更新
如果客户端和服务端都需要随时主动发送数据,则可以使用:
WebSocket
其核心特点是:
arduino
Client ⇄ Server
因此非常适合:
聊天室
实时弹幕
在线游戏
协同编辑
实时语音
最终需要记住的并不是哪种技术"更高级",而是:
根据通信模型选择合适的协议。
如果只是为了普通接口跨域,不要因为 WebSocket 可以跨 Origin 就把 HTTP API 改成 WebSocket。
如果只需要服务器向客户端进行流式输出,也没有必要强行使用双向 WebSocket。
技术选择的核心始终应该是:
需求
↓
通信模型
↓
协议
而不是反过来。
这也是理解 HTTP、SSE 和 WebSocket 之间关系的关键。