从跨域到 WebSocket:一篇讲透浏览器的通信边界

文章目录

    • 一、跨域解决方案全景
      • [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 是单向传输协议:

  1. 用户发起请求
  2. 服务器反馈(响应)
  3. 断开连接

注意"伺服"(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 Continue101 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');
});

关键步骤解析:

  1. 先建 HTTP server:对应 3.2 节的第一步,WebSocket 握手借道 HTTP
  2. new WebSocket.Server({ server, path: '/ws' }) :把 WebSocket 服务挂到 HTTP server 上,指定只有 /ws 路径触发协议升级
  3. connection 事件 :每次有客户端握手成功就触发一次,参数 ws 是这个客户端的专属连接对象
  4. 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>

关键步骤解析:

  1. new WebSocket('ws://localhost:8080/ws'):发起连接(异步),地址的 path 要和服务端 path: '/ws' 对上
  2. send 必须在 onopen 回调里 :脚本执行到 new WebSocket() 时连接还在握手中(CONNECTING 状态),此时调用 send() 会抛错。onopen 就是"连接就绪"的信号
  3. 四个事件回调把连接全生命周期覆盖,任何问题都能在控制台看到痕迹

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 协议不需要遵守同源策略,可以跨域通信。 原因有二:

  1. 握手虽然是 HTTP,但浏览器不把 WebSocket 握手当作普通资源请求做 CORS 检查 ------它不是 XMLHttpRequest/fetch
  2. 握手成功后跑的是独立的 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。


全文总结

本文从"跨域"出发,走完了一条通信模型的演进线:

  1. 跨域的根源是浏览器的同源策略(协议+域名+端口三者全同才算同源),且跨域只是浏览器行为
  2. 跨域方案全景 :nginx 反向代理和 vite 代理靠"转发"制造同源假象;jsonp 钻 <script> 的空子;CORS 让服务器声明放行;postMessage 解决窗口间通信;WebSocket 换协议天然跨域
  3. HTTP 是单向 的请求-响应协议,服务器无法主动推送;SSE 打开了单向推送 的口子;WebSocket 实现双工,双方平等收发
  4. WebSocket 连接分两步 :先 HTTP 握手找到 Web Server,再以 101 Switching Protocols 切换协议,之后基于事件机制双向通信
  5. WebSocket 能跨域是因为握手不受 CORS 检查、切换后是独立协议,但它只该用于实时场景
  6. 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 连接时 sendInvalidStateError

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 凭什么跨域、实时通信该怎么选型"这条链路就完整了。下次面对"跨域报错"或"实时需求"时,先判断通信方向,再选协议,思路会清晰很多。

相关推荐
2601_966799041 小时前
酷嗨米J300:硬件级多通道分发采集设备,为矩阵直播打造独立信号通道
服务器·网络·负载均衡
2601_967212721 小时前
新一代电源轨道系统技术甄别维度与行业技术路线分析
大数据·网络·人工智能
艾芯微科技1 小时前
SKY13370‑374LF|0.5‑6GHz SPDT 射频开关,WLAN/LTE 射频前端实用器件
网络·单片机·嵌入式硬件·集成测试·51单片机
今夕资源网1 小时前
本地SSL证书生成工具本地 HTTPS 配置不用再敲命令:CertTool 一站式管理自签证书与 Hosts
网络协议·https·ssl·ssl证书生成·ssl本地测试证书
CC城子2 小时前
lwIP 2.2.1源码结构与移植
网络·lwip
Titan20242 小时前
网络基础:传输层协议知识梳理
linux·服务器·网络
2401_868534782 小时前
网规备考_5.4 防火墙及访问控制技术
linux·网络协议
TechWayfarer2 小时前
做IP归属地查询时总是查不准?IP纯净度检测实战:从“查归属地“到识别“脏IP“的四个维度
服务器·网络·python·tcp/ip·安全·web安全
ocean21033 小时前
2025-2026年计算机网络大厂面试高频问题示例
websocket·计算机网络·秋招·tcp·后端面试·大厂面经·面试真题