在前端开发中,几乎每个开发者都会遇到这样一个场景:本地启动的开发服务器运行在 http://localhost:3000,而后端 API 运行在 http://localhost:8080,浏览器控制台无情地抛出一行红字:
Access to fetch at 'http://localhost:8080/api' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header...
这就是跨域问题。理解它的本质和解决方案,是前后端协作开发的基础技能。
16.4.1 同源策略的本质与限制
同源策略(Same-Origin Policy) 是浏览器的核心安全机制。它规定:一个源的文档或脚本,只能与同源的资源进行交互。
什么是“同源”?必须同时满足三个条件:
- 协议相同(如
httpvshttps) - 域名相同(如
www.example.com的主域名和子域名都算不同源) - 端口相同(如
3000vs8080)
举例来说,http://www.example.com:80/page 与以下地址的同源性判断:
| 待比较地址 | 是否同源 | 原因 |
|---|---|---|
| http://www.example.com:80/other | ✅ 同源 | 协议、域名、端口完全相同 |
| https://www.example.com:80/page | ❌ 不同源 | 协议不同(https vs http) |
| http://api.example.com:80/page | ❌ 不同源 | 域名不同(子域名不同) |
| http://www.example.com:8080/page | ❌ 不同源 | 端口不同 |
同源策略限制了什么?
- 不能读取不同源的 Cookie、localStorage、IndexedDB
- 不能获取不同源的 DOM(比如 iframe 内嵌的页面内容)
- 不能向不同源地址发送 AJAX 请求(XHR 或 fetch)并读取响应
注意:请求是可以发出去的,服务器也收到了请求并返回了响应,但浏览器拒绝将响应交给 JavaScript 代码。这是浏览器的安全拦截,不是网络层面的阻断。
16.4.2 JSONP:利用 script 标签的“漏洞”
在同源策略的限制下,有一个历史悠久的例外:<script> 标签加载外部脚本不受同源策略限制。JSONP(JSON with Padding)正是利用这一特性实现跨域。
原理:
- 前端动态创建一个
<script>标签,将其src指向目标 API 地址,并附加一个回调函数名作为查询参数。 - 服务端接收到请求后,不直接返回 JSON 数据,而是将 JSON 数据包裹在前端指定的回调函数中,返回一段可执行的 JavaScript 代码。
- 浏览器加载这个脚本后,立即执行该回调函数,前端通过回调函数拿到数据。
代码示例:
// 前端定义回调
function handleData(data) {
console.log('跨域获取的数据:', data);
}
// 动态创建 script 标签
const script = document.createElement('script');
script.src = 'http://api.example.com/data?jsonp=handleData';
document.body.appendChild(script);
// 后端返回内容示例(不是 JSON,而是 JS 代码)
// handleData({"name": "Alice", "age": 25})
JSONP 的局限:
- 只支持 GET 请求,不支持 POST 等其他方法
- 需要服务端专门配合,返回格式必须兼容
- 安全性较差,容易遭受 XSS 攻击(如果对方返回恶意代码也会被执行)
- 无法处理 HTTP 状态码和错误,异常处理困难
在现代开发中,JSONP 基本已被 CORS 淘汰,但了解它的原理有助于理解浏览器安全策略的演进。
16.4.3 CORS:官方的跨域解决方案
CORS(Cross-Origin Resource Sharing,跨域资源共享) 是 W3C 标准,也是目前解决跨域问题的最佳实践。它通过服务器设置一系列 HTTP 响应头,明确告诉浏览器“这个跨域请求是被允许的”。
简单请求与非简单请求
浏览器将跨域请求分为两类:
简单请求:同时满足以下条件:
- 请求方法为
GET、HEAD、POST之一 - 请求头只包含
Accept、Accept-Language、Content-Language、Content-Type(仅限text/plain、multipart/form-data、application/x-www-form-urlencoded) - 没有自定义请求头
简单请求直接发送,浏览器会根据响应头中的 Access-Control-Allow-Origin 判断是否允许访问。
非简单请求:凡是不满足上述条件的都是非简单请求(例如发送 application/json 数据、自定义 Header、PUT/DELETE 方法等)。浏览器会先发一个 OPTIONS 预检请求(Preflight),询问服务器是否允许该请求,服务器明确允许后才发送实际请求。
关键响应头
服务端需要设置以下几个 HTTP 响应头:
// 允许的来源,可以是具体域名或 *(通配符)
Access-Control-Allow-Origin: http://localhost:3000
// 允许的请求方法
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
// 允许的请求头
Access-Control-Allow-Headers: Content-Type, Authorization
// 是否允许携带 Cookie
Access-Control-Allow-Credentials: true
// 预检请求缓存时间(秒),减少重复询问
Access-Control-Max-Age: 86400
注意:当 Access-Control-Allow-Credentials 设置为 true 时,Access-Control-Allow-Origin 不能设为 *,必须指定具体域名。
前端如何配合
前端代码基本不需要额外处理,保持正常的请求写法即可:
// 如果需要带 Cookie,需要设置 credentials
fetch('http://api.example.com/data', {
credentials: 'include' // 携带跨域请求的 Cookie
})
.then(res => res.json())
.then(data => console.log(data));
如果使用 axios,全局设置更简单:
axios.defaults.withCredentials = true;
CORS 是标准的、安全的跨域方案,也是前后端分离架构中最常用的方式,推荐优先使用。
16.4.4 代理方案:绕过浏览器的限制
既然跨域限制是浏览器的行为,那么如果请求不经过浏览器,自然就没有跨域问题了。代理(Proxy)正是基于这个思路。
开发环境:webpack-dev-server / Vite 代理
在开发环境中,webpack-dev-server 和 Vite 都提供了代理配置,将特定路径的请求转发到后端服务器。浏览器以为是同源请求,实际上被开发服务器转发到了后端。
Vite 配置示例(vite.config.js):
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
前端请求 /api/users,Vite 开发服务器将其代理转发到 http://localhost:8080/users,响应原路返回。浏览器只与开发服务器通信,完美规避跨域。
生产环境:Nginx 反向代理
在真正部署上线后,通常使用 Nginx 作为反向代理服务器,将前端静态资源和后端 API 统一到同一个域名下。
Nginx 配置示例:
server {
listen 80;
server_name www.example.com;
# 前端静态资源
location / {
root /var/www/frontend/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# 后端 API 反向代理
location /api/ {
proxy_pass http://api-server:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
这样,前端页面和 API 接口都在 www.example.com 域名下,完全同源,无需 CORS 配置,也更安全(API 服务器可以不对公网暴露)。
16.4.5 postMessage:窗口与 iframe 间的跨域通信
有时需要解决的不是 AJAX 请求的跨域,而是两个不同源的窗口(或 iframe)之间的通信。window.postMessage 是浏览器提供的安全跨域通信 API。
使用场景:
- 父页面与嵌入的跨域 iframe 通信
- 两个打开的浏览器标签页之间通信
发送消息:
// 父页面向 iframe 发送消息
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage('Hello from parent', 'http://child.example.com');
// 子页面监听消息
window.addEventListener('message', (event) => {
// 验证来源域名,防止恶意消息
if (event.origin !== 'http://parent.example.com') return;
console.log('收到的消息:', event.data);
// 可以回复消息
event.source.postMessage('Got it!', event.origin);
});
postMessage 通信是双向的,但需要双方都验证 event.origin,不能盲目信任,否则会有安全风险。
16.4.6 其他方案简述
除了上述主流方案外,还有一些较少使用的跨域手段,简单了解即可:
- WebSocket:不受同源策略限制(但建立连接时的握手是通过 HTTP 完成的,仍需处理跨域),之后就可以自由双向通信。
- window.name:利用 window.name 属性在不同页面跳转时依然保持不变的特性传输数据,是一种 hack 方案,现在已不推荐。
- document.domain:将两个页面的
document.domain设置为相同主域来通信,只能用于主域相同、子域不同的情况,且安全性有限,已逐渐被废弃。
16.4.7 方案选型建议
在实际开发中,推荐优先级如下:
- CORS:前后端都可以控制,标准化且安全,是首选方案。
- 开发代理:开发阶段使用 Vite/Webpack 代理,零配置跨域。
- Nginx 反向代理:生产环境统一域名,无需前端操心。
- postMessage:用于 iframe 或窗口间通信,而非 AJAX 跨域。
- JSONP:除非需要兼容非常老旧的浏览器,否则不推荐使用。
理解跨域问题的本质——它不是网络障碍,而是浏览器对用户数据安全的保护——之后,选择合适的解决方案就能从容应对。