前端必知必会:跨域解决方案全景,以及 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/a → http://a.com/b | ❌ 同源 | 协议、域名、端口都相同(默认 80 端口) |
http://a.com → https://a.com | ✅ 跨域 | 协议不同 |
http://a.com → http://b.com | ✅ 跨域 | 域名不同 |
http://a.com:3000 → http://a.com:8080 | ✅ 跨域 | 端口不同 |
http://a.com → http://news.a.com | ✅ 跨域 | 子域名也算不同域名 |
🎯 一句话:同源策略是浏览器的安全约定——不同源之间,默认不允许互相读取对方的资源(cookie、localStorage、DOM、Ajax 响应等)。
2.2 为什么要有同源策略?
假设没有同源策略:
- 你登录了网银
http://bank.com,浏览器存了你的登录凭证(cookie)。 - 你浏览另一个网站
http://evil.com。 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 一张表总结三种协议
| 对比维度 | HTTP | SSE | WebSocket |
|---|---|---|---|
| 通信方向 | 单向(客户端→服务器) | 单向(服务器→客户端) | 双向全双工 |
| 协议 | http/https | http/https 长连接 | ws/wss(独立协议) |
| 能否跨域 | 受同源策略约束 | 受同源策略约束 | 不受同源策略约束 |
| 断线重连 | 无需 | 浏览器内置自动重连 | 需手动实现 |
| 典型场景 | 普通请求 | AI 流式输出、通知 | 聊天、弹幕、游戏 |
六、延伸思考:WebSocket 明明是双工,为什么 AI 流式不用它?🤔
这是本仓库 notes 里留下的一个好问题,也常是面试加分的点:
WebSocket 是双工通信,一边生成一边输出,用它做大模型流式输出也行啊,为什么主流都用 SSE?
答案是**"合适的工具做合适的事",不是"能力强就一定用"**:
-
AI 对话本质是单向流:用户发一个问题,模型持续推回答。客户端在生成过程中并不需要"再主动推数据"回给服务器。WebSocket 的双向能力在这里是浪费。
-
SSE 够简单:SSE 基于 HTTP 长连接,一个普通
fetch或EventSource就能搞定,不需要额外的握手升级、不需要维护连接状态机。WebSocket 要处理 ping/pong 保活、重连、心跳,复杂度高一个量级。 -
SSE 自动重连:SSE 断线后浏览器内置自动重连机制;WebSocket 断线必须手动写重连逻辑,否则连接就死了。
-
基础设施友好: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 的取舍时,这道题你已经赢了。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注!有任何问题欢迎在评论区交流讨论 👏