谈谈前端跨域问题及解决方法
浏览器出于安全考虑实施同源策略,会限制不同源之间的资源交互,由此产生跨域问题。
什么是跨域
广义上的跨域,是指一个域下的文档、脚本去访问另一个域的资源,包含三类场景:
- 资源跳转:
<a>标签、HTTP 重定向、表单提交;这类行为浏览器默认允许执行。 - 资源嵌入:
<link>、<script>、<img>、<iframe>,CSS 的url()、@font-face外链文件。 - 脚本发起交互:JS 执行 Ajax、Fetch 请求、操作跨域
iframe的 DOM、访问其他窗口对象;这也是我们日常开发说的狭义跨域问题,会被同源策略拦截。
备注:跨域不是服务器收不到请求。很多情况下请求已经抵达后端、后端正常返回数据,只是浏览器收到响应后做拦截,不让 JS 拿到返回内容。
同源与同站
同源(Same-Origin)
判断同源看三个要素:协议、域名、端口,三者必须全部一致才属于同源。
| URLA | URLB | 结果 | 说明 |
|---|---|---|---|
http://a.com/b1/c.html |
http://a.com/b2/c.html |
同源 | 协议、域名、端口一致,仅路径不同 |
http://a.com:80 |
http://a.com |
同源 | HTTP 默认端口为 80,可以省略不写 |
http://a.com/b1 |
https://a.com/b2 |
跨域 | 协议不同 |
http://a.com |
http://www.a.com |
跨域 | 域名不同 |
http://a.com |
http://a.com:8080 |
跨域 | 端口不同 |
IE 特殊行为:旧版 IE 在高信任内部域名场景,不完全校验端口,同协议同域名不同端口也视为同源,属于浏览器历史兼容特性。
同站(Same-Site)
同站判断标准更加宽松:只对比 eTLD+1(有效顶级域名 + 二级域名),忽略协议、端口。
eTLD(有效顶级域名)来自 Mozilla 公共后缀列表,例如 .com、.co.uk、.github.io。
示例:
www.a.taobao.com与www.b.taobao.com:同站,eTLD+1 都是taobao.com;a.github.io与b.github.io:跨站,github.io本身是公共后缀,二者 eTLD+1 分别是a.github.io、b.github.io。
注意:Cookie 的 SameSite 属性使用「同站」规则;而 Ajax、DOM 访问、LocalStorage 遵循严格的「同源」规则,二者不要混淆。
同源策略
同源策略(Same-Origin Policy) 是浏览器核心安全机制,1995 年由 Netscape 引入,所有现代浏览器均强制执行。
目的:阻止恶意网站读取其他网站的隐私数据,防范 CSRF、信息窃取类安全风险。
非同源情况下,以下三类行为会被浏览器限制:
- 无法读取
Cookie、LocalStorage、IndexedDB; - 无法直接访问对方 DOM(例如跨域
iframe.contentWindow.document); - Ajax、Fetch 请求可以发出,但浏览器拦截 JS 获取响应结果。
允许加载但禁止读取内容的标签:
<img src="">、<video src="">、<audio src=""><link href="">、<script src="">、<iframe src="">
行为限制:
- 跨域
<script>加载的脚本会正常执行,但原始脚本的语法错误信息,不会被当前页面捕获; @font-face跨域字体,部分浏览器会施加访问限制;- JS 访问跨域
window、location对象,权限被严格收缩:- 可调用方法:
window.blur()、window.close()、window.focus()、window.postMessage() - 只读属性:
window.closed、window.frames、window.length、window.opener、window.parent、window.self、window.top、window.window - 读写:
window.location(可以赋值跳转,跨域下不能完整读取) - Location:可调用
location.replace();href仅允许写,不允许读。
- 可调用方法:
跨域解决方案
典型跨域场景:
Cookie 跨域:Cookie 遵循同站规则,eTLD+1 一致就可以共享,不强制协议、端口相同。服务端设置 Cookie 时指定
domain,子域名可自动读取。iframe 跨域:
iframe会创建独立的window上下文。同源可以直接读写子页面 DOM;跨域时直接访问 DOM 会抛出安全异常。该限制同样适用于window.open()打开的新窗口。LocalStorage、IndexedDB 跨域:存储类 API 严格遵循同源策略,不同源页面完全隔离存储。
Ajax、Fetch 跨域:浏览器阻止 JS 获取跨域请求的响应,这是日常开发最常遇到的跨域报错。
CORS(推荐)
CORS(Cross-Origin Resource Sharing,跨域资源共享),是一个 W3C 标准,是解决 Ajax、Fetch 跨域的标准方案,需要浏览器 + 后端服务共同支持,IE 需要 ≥ IE10。
核心:浏览器自动处理请求头;主要改动在服务端配置响应头,前端业务代码基本不用改动。当检测到请求跨域,浏览器自动追加 Origin 等请求头;非简单请求会额外发送一次 OPTIONS「预检请求」。
适用场景:XMLHttpRequest、Fetch、WebGL 贴图、drawImage 绘制跨域图片、@font-face 字体资源。
简单请求 & 非简单请求
同时满足全部条件才是简单请求:
- 请求方法:
GET、HEAD、POST; - 手动设置的请求头属于安全集合:Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type;
Content-Type只允许:application/x-www-form-urlencoded、multipart/form-data、text/plain;XHR.upload没有注册事件监听;不使用ReadableStream对象。
只要不满足上面任意一条,就是非简单请求。浏览器会先发 OPTIONS 预检请求,向服务器确认是否允许跨域,预检通过才发送真实业务请求。
预检请求目的:给老旧服务一个机会拒绝危险请求,避免服务器收到大量 PUT、DELETE 这类浏览器原生表单无法发出的请求。
关键 HTTP 头
请求头(浏览器自动添加,前端不要手动设置):
Origin:标记请求来源;Access-Control-Request-Method:预检专用,告知服务器真实请求使用的 HTTP 方法;Access-Control-Request-Headers:预检专用,告知服务器真实请求携带的自定义请求头。
服务端响应头(后端配置):
Access-Control-Allow-Origin: <origin> | *:允许访问的源;带凭证请求不能使用*,必须写具体域名。Access-Control-Allow-Methods:允许的请求方法,用于预检返回;Access-Control-Allow-Headers:允许的自定义请求头,用于预检返回;Access-Control-Allow-Credentials: true:是否允许携带 Cookie、HTTP 凭证;Access-Control-Expose-Headers:JS 通过getResponseHeader()可以读取到的自定义响应头;浏览器默认只开放少量基础响应头;Access-Control-Max-Age:预检结果缓存的秒数,减少重复 OPTIONS 请求。
携带 Cookie 凭证
设置允许跨域:
- 前端:XHR 设置
xhr.withCredentials = true;Fetch 设置credentials: 'include'; - 后端:设置
Access-Control-Allow-Credentials: true; - 硬性约束:此时响应头
Access-Control-Allow-Origin禁止为*,必须填写确切源站; - Cookie 依旧遵循 Cookie 自身域、SameSite 规则,不会无条件跨域发送。
备注:OPTIONS 预检请求不会携带 Cookie;只有后续真实业务请求才携带凭证。
XHR 完整示例:
const xhr = new XMLHttpRequest();
xhr.open('GET','http://api.example.com/data',true);
xhr.withCredentials = true;
xhr.onload = () => {};
xhr.onerror = (err) => {};
xhr.send();
Fetch 完整示例:
fetch('http://api.example.com/data', {
credentials:'include'
})
.then(res => res.text())
.then(data => console.log(data))
.catch(err => console.error(err));
Node.js 完整示例:
const express = require('express');
const app = express();
app.use((req,res,next)=>{
const origin = req.headers.origin;
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Max-Age', 86400);
if(req.method === 'OPTIONS'){
return res.sendStatus(200);
}
next();
});
app.get('/data',(req,res) => {
res.json({msg:'cors 测试数据'});
});
app.listen(4000,() => {
console.log('服务启动,端口4000');
});
Nginx 反向代理
核心原则:同源策略是浏览器的限制,服务器之间通信不受同源策略约束。
原理:Nginx 作为代理服务器,页面请求与页面同源的 Nginx 地址,Nginx 在服务端根据不同请求路径,把请求转发到真正的目标后端,再把响应返回浏览器。
server {
listen 9700;
server_name localhost;
location /api/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:9800/;
}
}
window.postMessage(跨窗口、iframe 消息通信)
postMessage 是浏览器提供的跨文档通信 API,用于在不同窗口、iframe 之间安全传递数据,突破同源策略限制。
备注:专门解决不同源窗口、iframe 之间消息传递,不能用于 Ajax 请求跨域。
targetWindow.postMessage(message, targetOrigin, [transfer])
targetWindow:目标窗口对象,iframe.contentWindow、window.open()返回对象;message:要发送的数据,会做结构化克隆算法(Structured Clone Algorithm)拷贝;支持对象、数组、字符串、数字;不能传函数、DOM 对象。targetOrigin:安全关键参数,指定允许接收消息的源。'*':不做源校验,允许任意来源(不推荐,有安全风险);'https://a.com':只允许该域名接收消息;
transfer:可选,转移对象所有权(Transferable 对象,如 ArrayBuffer),发送之后发送方失去该对象使用权,多用于二进制大数据,不是深拷贝。
父页面 parent.html:
<!DOCTYPE html>
<html>
<body>
<iframe id="frame" src="https://localhost:5200/child.html"></iframe>
<script>
const iframe = document.getElementById('frame');
const targetOrigin = 'https://localhost:5200';
iframe.onload = () => {
iframe.contentWindow.postMessage('来自父页面的消息', targetOrigin);
};
window.addEventListener('message', e => {
if(e.origin === 'https://localhost:5200') {
console.log('父页面收到消息:',e.data);
};
});
</script>
</body>
</html>
子页面 child.html:
<!DOCTYPE html>
<html>
<body>
<script>
window.addEventListener('message', e => {
if(e.origin === 'https://localhost:3000') {
console.log('子页面收到消息:', e.data);
e.source.postMessage('回复消息',e.origin);
};
});
</script>
</body>
</html>
Node.js 中间代理
原理:使用 Node 服务充当跳板,转发浏览器请求至目标接口。
index.html 前端服务器:
<!DOCTYPE html>
<html lang="zh-CN">
<body>
<script>
const xhr = new XMLHttpRequest();
xhr.open('post', 'http://localhost:3000/', true);
xhr.withCredentials = true;
xhr.onload = () => {
console.log('代理返回结果:',xhr.responseText);
};
xhr.send(null);
</script>
</body>
</html>
proxy-server.js 代理服务器:
const http = require('http');
const server = http.createServer((request, response) => {
response.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': 'Content-Type'
});
const proxyRequest = http.request({
host: '127.0.0.1',
port: 4000,
path: '/',
method: request.method,
headers: request.headers
}, serverResponse => {
let body = '';
serverResponse.on('data', chunk => body += chunk);
serverResponse.on('end', () => response.end(body));
}).end();
});
server.listen(3000, () => console.log('代理服务器:http://localhost:3000'));
target-server.js 目标业务服务器:
const http = require('http');
const data = { title: 'frontend', password: '123456' };
const server = http.createServer((request, response) => {
if (request.url === '/') {
response.end(JSON.stringify(data));
}
});
server.listen(4000, () => console.log('目标服务器:http://localhost:4000'));
WebSocket
核心规则:ws://、wss:// 协议不受 HTTP 同源策略约束,可以跨域双向通信,适合即时通讯类场景。
备注:WebSocket 自身有 Origin 校验,服务端依旧建议做来源白名单校验,防范恶意网站连接。
<!DOCTYPE html>
<html>
<body>
<script>
const socket = new WebSocket('ws://localhost:3000');
socket.onopen = function () {
socket.send('Hello WebSockets!');
};
socket.onmessage = (e) => {
console.log(`收到服务端消息:${e.data}`);
};
</script>
</body>
</html>
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 3000 });
wss.on("connection", (ws) => {
ws.on("message", (data) => {
console.log('收到客户端消息:', data);
ws.send("Hello Client!");
});
});
JSONP(历史兼容,不推荐)
核心原理:利用 <script src> 标签不受同源限制的特性实现 GET 跨域。
<!DOCTYPE html>
<html>
<body>
<script>
function loadScriptFile (src) {
const script = document.createElement('script');
script.setAttribute("type","text/javascript");
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
loadScriptFile('http://localhost:4000/jsonp?callback=foo');
};
// 全局回调函数,后端调用
function foo(data) {
console.log('JSONP 接收数据: ', data.ip);
}
</script>
</body>
</html>
const http = require('http');
const server = http.createServer((req,res) => {
const url = new URL(req.url,`http://${req.headers.host}`);
const cb = url.searchParams.get('callback');
if(cb){
const ret = {ip:'127.0.0.1'};
res.end(`${cb}(${JSON.stringify(ret)})`);
}
});
server.listen(4000);
webpack-dev-server(仅开发环境)
开发环境,启用 webpack 的代理功能。
注意:只用于本地开发。打包上线后该配置完全失效。
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://api.example.com',
pathRewrite: {'^/api': ''},
changeOrigin: true,
secure: false
}
}
}
}
window.name + iframe(老旧技巧,不推荐)
核心原理:同一个窗口、iframe 上下文,页面跳转之后,window.name 的值依旧保留,最大支持约 2MB 数据。
完整执行流程:
http://a.com/parent.html:父页面。创建隐藏 iframe,做整个逻辑调度,最终拿到数据。http://b.com/child.html:跨域子页面。把要传递的数据赋值给window.name。http://a.com/proxy.html:代理页面,与父页面同源。仅用来切换源,不需要写逻辑。
实现步骤:
- 父页面动态创建隐藏 iframe,
src指向跨域子页面; - iframe 加载完成,跨域子页面执行脚本,把数据写入
window.name; - iframe 内部跳转至同源代理页面;
- 跳转回同源页面后,父页面可以安全读取
iframe.contentWindow.name,拿到跨域子页面存入的数据。
浏览器限制:部分现代浏览器已经修改规范,跨站导航会清空 window.name。
父页面 parent.html:
<!DOCTYPE html>
<meta charset="UTF-8">
<body>
<script>
function getNameCrossOrigin(url, callback) {
let state = 0;
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
iframe.onload = function () {
if (state === 0) {
iframe.contentWindow.location = 'http://a.com/proxy.html';
state = 1;
} else if (state === 1) {
const data = iframe.contentWindow.name;
callback(data);
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
};
document.body.appendChild(iframe);
}
getNameCrossOrigin('http://b.com/child.html', (res) => {
console.log('拿到跨域返回的数据:', res);
});
</script>
</body>
</html>
跨域子页面 child.html:
<!DOCTYPE html>
<meta charset="UTF-8">
<script>
const data = JSON.stringify({
username: 'test',
msg: '来自跨域页面的数据'
});
window.name = data;
</script>
父页面同源的代理页面 proxy.html:
<!DOCTYPE html>
<html>
<body></body>
</html>
location.hash + iframe(老旧技巧,不推荐)
原理:URL # 后面的 hash 改变,页面不会刷新。利用 hash 在父子 iframe 传递字符串数据,监听 hashchange 事件接收变更。
父页面 parent.html:
<!DOCTYPE html>
<html>
<body>
<iframe id="frame" src="http://localhost:5300/child.html#helloA"></iframe>
<script>
window.onhashchange = () => {
console.log('父页面 hash 变更', location.hash);
};
</script>
</body>
</html>
子页面 child.html:
<!DOCTYPE html>
<html>
<body>
<script>
console.log('子页面获取 hash', location.hash);
window.parent.location.hash = '#helloB';
</script>
</body>
</html>
常见问题排查
CORS 跨域报错
常见原因:
- 响应头
Access-Control-Allow-Origin和请求 Origin 不匹配; - 后端没有正确处理
OPTIONS预检请求; - 自定义请求头没有在
Access-Control-Allow-Headers放行; - 预检请求后发生重定向;
- CORS 对重定向有限制。
跨域请求无法携带 Cookie
- 前端没有开启凭证标记:XHR 设置
withCredentials=true,Fetch 设置credentials: "include"; - 后端没有返回
Access-Control-Allow-Credentials: true; - 返回头
Access-Control-Allow-Origin设置为*(带凭证场景禁止使用通配符); - Mock.js 等第三方包拦截了请求;
- Cookie 的
SameSite属性、浏览器第三方 Cookie 策略拦截。
Canvas 绘制跨域图片报错
使用 getImageData()、toDataURL() 读取画布像素,如果图片来自跨域,浏览器会污染画布抛出异常。
方式一:img 设置 crossOrigin
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
context.drawImage(this, 0, 0);
const res = context.getImageData(0, 0, this.width, this.height);
console.log(res);
};
img.src = 'https://example.com/img/test.jpg';
方式二:通过 Ajax 拉取图片 blob
const xhr = new XMLHttpRequest();
xhr.open('GET','https://example.com/img/test.jpg',true);
xhr.responseType = 'blob';
xhr.onload = function () {
const url = URL.createObjectURL(this.response);
const img = new Image();
img.onload = function () {
URL.revokeObjectURL(url);
};
img.src = url;
};
xhr.send();
总结
跨域问题的根源在于浏览器的同源策略:它限制的是前端脚本对跨源资源的读取与交互,而非服务器能否收到请求。日常开发里,先弄清限制落在哪类资源、哪一步交互上,再选用服务端可控或浏览器原生提供的合规方案,比在前端硬绕限制更稳妥。
最佳实践:接口跨域优先采用 CORS 或 Nginx 反向代理;iframe、多窗口页面通信优先使用 postMessage。