从跨域到 WebSocket:前端跨域方案、SSE 与双向实时通信详解

在前端开发中,"跨域"几乎是绕不开的话题。

刚开始学习跨域时,我们通常会接触 CORS、Nginx 反向代理、JSONP 等方案;但随着业务逐渐涉及实时聊天、消息推送、大模型流式输出,又会接触 SSE 和 WebSocket。

这些技术看起来彼此独立,实际上它们都围绕着两个核心问题:

  1. 浏览器为什么会限制某些网络请求?
  2. 浏览器和服务器之间应该采用什么方式通信?

本文从浏览器的同源策略开始,一步步理解跨域、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 之间关系的关键。

相关推荐
原则猫1 小时前
TS 类型工具
前端
excel2 小时前
Nuxt + Twin CSS 中宽度超过屏幕时底部出现空白的原因与解决方案
前端·javascript
行百里er2 小时前
Gateway 上别硬塞 MVC:DeferredImportSelector 看菜下锅
spring boot·后端·开源
名字还没想好☜3 小时前
Java 遍历时删元素抛 ConcurrentModificationException:fail-fast 原理与三种正确删法
后端
计算机魔术师3 小时前
美国司法部正式站队 OpenAI,AI 训练的版权账要这么算了
前端
IT_陈寒3 小时前
React Hooks闭包陷阱差点让我加班到凌晨
前端·人工智能·后端
风骏时光牛马3 小时前
疑难Bug根因定位与问题复盘分析
前端
码事漫谈4 小时前
本体论,彻底懂
后端
创新技术阁4 小时前
FastapiAdmin 系统日志体系与核心配置参数详解
前端·后端·fastapi