谈谈前端跨域问题及解决方法

浏览器出于安全考虑实施同源策略,会限制不同源之间的资源交互,由此产生跨域问题。

什么是跨域

广义上的跨域,是指一个域下的文档、脚本去访问另一个域的资源,包含三类场景:

  • 资源跳转<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.comwww.b.taobao.com同站,eTLD+1 都是 taobao.com
  • a.github.iob.github.io跨站github.io 本身是公共后缀,二者 eTLD+1 分别是 a.github.iob.github.io

注意:Cookie 的 SameSite 属性使用「同站」规则;而 Ajax、DOM 访问、LocalStorage 遵循严格的「同源」规则,二者不要混淆。

同源策略

同源策略(Same-Origin Policy) 是浏览器核心安全机制,1995 年由 Netscape 引入,所有现代浏览器均强制执行。

目的:阻止恶意网站读取其他网站的隐私数据,防范 CSRF、信息窃取类安全风险。

非同源情况下,以下三类行为会被浏览器限制:

  • 无法读取 CookieLocalStorageIndexedDB
  • 无法直接访问对方 DOM(例如跨域 iframe.contentWindow.document);
  • Ajax、Fetch 请求可以发出,但浏览器拦截 JS 获取响应结果。

允许加载但禁止读取内容的标签

  • <img src=""><video src=""><audio src="">
  • <link href=""><script src=""><iframe src="">

行为限制

  • 跨域 <script> 加载的脚本会正常执行,但原始脚本的语法错误信息,不会被当前页面捕获;
  • @font-face 跨域字体,部分浏览器会施加访问限制;
  • JS 访问跨域 windowlocation 对象,权限被严格收缩:
    • 可调用方法:window.blur()window.close()window.focus()window.postMessage()
    • 只读属性:window.closedwindow.frameswindow.lengthwindow.openerwindow.parentwindow.selfwindow.topwindow.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「预检请求」。

适用场景:XMLHttpRequestFetch、WebGL 贴图、drawImage 绘制跨域图片、@font-face 字体资源。

简单请求 & 非简单请求

同时满足全部条件才是简单请求:

  • 请求方法:GETHEADPOST
  • 手动设置的请求头属于安全集合:Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type;
  • Content-Type 只允许:application/x-www-form-urlencodedmultipart/form-datatext/plain
  • XHR.upload 没有注册事件监听;不使用 ReadableStream 对象。

只要不满足上面任意一条,就是非简单请求。浏览器会先发 OPTIONS 预检请求,向服务器确认是否允许跨域,预检通过才发送真实业务请求。

预检请求目的:给老旧服务一个机会拒绝危险请求,避免服务器收到大量 PUTDELETE 这类浏览器原生表单无法发出的请求。

关键 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 请求。

设置允许跨域:

  • 前端: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.contentWindowwindow.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 对重定向有限制。
  • 前端没有开启凭证标记: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

参考资料

MDN 浏览器的同源策略

MDN CORS

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-08-22 02:31:05

results matching ""

    No results matching ""