一、前言:为什么我们总在谈"跨域"?
作为一名前端开发者,你一定遇到过这样的报错:
bash
Access to fetch at 'http://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
红字警告,请求被拦截,数据拿不到,页面空白......是不是很熟悉?
这,就是臭名昭著的 跨域问题(Cross-Origin Resource Sharing)。
在前后端分离架构盛行的今天,前端跑在 localhost:3000
,后端接口在 api.example.com:8080
,看似只是"换个地址",实则触发了浏览器的 同源策略(Same-Origin Policy) ------这是浏览器为保障用户安全而设立的"防火墙"。
那跨域真的"跨"不过去吗?当然不是。本文将带你从 JSONP 到 CORS,深入浅出地理解跨域的本质与解决方案,让你在面试和实战中游刃有余。
二、什么是"同源"?为什么要有同源策略?
所谓"同源",指的是 协议(protocol)、域名(host)、端口(port) 三者完全相同。
比如:
URL | 是否同源于 http://localhost:3000 |
---|---|
http://localhost:3000/api |
✅ 同源 |
https://localhost:3000/api |
❌ 协议不同(https vs http) |
http://127.0.0.1:3000/api |
❌ 域名不同(localhost vs 127.0.0.1) |
http://localhost:8080/api |
❌ 端口不同 |
只要有一个不同,就算"跨源"。
同源策略的目的
浏览器引入同源策略,是为了防止恶意网站通过脚本读取其他网站的数据,比如:
- 恶意页面通过
<script>
加载银行网站的接口,窃取用户信息? - 通过
XMLHttpRequest
直接读取你邮箱里的私密内容?
听起来吓人吧?所以浏览器规定:
只有同源的资源才能被脚本(如 AJAX、fetch)自由访问。
但现实开发中,前后端分离、微服务架构、CDN 静态资源分发......跨域是常态。于是,我们得想办法"合法跨域"。
三、远古方案:JSONP ------ 利用 <script>
的"漏洞"
<script>
为什么能跨域?
你有没有发现,下面这段代码从不会报跨域错误?
html
<script src="https://cdn.jsdelivr.net/npm/vue@3"></script>
没错,<script>
、<img>
、<link>
等标签天生支持跨域加载资源。这是浏览器为了正常网页功能(如加载 CDN 资源)而留下的"后门"。
JSONP(JSON with Padding)正是利用了这一点。
JSONP 的核心思想
既然 fetch
和 XMLHttpRequest
被 CORS 拦截,那我们就不用它们,改用 <script>
标签来"发请求"。
但 <script>
加载的是 JS 脚本,不是 JSON 数据。怎么办?
思路:让后端返回一段 JS 代码,调用一个前端定义好的函数,并把数据作为参数传进去。
手写一个 JSONP 实现
前端代码:
javascript
function jsonp(url, params = {}, callbackName = 'callback') {
return new Promise((resolve, reject) => {
// 生成唯一函数名,防止冲突
const fnName = 'jsonp_' + Date.now() + '_' + Math.random().toString(36).substr(2);
// 挂载全局函数
window[fnName] = function (data) {
resolve(data);
// 清理:删除 script 标签和全局函数
document.body.removeChild(script);
delete window[fnName];
};
// 构造带 callback 参数的 URL
const queryString = new URLSearchParams({
...params,
[callbackName]: fnName
}).toString();
const script = document.createElement('script');
script.src = `${url}?${queryString}`;
script.onerror = () => {
reject(new Error('JSONP request failed'));
document.body.removeChild(script);
delete window[fnName];
};
document.body.appendChild(script);
});
}
// 使用示例
jsonp('http://api.example.com/user', { id: 123 })
.then(data => console.log('用户数据:', data))
.catch(err => console.error('请求失败:', err));
后端返回格式(Node.js 示例)
js
app.get('/user', (req, res) => {
const { id, callback } = req.query;
const userData = { id, name: '归于尽', age: 21 };
// 返回 JS 函数调用
res.setHeader('Content-Type', 'application/javascript');
res.send(`${callback}(${JSON.stringify(userData)})`);
});
返回内容实际是:
js
jsonp_123456789_abc({"id":123,"name":"归于尽","age":21})
JSONP 的局限性
- ✅ 优点:兼容老浏览器(IE6 都能用)
- ❌ 缺点:
- 只支持
GET
请求 - 需要后端配合返回函数调用
- 安全性差(易被 XSS 攻击)
- 错误处理不完善
- 只支持
所以,JSONP 已逐渐被淘汰,仅用于兼容极老项目。
四、现代主流方案:CORS(跨域资源共享)
CORS 是什么?
CORS(Cross-Origin Resource Sharing)是 W3C 制定的标准,允许服务器声明哪些外域可以访问其资源。
核心机制:通过 HTTP 响应头控制跨域权限。
只要后端在响应中带上:
http
Access-Control-Allow-Origin: http://localhost:3000
浏览器就会放行该跨域请求。
简单请求 vs 预检请求
CORS 将请求分为两类:
✅ 简单请求
满足以下所有条件:
- 请求方法是:
GET
、POST
、HEAD
- Content-Type 仅限:
text/plain
application/x-www-form-urlencoded
multipart/form-data
这类请求不会触发预检 ,浏览器直接发送请求,后端加个
Access-Control-Allow-Origin
即可。
前端代码示例:
javascript
fetch('http://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'name=归于尽&age=21'
})
.then(res => res.json())
.then(data => console.log(data));
后端 Node.js 配置:
js
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
res.header('Access-Control-Allow-Methods', 'GET,POST,HEAD');
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
});
🔥 复杂请求(需要预检)
只要不符合"简单请求"条件,就会触发 预检请求。
浏览器先发送一个 OPTIONS
请求,询问服务器:"我能不能跨域发这个请求?"
服务器必须返回允许的配置,浏览器才会发送真正的请求。
触发预检的常见情况:
- 使用
PUT
、DELETE
、PATCH
方法 - 自定义请求头,如
Authorization: Bearer xxx
- Content-Type 为
application/json
(注意!)
前端示例(触发预检):
javascript
fetch('http://api.example.com/user/123', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer xxx' // 自定义头
},
body: JSON.stringify({ name: '归于尽' })
})
.then(res => res.json())
.then(data => console.log(data));
后端必须响应 OPTIONS 请求:
js
app.options('/user/*', (req, res) => {
res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.sendStatus(200); // 返回 200 表示允许
});
// 实际 PUT 接口
app.put('/user/:id', (req, res) => {
// 也要带上 CORS 头
res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
res.json({ success: true, data: req.body });
});
常用 CORS 响应头
Header | 说明 |
---|---|
Access-Control-Allow-Origin |
允许的源,可设具体域名或 * (但 * 不支持 credentials) |
Access-Control-Allow-Methods |
允许的 HTTP 方法 |
Access-Control-Allow-Headers |
允许的请求头 |
Access-Control-Allow-Credentials |
是否允许携带 Cookie(设为 true 时,Origin 不能为 * ) |
Access-Control-Max-Age |
预检结果缓存时间(秒) |
五、其他跨域方案(补充)
方案 | 适用场景 | 说明 |
---|---|---|
Nginx 反向代理 | 开发/生产环境 | 将 API 请求代理到同域,彻底避免跨域 |
Webpack DevServer 代理 | 开发环境 | proxy: { '/api': 'http://localhost:8080' } |
PostMessage | iframe 通信 | 页面与 iframe 间跨域传数据 |
WebSocket | 实时通信 | 不受同源策略限制 |
推荐:开发用代理,生产用 CORS。
六、面试高频问题
Q1:跨域请求到底发出去了吗?
是的。请求已经到达服务器 ,服务器也正常处理并返回了结果。但浏览器在收到响应后,检查
Access-Control-Allow-Origin
头,发现不匹配,于是拦截响应结果,不交给前端 JS。
Q2:JSONP 为什么只支持 GET?
因为
<script src="...">
本质是 GET 请求,无法设置请求体或方法。
Q3:CORS 需要前端做什么?
通常不需要。只要后端配置正确,前端
fetch
或axios
可以照常使用。但若涉及Cookie
,需设置:
jsfetch('/api', { credentials: 'include' // 携带 Cookie })
同时后端必须设置:
httpAccess-Control-Allow-Origin: 具体域名(不能是 *) Access-Control-Allow-Credentials: true
七、总结
方案 | 优点 | 缺点 | 推荐场景 |
---|---|---|---|
JSONP | 兼容老浏览器 | 仅 GET,不安全 | 遗留系统 |
CORS | 标准,功能全 | 需后端配合 | ✅ 主流方案 |
代理 | 前端无感 | 需部署支持 | 开发/特定生产环境 |
一句话总结:现代开发首选 CORS,开发阶段可用代理,JSONP 了解即可。
参考资料
- MDN Web Docs: CORS
- W3C CORS Specification
- Express.js 官方文档