你真的懂跨域吗?为什么浏览器要拦跨域请求?CORS 和 JSONP 到底有啥区别?Nginx 反向代理怎么配置才管用?今天咱们从底层原理讲到实战代码,再穿插面试官常问的坑,一篇把跨域讲透!
一、“拦路虎” 同源策略
跨域的根源,其实是浏览器的同源策略—— 这是浏览器为了保护用户数据安全设置的 “安全结界”。咱们先拆透这个结界的规则。
同源策略限制了从同一个源加载的文档或脚本如何与另一个源的资源进行交互。这是浏览器的一个用于隔离潜在恶意文件的重要的安全机制。
1. 什么是 “同源”?3 个条件必须全满足
“同源” 指的是两个 URL 的 协议、域名、端口 完全一致,少一个都不算同源。举个例子,以 http://company.com/post/page.html 为基准,对比结果一目了然:
| 目标 URL | 是否同源 | 原因 |
|---|---|---|
http://company.com/post/inner.html | 是 | 只有路径不同,协议、域名、端口一致 |
https://company.com/secure.html | 否 | 协议不同(http vs https) |
http://company.com:81/dir.html | 否 | 端口不同(默认 80 vs 81) |
http://company.com/dir.html | 否 | 域名不同(store vs news) |
这里有个小细节:HTTP 默认端口是 80,HTTPS 默认是 443,写 URL 时可以省略,但浏览器会自动补全 —— 所以 http://a.com 和 http://a.com:80 是同源,和 http://a.com:8080 就不是。
2. 同源策略 “拦” 的是什么?3 类 JS 操作被禁止
很多人以为 “跨域 = 所有请求都拦”,其实不对 —— 同源策略只拦 JavaScript 发起的跨域操作,不拦浏览器原生的资源加载(比如 img、script 标签)。具体来说,它禁止 3 件事:
- 禁止读取其他域的存储数据:比如 JS 不能读其他域的 Cookie、LocalStorage、IndexDB(防止恶意网站偷用户登录态);
- 禁止操作其他域的 DOM:比如父窗口的 JS 不能改子 iframe(不同域)的 DOM(防止篡改页面内容);
- 禁止发起跨域 AJAX 请求:比如
localhost:8080的 JS 不能发 AJAX 到api.xxx.com(防止伪造请求发起攻击)。
值得注意的是,同源策略限制的是脚本的读取能力。实际上,跨域请求已经成功发送到服务器,服务器也处理了请求并返回了数据。但浏览器在接收到响应时,会检查其来源是否同源。如果不是,浏览器会拦截响应,不将其交给 JavaScript,并在控制台抛出错误。
为什么 script、img 标签没有跨域限制?
因为这些标签是 “单向加载资源”,不会通过响应数据执行 可能有安全风险的操作:比如 img 只显示图片,不会解析响应里的 JS;script 虽然会执行,但加载的是预先信任的资源(比如 CDN 的 JS),且无法主动读取响应内容 —— 所以浏览器允许它们跨域加载。
二、如何解决跨域问题?
针对同源策略的限制,业界发展出了多种解决方案。我们重点介绍几种最核心、最常用的方法。
1. CORS:现代项目首选,浏览器 + 服务器 “协商” 解决
CORS(跨域资源共享)是 W3C 标准,也是现在最推荐的方案 —— 本质是 浏览器和服务器通过 HTTP 头 “协商” ,让服务器明确告诉浏览器:“这个域的请求我允许,你放心过”。
“简单请求” 和 “非简单请求”
浏览器会根据请求的 “危险程度”,把 CORS 请求分成两类,处理逻辑不同:
(1)简单请求:直接发,不用 “打招呼”
满足以下两个条件就是简单请求,浏览器会直接发起跨域请求,不用提前 “请示”:
- 请求方法是 HEAD、GET、POST 三者之一;
- 请求头只包含特定的安全字段 Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type(且 Content-Type 只能是
application/x-www-form-urlencoded、multipart/form-data、text/plain)。
流程拆解:
-
浏览器发请求时,自动加一个
Origin头,告诉服务器 “我来自哪个域”(比如Origin: http://localhost:8080); -
服务器收到后,检查
Origin是否在允许列表里:- 允许:返回的响应头里加
Access-Control-Allow-Origin: http://localhost:8080(和 Origin 一致),浏览器看到这个头就放行; - 不允许:服务器返回正常 HTTP 响应(比如 200),但没有
Access-Control-Allow-Origin,浏览器发现后就报错。
- 允许:返回的响应头里加
(2)非简单请求:先 “预检”,再发正式请求
如果请求不满足简单请求条件(比如方法是 PUT/DELETE,或 Content-Type 是 application/json),浏览器会先发一个 预检请求(OPTIONS 方法) ,问服务器:“我能发这个请求吗?” 得到允许后才发正式请求。
流程拆解:
- 浏览器发预检请求,带 3 个关键头:
Origin:请求来源域;Access-Control-Request-Method:正式请求要用的方法(比如 PUT);Access-Control-Request-Headers:正式请求要带的自定义头(比如Content-Type: application/json);
-
服务器回应预检请求,返回允许的配置:
Access-Control-Allow-Origin: http://localhost:8080 # 允许的域 Access-Control-Allow-Methods: GET, POST, PUT # 允许的方法 Access-Control-Allow-Headers: Content-Type # 允许的头 Access-Control-Max-Age: 1728000 # 预检结果缓存1天(避免重复发预检)
性能优化:通过在服务器响应中设置
Access-Control-Max-Age头部,让浏览器在指定时间内缓存预检请求的结果,避免对同一 URL 的重复预检。
- 预检通过后,浏览器发正式请求,后续流程和简单请求一致。
实战代码:服务器怎么配置 CORS?
CORS 的核心在服务器,前端基本不用改代码(除非要带 Cookie)。以 Node.js(Express)为例:
// 1. 简单配置(允许所有域,不带Cookie)
const express = require('express');
const app = express();
// 所有请求都加CORS头
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*'); // *表示允许所有域(不能和Cookie同时用)
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// 接口
app.get('/api/data', (req, res) => {
res.json({ msg: 'CORS成功' });
});
app.listen(3000);
如果要带 Cookie,需要满足 3 个条件:
-
前端请求加
withCredentials: true(Axios 为例):axios.get('http://localhost:3000/api/data', { withCredentials: true // 允许带Cookie }); -
服务器
Access-Control-Allow-Credentials: true; -
服务器
Access-Control-Allow-Origin不能是*,必须写具体域名(比如http://localhost:8080)。
简单请求和非简单请求的核心区别是什么?
简单请求直接发,只带
Origin头;非简单请求先发 OPTIONS 预检,确认服务器允许后再发正式请求 —— 本质是浏览器对 “高风险请求” 的额外保护(比如 PUT/DELETE 可能修改服务器数据,需要更谨慎)。
2. JSONP:兼容老浏览器的 “老办法”,靠 script 标签突围
JSONP 是 CORS 普及前的常用方案,底层原理很 “取巧”:利用 script 标签没有跨域限制,通过 GET 请求把数据 “包裹” 在函数调用里返回。
底层原理:4 步完成数据传递
- 前端定义一个全局回调函数(比如
handleData),用来接收跨域数据; - 前端创建一个 script 标签,src 指向跨域接口,同时传一个
callback参数(值是回调函数名,比如http://localhost:3000/api/jsonp?callback=handleData); - 服务器收到请求后,拼接字符串:
回调函数名(数据)(比如handleData({msg: 'JSONP成功'})),返回给浏览器; - 浏览器加载 script 标签,执行返回的函数调用,数据就传到前端了。
原生 JS+Node.js 实现
<!-- 前端:http://localhost:8080 -->
<script>
// 1. 定义全局回调函数
function handleData(data) {
console.log('跨域数据:', data); // 输出 {msg: 'JSONP成功'}
}
// 2. 创建script标签,发起请求
const script = document.createElement('script');
script.src = 'http://localhost:3000/api/jsonp?callback=handleData'; // 带callback参数
document.body.appendChild(script);
// 3. 用完删除script(可选,避免冗余)
script.onload = () => {
document.body.removeChild(script);
};
</script>
// 后端:Node.js(Express)
app.get('/api/jsonp', (req, res) => {
const { callback } = req.query; // 1. 获取前端传的回调函数名
const data = { msg: 'JSONP成功' }; // 2. 要返回的数据
const result = `${callback}(${JSON.stringify(data)})`; // 3. 拼接函数调用
res.type('text/javascript'); // 4. 设置响应类型为JS(浏览器会执行)
res.send(result); // 返回:handleData({"msg":"JSONP成功"})
});
JSONP 的致命缺点:
- 只能用 GET 请求:因为 script 标签的 src 只能发 GET;
- 有 XSS 风险:如果服务器返回的内容包含恶意 JS,会直接执行(比如
callback=恶意代码); - 无法捕获错误:script 标签加载失败时,前端很难判断是网络问题还是服务器问题。
JSONP 和 CORS 的区别是什么?
| 维度 | JSONP | CORS |
|---|---|---|
| 请求方法 | 只支持 GET | 支持 GET/POST/PUT 等所有方法 |
| 安全性 | 有 XSS 风险 | 更安全(有预检、CSP 等保护) |
| 错误处理 | 无法捕获加载错误 | 可通过 AJAX 的 error 回调捕获 |
| 兼容性 | 兼容所有浏览器(包括 IE) | IE8/9 不支持(需用 XDomainRequest) |
3. postMessage:跨窗口 /iframe 的 “信使”,HTML5 标准方案
如果需要跨窗口(比如新打开的窗口)或跨域 iframe 通信,postMessage 是最佳选择 —— 它是 HTML5 专门为跨源通信设计的 API,底层是 “定向发送消息 + 安全校验”。
底层原理:2 个核心参数 + 1 个事件监听
postMessage 的语法很简单:window.postMessage(data, targetOrigin),两个参数必须懂:
data:要发送的数据(HTML5 支持对象,但建议用JSON.stringify序列化,避免浏览器兼容问题);targetOrigin:目标窗口的域(比如http://localhost:3000),可以设为*(允许发给任何域,但有安全风险);
接收消息时,需要监听 message 事件:窗口会在收到消息时触发这个事件,通过 event 对象获取数据和来源。
父窗口(8080)和跨域 iframe(3000)通信
<!-- 父窗口:http://localhost:8080 -->
<iframe id="crossIframe" src="http://localhost:3000/iframe.html" style="width: 300px; height: 200px;"></iframe>
<script>
const iframe = document.getElementById('crossIframe');
// 1. iframe加载完成后发消息
iframe.onload = () => {
const data = { type: 'greet', content: '你好,iframe!' };
// 发消息:数据+目标域(必须写具体域,别用*)
iframe.contentWindow.postMessage(JSON.stringify(data), 'http://localhost:3000');
};
// 2. 监听iframe返回的消息
window.addEventListener('message', (event) => {
// 安全校验:只处理来自3000域的消息
if (event.origin !== 'http://localhost:3000') return;
const data = JSON.parse(event.data);
console.log('iframe返回:', data); // 输出 { type: 'reply', content: '你好,父窗口!' }
});
</script>
<!-- iframe页面:http://localhost:3000/iframe.html -->
<script>
// 1. 监听父窗口的消息
window.addEventListener('message', (event) => {
// 安全校验:只处理来自8080域的消息
if (event.origin !== 'http://localhost:8080') return;
const data = JSON.parse(event.data);
console.log('父窗口发来:', data); // 输出 { type: 'greet', content: '你好,iframe!' }
// 2. 给父窗口返回消息
const replyData = { type: 'reply', content: '你好,父窗口!' };
event.source.postMessage(JSON.stringify(replyData), 'http://localhost:8080');
});
</script>
postMessage 的 targetOrigin 设为 * 有什么风险?
如果设为
*,表示 “允许发给任何域的窗口”—— 如果窗口被恶意网站劫持(比如 iframe 的 src 被篡改),消息会被恶意网站接收,可能泄露敏感数据(比如用户 ID)。所以必须写具体的目标域,做安全校验。
4. Nginx:服务端解决跨域的 “利器”,配置即生效
Nginx 是反向代理的常用工具,解决跨域主要有两种场景:要么直接设置 CORS 头,要么做反向代理。
场景 1:静态资源跨域(比如 iconfont)
浏览器允许加载跨域的 JS/CSS,但字体文件(eot/ttf/woff)会被拦截,此时只需在 Nginx 配置里加 CORS 头:
# nginx.conf
server {
listen 80;
server_name static.xxx.com; # 静态资源域名
# 所有请求都加CORS头
location / {
add_header Access-Control-Allow-Origin *; # 允许所有域(静态资源安全)
add_header Access-Control-Allow-Methods GET,POST;
root /usr/share/nginx/html; # 静态资源目录
}
}
场景 2:接口反向代理(核心用法)
比如前端 localhost:8080 想访问 http://api.xxx.com:3000,Nginx 配置如下:
# nginx.conf
server {
listen 80;
server_name localhost; # 代理服务器域名(和前端同域)
# 匹配前端请求的接口路径(比如 /api 开头)
location /api {
# 1. 把请求转发到真实接口
proxy_pass http://api.xxx.com:3000;
# 2. 修改Cookie的域名(可选,让Cookie能写入)
proxy_cookie_domain api.xxx.com localhost;
# 3. 加CORS头(如果需要带Cookie,Origin不能是*)
add_header Access-Control-Allow-Origin http://localhost:8080;
add_header Access-Control-Allow-Credentials true;
# 4. 处理预检请求(OPTIONS)
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE;
add_header Access-Control-Allow-Headers Content-Type;
return 200; # 预检请求直接返回成功
}
}
}
配置完后,前端请求地址从 http://api.xxx.com:3000/api/data 改成 http://localhost/api/data—— 和前端同域,完美解决跨域。
Nginx 配置 CORS 时,add_header 为什么有时候不生效?
可能有两个原因:
add_header只在 200、204、301 等成功状态码生效,如果服务器返回 404、500,需要加always(比如add_header Access-Control-Allow-Origin * always;);- 配置层级错误:如果在
http层加了add_header,又在server或location层加了,会覆盖上层配置 —— 建议在location层针对性配置。
正向代理 vs 反向代理:从 “谁来设置” 看本质
很多人分不清正向代理和反向代理,其实核心就一个:代理是给谁用的?想隐藏谁? 先看一张对比表:
| 维度 | 正向代理(Forward Proxy) | 反向代理(Reverse Proxy) |
|---|---|---|
| 谁设置的 | 客户端(比如用户自己) | 服务器端(比如网站运维) |
| 作用 | 帮助客户端访问无法直接访问的服务器 | 帮助服务器分流、隐藏真实 IP |
| 隐藏对象 | 隐藏客户端(服务器不知道谁发的请求) | 隐藏服务器(客户端不知道访问的是谁) |
| 例子 | VPN、科学上网 | Nginx 负载均衡、CDN |
底层逻辑:为什么代理能解决跨域?
跨域是浏览器的同源策略限制,服务器之间没有跨域问题—— 代理的本质就是 “让浏览器和代理同域,服务器之间通信”:
- 比如前端在
localhost:8080,想访问api.xxx.com(跨域); - 搭一个反向代理服务器(比如 Nginx,
localhost:80); - 前端发请求到
localhost:80/api(和前端同域,不跨域); - Nginx 把请求转发到
api.xxx.com(服务器之间通信,无跨域); - Nginx 把响应返回给前端。
正向代理和反向代理的核心区别是什么?
最关键的两点:
- 代理的 “归属”:正向代理是客户端的工具,反向代理是服务器的工具;
- 隐藏的 “对象”:正向代理隐藏客户端,反向代理隐藏服务器。
5. WebSocket:实时通信的 “直通车”,天然支持跨域
WebSocket 是 HTML5 推出的全双工通信协议,它最大的特点是:一旦建立连接,客户端和服务器可以随时互相发送数据,而且天然支持跨域—— 这让它成为实时聊天、股票行情、弹幕等场景的首选。
底层原理:从 HTTP 握手到 TCP 长连接
WebSocket 虽然用 ws:// 或 wss:// 协议(比如 ws://api.xxx.com),但建立连接的过程很特殊:
-
握手阶段:客户端先发送一个 HTTP 请求,带特殊头信息,申请 “升级” 到 WebSocket 协议:
GET /ws HTTP/1.1 Host: api.xxx.com Origin: http://localhost:8080 # 客户端域名(跨域时会带) Connection: Upgrade # 申请升级协议 Upgrade: websocket # 要升级到WebSocket Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== # 随机字符串(用于校验) -
服务器响应:如果同意,返回 101 状态码(协议切换),并带上加密后的校验信息:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk= # 加密后的Key -
建立连接:握手成功后,HTTP 连接升级为 TCP 长连接,客户端和服务器可以通过这个连接双向发送数据,且不受同源策略限制(浏览器会校验 Origin,但服务器可以决定是否允许)。
实战代码:原生 WebSocket+Node.js 实现
<!-- 前端:http://localhost:8080 -->
<input type="text" id="msgInput" placeholder="输入消息">
<button onclick="sendMsg()">发送</button>
<script>
// 1. 建立WebSocket连接(注意协议是ws,跨域直接写目标域名)
const ws = new WebSocket('ws://localhost:3000');
// 2. 连接成功回调
ws.onopen = () => {
console.log('WebSocket连接已建立');
};
// 3. 接收服务器消息
ws.onmessage = (event) => {
console.log('收到服务器消息:', event.data);
};
// 4. 发送消息到服务器
function sendMsg() {
const input = document.getElementById('msgInput');
ws.send(input.value); // 发送字符串(对象需JSON.stringify)
input.value = '';
}
// 5. 连接关闭回调
ws.onclose = () => {
console.log('WebSocket连接已关闭');
};
// 6. 错误处理
ws.onerror = (err) => {
console.error('WebSocket错误:', err);
};
</script>
// 后端:Node.js(用ws库,先安装npm i ws)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3000 });
// 监听连接事件
wss.on('connection', (ws, req) => {
// 1. 校验客户端来源(可选,增强安全性)
const origin = req.headers.origin;
const allowedOrigins = ['http://localhost:8080'];
if (!allowedOrigins.includes(origin)) {
ws.close(1008, '不允许的来源'); // 拒绝连接
return;
}
console.log('客户端已连接');
// 2. 接收客户端消息
ws.on('message', (data) => {
console.log('收到客户端消息:', data.toString());
// 3. 给客户端回复消息
ws.send(`服务器收到:${data}`);
});
// 4. 连接关闭时
ws.on('close', () => {
console.log('客户端已断开');
});
});
WebSocket 的跨域优势:
- 天然跨域:握手阶段虽然带 Origin 头,但服务器可以自主决定是否允许(不像 AJAX 被浏览器直接拦截);
- 全双工通信:一次连接可双向持续发送数据,比轮询(定时 AJAX)效率高 10 倍以上;
- 无请求方法限制:数据格式灵活,可发文本、二进制(图片、视频)。
👉 WebSocket 和 HTTP 的区别是什么?
| 维度 | HTTP | WebSocket |
|---|---|---|
| 连接方式 | 短连接(请求 - 响应后关闭) | 长连接(一次建立,持续通信) |
| 通信方向 | 单向(客户端请求→服务器响应) | 双向(客户端↔服务器随时发数据) |
| 跨域处理 | 受同源策略限制(需 CORS 等) | 天然支持跨域(服务器控制 Origin) |
| 适用场景 | 普通接口请求 | 实时通信(聊天、直播、监控) |
三、其他解法:了解即可
除了上面 5 种,还有几种老方案,现在用得少,简单带过:
- document.domain:仅限 “主域相同、子域不同” 的场景(比如
a.xxx.com和b.xxx.com),双方都设document.domain = 'xxx.com'即可同域; - location.hash:通过 iframe 的 hash 传值,需中间页转发(比如 A→B→C,C 和 A 同域),流程复杂,易泄露数据;
- window.name:利用
window.name在页面刷新后不变的特性,通过 iframe 加载跨域页,再切换到同域页读取 name 值,兼容性好但逻辑绕;
总结:不同场景怎么选?一张表搞定
| 场景 | 推荐方案 | 优点 | 缺点 |
|---|---|---|---|
| 现代项目接口跨域 | CORS | 标准、安全、支持所有方法 | IE8/9 不支持 |
| 兼容老浏览器(比如 IE) | JSONP | 兼容性好 | 只支持 GET、有 XSS 风险 |
| 跨窗口 /iframe 通信 | postMessage | 标准、安全 | 需要监听事件、序列化数据 |
| 服务端控制、需负载均衡 | Nginx 反向代理 | 无前端改动、性能好 | 需要配置服务器 |
| 客户端访问墙外资源 | 正向代理(VPN) | 简单、客户端可控 | 依赖代理服务 |