前端必知必会:跨域解决方案全景,以及 WebSocket 为什么天生免跨域

5 阅读9分钟

前端必知必会:跨域解决方案全景,以及 WebSocket 为什么天生免跨域 🚀

面试官问"说说你了解的跨域方案",你只能蹦出"CORS 加几个响应头"?这篇文章带你从同源策略讲到 WebSocket 的 101 握手升级,一次讲透。


一、从一个"被拦截的请求"说起

打开一个前端项目,页面地址是 http://localhost:5173,你想请求后端的 http://localhost:3001/api/user。请求发出去了,浏览器控制台却红了一片:

Access to fetch at 'http://localhost:3001/api/user' from origin
'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

你纳闷:我不是明明在代码里写了 axios.get('http://localhost:3001/api/user') 吗?为什么被浏览器拦了?

关键在于:请求确实发出去了,后端也确实返回了数据,但浏览器不肯把响应交给你。 这道墙的名字,叫同源策略(Same-Origin Policy)


二、什么是同源策略?——浏览器的安全底线

2.1 "源(Origin)"由三部分组成

源 = 协议 + 域名 + 端口
     http  + localhost + 5173

三者完全一致,才叫"同源"。只要有一个不同,就是"跨源(跨域)"。

场景是否跨域原因
http://a.com:80/ahttp://a.com/b❌ 同源协议、域名、端口都相同(默认 80 端口)
http://a.comhttps://a.com✅ 跨域协议不同
http://a.comhttp://b.com✅ 跨域域名不同
http://a.com:3000http://a.com:8080✅ 跨域端口不同
http://a.comhttp://news.a.com✅ 跨域子域名也算不同域名

🎯 一句话:同源策略是浏览器的安全约定——不同源之间,默认不允许互相读取对方的资源(cookie、localStorage、DOM、Ajax 响应等)。

2.2 为什么要有同源策略?

假设没有同源策略:

  1. 你登录了网银 http://bank.com,浏览器存了你的登录凭证(cookie)。
  2. 你浏览另一个网站 http://evil.com
  3. evil.com 里藏了一段 JS,偷偷 fetch('http://bank.com/transfer') 给你转账。

浏览器会自动带上 bank.com 的 cookie——因为请求确实是发往 bank.com 的。这样恶意网站就能冒充你操作网银。

同源策略就是阻止"A 源网站读取 B 源网站的响应",防止跨站窃取数据和身份冒充。这是 Web 安全的第一道防线


三、跨域的 6 种解决方案全景 🗺️

理解了"为什么拦",再看"怎么合法地放行"。这道题面试高频,先把全景图立住:

方案核心思想是否前端改适用场景
CORS后端加响应头,明确允许某个源❌ 后端配标准方案,最主流
JSONP利用 <script> 标签不受同源限制✅ 前端改只支持 GET 的老接口
nginx 反向代理同源转发,跨域交给服务器❌ 运维配生产环境最常用
Vite/mockjs 开发代理开发服务器代理接口✅ 配置即可本地开发
postMessage跨窗口通信 API✅ 前端改iframe/窗口间的数据传递
WebSocket换协议,天然不受同源策略约束✅ 前端改实时双向通信

下面逐个讲清"是什么、怎么用、为什么能解决"。

3.1 CORS(Cross-Origin Resource Sharing,跨域资源共享)

CORS 是标准解法。原理是:后端在响应头上声明"我允许哪些源访问我"

// Node/Express 后端
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');

浏览器发现响应头里有 Access-Control-Allow-Origin,且和当前页面源匹配,就放行。

💡 关键认知:CORS 不是前端的活,是后端的活。 前端代码一行不用改,后端配置响应头即可。前端遇到的"跨域报错",本质是后端没配 CORS。

3.2 JSONP(JSON with Padding)——上个时代的"补丁"

同源策略管得住 fetch/XHR,却管不住 <script> 标签。<script src> 天生可以跨域加载 JS。

JSONP 就钻了这个空子:

<script>
  function handleUser(data) {
    console.log('拿到数据', data);  // 全局回调函数
  }
</script>
<!-- 跨域加载一段 JS,这段 JS 内容是 handleUser({...}) -->
<script src="http://localhost:3001/api/user?callback=handleUser"></script>

后端返回的不是 JSON,而是一段调用全局函数的 JS

handleUser({ name: '吴贤宏', age: 21 });

浏览器当作 JS 执行,handleUser 就被调用了,数据也就拿到了。

⚠️ 缺点明显:只支持 GET(因为 <script> 只能发 GET),且有 XSS 安全风险,现在基本被 CORS 取代。面试知道原理即可。

3.3 nginx 反向代理——生产环境的主流姿势

生产环境最常见做法:让浏览器永远只访问自己的源,跨域交给服务器去转发

浏览器 → http://myapp.com/api/user  (同源,无跨域)
              │
              ▼
nginx(myapp.com)
  把 /api 前缀的请求转发到后端 → http://localhost:3001/api/user

nginx 配置大致长这样:

location /api {
    proxy_pass http://localhost:3001;  # 转发到后端
    proxy_set_header Host $host;
}

浏览器只看到了 myapp.com/api/user(同源),nginx 在服务器端悄悄把请求转到真实后端。跨域的锅,服务器背走了。

3.4 Vite 开发代理 + mockjs——本地开发的救星

开发阶段没上 nginx,怎么办?Vite 提供了 server.proxy,原理和 nginx 一模一样:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3001',  // 后端地址
        changeOrigin: true,
      }
    }
  }
}

前端请求 /api/user,Vite 开发服务器在本地帮你转发到 localhost:3001。浏览器看到的依然是同源,无跨域。

mockjs 更进一步:连后端都不用启,直接在开发阶段用假数据拦截请求,前端独立开发。

3.5 postMessage——跨窗口通信

前面几种解决的都是"前后端跨域",postMessage 解决的是页面与页面之间(iframe、新窗口)的跨域通信:

// 父页面(http://a.com)向 iframe(http://b.com)发消息
iframe.contentWindow.postMessage('你好', 'http://b.com');

// iframe 内部监听
window.addEventListener('message', (event) => {
  if (event.origin === 'http://a.com') {  // 校验来源,防止伪造
    console.log('收到', event.data);
  }
});

postMessage 是 HTML5 提供的跨源通信 API,配合 event.origin 校验来保证安全。

3.6 WebSocket——换一条"不设防"的路

有趣的是,WebSocket 解决跨域的思路完全不是"绕过同源策略",而是它压根不需要遵守同源策略。这是本文后半段的主角,我们单独展开。


四、手写一个 WebSocket 跨域通信 Demo 🔨

先看我们仓库里真实的代码:cors/demo

4.1 服务端 server.js

const WebSocket = require('ws');       // ws 库:WebSocket 协议的实现
const http = require('http');          // Node 内置的 http 模块

// ① 先启动一个普通的 HTTP Server
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('WebSocket Server Running!');
});

// ② 基于这个 HTTP Server 再搭建 WebSocket 协议,路径挂载在 /ws
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}`);  // 回显消息
  });
});

// ④ 监听 8080 端口
server.listen(8080, () => {
  console.log(`listening on http://localhost:8080`);
});

4.2 客户端 index.html

<script>
  // HTML5 原生支持,不需要引库
  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>

运行 node server.js,然后浏览器打开 index.html,控制台会看到客户端和服务端互相收到消息。

4.3 逐行拆解:WebSocket 的"两步握手" 🤝

最值得讲的是连接建立的过程——它回答了"为什么 WebSocket 能跨域"。

客户端 new WebSocket('ws://localhost:8080/ws') 之后,其实发生了两步

第一步:HTTP 握手(找服务器)
  客户端发起一次普通的 HTTP 请求,带上特殊请求头:
    Upgrade: websocket
    Connection: Upgrade
  → 意思是:"我想把这条 HTTP 连接升级成 WebSocket"

第二步:101 协议切换(切换成功)
  服务器回应:
    HTTP/1.1 101 Switching Protocols
  → 状态码 101 表示"切换协议成功"
  这条连接从此从 HTTP 变成了 WebSocket(ws://)

🎯 状态码 101 划重点

  • 1XX:信息性响应,表示"还在通信中,没有完成"
  • 2XX:成功
  • 3XX:重定向
  • 4XX:客户端错误
  • 5XX:服务器错误

101 Switching Protocols 就是典型的 1XX——"协议切换中"。

这就是为什么 WebSocket 不需要遵守同源策略:它名义上是 ws:// 协议,底层复用 HTTP 握手,但升级后已经是另一条协议了。同源策略约束的是 HTTP(S) 的资源读取,管不到 WebSocket 协议。

⚠️ 不过要澄清:WebSocket 握手阶段仍然会带上 Origin 头,服务器可以选择校验 Origin 来拒绝陌生来源。它"免跨域"是指浏览器不强制拦截,而不是"服务器不能做权限控制"。实际生产里,服务端通常还是会校验 Origin 防滥用。而我们的 Demo 因为只是演示双向通信,没有做 Origin 校验。


五、通信方向全景:HTTP vs SSE vs WebSocket 🧭

理解了 WebSocket,我们把 "HTTP 之外的协议" 这条线拎清楚。这是理解跨域方案的底层脉络。

5.1 HTTP:一问一答,必断

普通的 HTTP 是单向 + 短连接

用户发请求 → 服务器反馈 → 断开

服务器处于"伺服(serve)"被动状态,默认不能主动向客户端推数据。你想拿实时数据?只能"轮询"——客户端不停发请求问"有新的吗?",又笨又费。

5.2 SSE(Server-Sent Events):服务器单向推送

SSE 解决"服务器主动推"的问题,但仍是单向(服务器 → 客户端):

Content-Type: text/event-stream;
Cache-Control: no-cache;
Connection: keep-alive;

服务器可以不断向浏览器推送数据,客户端不用反复请求。AI 大模型的流式输出(打字机效果)用的大多就是这个思路。

5.3 WebSocket:双向全双工,两边平等

QQ、微信这类实时聊天,需要的不只是"服务器推",而是"两边都能主动发"。这就是 Socket 协议的双工(Duplex)通信

客户端 ⇄ 服务器   (两边都可以随时发数据)

在线状态、弹幕、直播、实时聊天——这些"你一言我一语"的场景,都是 Socket 协议的天下。抖音、腾讯、B 站、各种 AI 弹幕,底层都能看到 WebSocket 的身影。

5.4 一张表总结三种协议

对比维度HTTPSSEWebSocket
通信方向单向(客户端→服务器)单向(服务器→客户端)双向全双工
协议http/httpshttp/https 长连接ws/wss(独立协议)
能否跨域受同源策略约束受同源策略约束不受同源策略约束
断线重连无需浏览器内置自动重连需手动实现
典型场景普通请求AI 流式输出、通知聊天、弹幕、游戏

六、延伸思考:WebSocket 明明是双工,为什么 AI 流式不用它?🤔

这是本仓库 notes 里留下的一个好问题,也常是面试加分的点:

WebSocket 是双工通信,一边生成一边输出,用它做大模型流式输出也行啊,为什么主流都用 SSE?

答案是**"合适的工具做合适的事",不是"能力强就一定用"**:

  1. AI 对话本质是单向流:用户发一个问题,模型持续推回答。客户端在生成过程中并不需要"再主动推数据"回给服务器。WebSocket 的双向能力在这里是浪费。

  2. SSE 够简单:SSE 基于 HTTP 长连接,一个普通 fetchEventSource 就能搞定,不需要额外的握手升级、不需要维护连接状态机。WebSocket 要处理 ping/pong 保活、重连、心跳,复杂度高一个量级。

  3. SSE 自动重连:SSE 断线后浏览器内置自动重连机制;WebSocket 断线必须手动写重连逻辑,否则连接就死了。

  4. 基础设施友好:SSE 走 HTTP,天然能和负载均衡、CDN、代理、缓存这些 HTTP 生态无缝衔接。WebSocket 是独立协议,很多中间件要特别配置。

🎯 一句话总结:WebSocket 的强是"双向",但 AI 流式只需要"单向推送"。用 SSE 是为简单场景选简单工具,不是 WebSocket 不行,而是它"杀鸡用牛刀"。


七、总结:一张表记住全文 ✅

主题核心结论
同源策略协议+域名+端口三者一致才同源,是浏览器安全底线
跨域的本质不是请求发不出,而是浏览器拦截了响应读取
CORS标准方案,后端加 Access-Control-Allow-Origin 响应头
JSONP<script> 空子,只支持 GET,已过时
nginx 代理同源转发,跨域交给服务器,生产主流
Vite 代理/mockjs开发阶段的本地反向代理
postMessage跨窗口(iframe)通信,配 origin 校验
WebSocket换协议,握手 101 升级后不受同源策略约束
通信方向HTTP 单向短连、SSE 单向推送、WebSocket 双向全双工
AI 流式单向需求用 SSE 更简单,WebSocket 是"杀鸡牛刀"

跨域这道题,背方案没用,理解"同源策略在防什么、每种方案在哪一环绕过了它"才是关键。当你讲得清 WebSocket 的 101 握手升级、讲得清 SSE 和 WebSocket 的取舍时,这道题你已经赢了。


如果这篇文章对你有帮助,欢迎点赞、收藏、关注!有任何问题欢迎在评论区交流讨论 👏