做前端开发的,几乎没有没被跨域坑过的人。某个周三下午,我同事在联调一个管理后台接口,页面一直报Access to fetch ... has been blocked by CORS policy,排查了一下午,最后发现是后端少返回了一个响应头。这种场景你一定不陌生。很多教程上来就丢给你几种解决方案,却没说清楚它们各自适用什么场景,更没说清为什么浏览器要管这么宽。这篇文章从同源策略的根上讲起,把 CORS、JSONP、服务端转发、跨窗口通信依次展开,每个方案都配上能直接复制的代码和我在项目里踩过的坑,适合正在联调接口、接手老项目,或者刚开始接触前后端分离的开发者。
1. 跨域问题的本质:同源策略与浏览器限制
1.1 同源的定义与判断规则
所谓同源,指的是协议、域名、端口三者完全一致。只要有一项不同,就属于跨域。一个页面的源由scheme://host:port三部分组成,比如你本地开发时访问http://localhost:8080,这个源就是http+localhost+8080。
| 页面地址 | 请求地址 | 是否同源 | 原因 |
|---|---|---|---|
http://localhost:8080/page | http://localhost:8080/api | 同源 | 协议/域名/端口全一致 |
http://localhost:8080/page | https://localhost:8080/api | 跨域 | 协议不同(http vs https) |
http://localhost:8080/page | http://localhost:9090/api | 跨域 | 端口不同 |
http://localhost:8080/page | http://api.example.com/api | 跨域 | 域名不同 |
http://www.example.com/page | http://example.com/api | 跨域 | 主域名相同但子域不同 |
很多人会把“域名相同”误认为就是同源,忽略端口和协议。实际上http://example.com和https://example.com之间也是跨域,这在混合内容(Mixed Content)场景下经常出问题。比如页面是 HTTPS 部署的,却去请求一个 HTTP 接口,浏览器不仅拦截响应,还可能直接阻止请求发出。
1.2 为什么浏览器要限制跨域
要理解跨域方案,先得知道浏览器在防什么。如果没有同源策略,你登录了某银行的网上银行系统,又在另一个标签页打开了一个恶意站点,这个恶意站点就能悄悄向银行接口发起转账请求。因为浏览器会自动携带 Cookie,银行服务端一看会话有效,请求就通过了。这就是经典的 CSRF 攻击场景。
同源策略是浏览器安全模型的核心。它默认规定:一个源加载的脚本,不能随意访问另一个源的资源或数据。这个“不能随意访问”体现在很多层面,最常见的有:
- 通过
XMLHttpRequest或fetch发起跨域请求时,响应数据会被拦截; - 跨域访问 iframe 里的 DOM 会被拒绝;
- 跨域读取 Cookie、localStorage 会被拒绝;
- 跨域调用 Web Storage、IndexedDB 等也会受限。
关键认知是:跨域拦截不等于请求发不出去。对于某些简单请求,浏览器实际上已经把请求发出去了,服务端也正常处理了,但浏览器在拿到响应后,发现自己没有权限把数据交给脚本,所以才报 CORS 错误。这是很多初学者最困惑的地方——明明后端日志里看到了请求,前端却拿不到数据。
1.3 简单请求、预检请求与 CORS 协议的引入
既然浏览器要限制,那合理的业务需求怎么满足?W3C 制定了 CORS(跨源资源共享)协议。它本质上是浏览器与服务端之间的一种握手规则:浏览器在发起跨域请求时,先看请求类型。如果是“简单请求”,就直接发出,然后检查响应头;如果是“非简单请求”,会先发一个OPTIONS预检请求,问服务端“我接下来要发的请求你允许吗”,服务端答复允许之后,浏览器才正式发出真实请求。
这里的“简单请求”有严格定义:请求方法只能是GET、HEAD、POST,且请求头只能是Accept、Accept-Language、Content-Language、Content-Type中的部分值,同时Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain三者之一。只要超出这个范围,就会触发预检。很多项目里前端一加自定义请求头Authorization就报预检失败,就是因为触发机制变了。
2. CORS:规范化的跨域解决方式
2.1 响应头与请求头的核心字段
CORS 的规则集中在 HTTP 头里。服务端收到跨域请求后,需要在响应中携带特定头字段,浏览器据此判断是否放行。核心响应头有这些:
Access-Control-Allow-Origin:允许哪些源访问,可以是具体某个源,也可以用*表示允许所有源;Access-Control-Allow-Methods:允许的请求方法列表;Access-Control-Allow-Headers:允许的请求头列表;Access-Control-Allow-Credentials:是否允许携带凭据(Cookie 等);Access-Control-Max-Age:预检请求的结果可以缓存多久,单位秒。
如果请求带上了Origin请求头,说明这是一个跨域请求。服务端响应的Access-Control-Allow-Origin必须包含这个 Origin 值,浏览器才愿意把响应交给你。注意,如果Access-Control-Allow-Origin返回的是*,同时你又想让请求携带 Cookie,这是不行的。浏览器有明确要求:当credentials设置为include时,Access-Control-Allow-Origin不能是通配符,必须明确写出具体的源。这是一个非常高频的坑。
2.2 服务端配置实战:Express 中间件写法
对于 Node.js 后端,最直接的方式是写一个中间件统一附加 CORS 响应头。下面这个示例基于框架无关的写法,核心逻辑在返回响应前设置头信息即可:
// node 中间件示例:统一附加 CORS 响应头 const allowCors = (req, res, next) => { // 读取请求来源,动态回填,避免写死 const origin = req.headers.origin; const allowedOrigins = [ 'http://localhost:8080', 'https://admin.example.com' ]; // 仅当请求来源在允许列表内时,才回填 CORS 头 if (origin && allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); // 允许携带 Cookie 时必须显式设置 credentials res.setHeader('Access-Control-Allow-Credentials', 'true'); } // 处理预检请求 if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Max-Age', '86400'); res.status(204).end(); return; } next(); }; app.use(allowCors);里面几个细节值得展开。第一,为什么用allowedOrigins列表而不是直接写*?因为一旦涉及 Cookie 或业务数据授权,通配符很容易被居心不良的页面利用。第二,为什么预检请求直接status(204)结束?因为预检本身不携带业务数据,浏览器只是想知道“允不允许我发真实请求”,返回 204 空响应是合理的做法,也避免重复进入业务路由。第三,Access-Control-Max-Age设成一天(86400 秒),这样同一浏览器后续再发同样结构的请求,就不需要每次都先 OPTIONS 一次,能明显降低接口延迟。
2.3 携带 Cookie 的跨域配置与多源发布问题
如果你的接口需要跨域携带 Cookie,仅仅设置 CORS 头还不够,前端也要配合。用fetch时,要显式加上credentials: 'include':
fetch('https://api.example.com/user/info', { method: 'GET', credentials: 'include' })后端必须同时满足两个条件:Access-Control-Allow-Origin是具体源,且Access-Control-Allow-Credentials为true。两者缺一不可,缺少任何一项,浏览器都会在控制台报错并拒绝把响应交给前端。
还有多源发布问题。有些后端会写死Access-Control-Allow-Origin: *,然后发现带 Cookie 的请求全挂,原因就是对上文的描述。也有后端在网关层做了动态 Origin 回填,但回填逻辑没考虑“多个源同时请求”的情况——其实每个请求只带一个 Origin,所以回填不存在“多个源同时返回”的问题,真正要处理的是“同一个源允许访问多个接口路径”的配置一致性。我见过一个项目,接口 A 允许了源 X,接口 B 没允许,前端调用 A 正常、调用 B 报错,直接怀疑前端代码,最后对全量接口做了一轮校验才定位到是后端漏配。
2.4 静态资源、字体文件的跨域决策
CORS 不只会出现在 XHR 和 fetch 上。页面加载跨域字体(@font-face)、跨域图片用于 canvas 绘制,也一样受同源策略约束。比如你想通过 canvas 处理一张放在 CDN 上的图片,不设置跨域属性,处理时 canvas 会被污染,导出图片时报 tainted canvas 错误。
处理方式是给图片标签加上crossorigin属性,同时要求 CDN 或图片服务返回正确的 CORS 头:
<img crossorigin="anonymous" src="https://img.example.com/poster.png" alt="海报" />这里的anonymous表示发起跨域请求时不携带凭据。如果是字体文件,服务器端同样需要返回Access-Control-Allow-Origin。有些团队只处理了接口跨域,忘了字体和图片,结果页面图标在开发环境显示正常,部署到生产环境全变成方框,就是因为生产域名变了,字体文件跨域了,而 nginx 转发配置里没有给字体文件扩展名附加 CORS 头。
3. JSONP:老系统的兼容方案
3.1 原理:巧妙利用 script 标签不受同源限制
在 CORS 还没普及的年代,JSONP 是前端跨域的主要手段。它利用的原理是:script、img、iframe这些标签发起请求时,不受同源策略限制。于是开发者想到,我动态创建一个script标签,把接口地址和回调函数名拼到 URL 上,服务端解析到callback参数后,返回一段“执行回调函数”的 JavaScript 代码,而不是普通的 JSON 数据。
服务端返回的响应体类似这样:
handleData({ "code": 0, "data": { "name": "cross-origin-demo" } })前端拿到这段内容后,浏览器把它当作普通脚本执行,自然就调用了预先定义好的handleData函数,从而拿到了数据。整个过程中,前端没有使用 XHR 或 fetch,因此不存在 CORS 拦截问题。
3.2 手写 JSONP 的完整实现
一个可用的 JSONP 封装代码如下:
// 封装一个简易 JSONP function jsonp(url, params = {}, callbackKey = 'callback') { return new Promise((resolve, reject) => { // 生成全局唯一回调函数名 const cbName = 'jsonp_cb_' + Date.now() + '_' + Math.floor(Math.random() * 10000); // 定义回调函数,挂在全局对象上 window[cbName] = (data) => { // 执行完立即清除,避免全局函数堆积 delete window[cbName]; document.body.removeChild(script); resolve(data); }; // 拼接 URL const queryString = Object.keys(params) .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(params[key])) .join('&'); const script = document.createElement('script'); script.src = url + '?' + queryString + '&' + callbackKey + '=' + cbName; script.onerror = () => { delete window[cbName]; document.body.removeChild(script); reject(new Error('JSONP 请求失败')); }; document.body.appendChild(script); }); } // 使用示例 jsonp('https://api.example.com/stock/info', { code: '000001' }) .then((data) => { console.log('拿到数据:', data); }) .catch((err) => { console.error('请求出错:', err); });封装里有两个容易被忽略的点。一是回调函数名必须全局唯一,否则两个请求同时进行时会互相覆盖。二是请求结束后要移除script标签并删除全局函数,否则页面里的脚本标签越来越多,全局变量也被污染。
3.3 适用场景与局限
JSONP 的局限非常明显:只能发送 GET 请求,参数都暴露在 URL 上;服务端必须配合返回可执行的 JavaScript;如果你请求的接口是第三方提供的,对方不一定支持callback参数。另外,JSONP 没有标准的状态码错误处理,网络异常时只能靠script标签的onerror捕获,而且很多时候浏览器并不会触发这个事件,需要自己加超时逻辑。
即便如此,仍有一些老系统、金融数据供应商、统计平台在用 JSONP。遇到这种接口,我的建议是封装一个轻量工具(类似上面这段代码),不要在每个页面里都裸写一遍。同时务必要给这类请求加超时,避免接口无响应时页面一直处于 loading 状态。
3.4 JSONP 与 CORS 的对比选择
| 维度 | JSONP | CORS |
|---|---|---|
| 请求方法支持 | 仅 GET | 任意 HTTP 方法 |
| 错误处理 | 弱,需额外封装 | 浏览器原生支持 |
| 服务端要求 | 必须返回可执行 JS | 配置响应头即可 |
| 安全性 | 依赖回调函数命名,易被注入 | 协议标准,更可控 |
| 适用场景 | 老系统、第三方开放数据接口 | 前后端分离的标准做法 |
如果你在对接一个由自己团队维护的后端,请优先使用 CORS。JSONP 更像是“在没有更好方案时的妥协选择”,不要因为它实现起来简单就盲目采用,尤其在新项目中,JSONP 往往带来更多的安全与调试负担。
4. 服务端转发:开发与生产环境的标准做法
4.1 开发环境:用构建工具转发请求
前后端分离开发时,前端跑在localhost:8080,后端可能跑在localhost:9090或某个远程测试环境。如果直接用 fetch 请求远程地址,必然跨域。这时除了让后端开 CORS,更常见的做法是把请求转发到后端,让浏览器始终只访问前端同源的地址。
以典型的构建工具为例,配置文件里可以这样设置:
// 构建工具本地转发配置 module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };这里的逻辑是:前端页面请求/api/user/list时,本地开发服务器收到请求后,作为中转方向http://localhost:9090再发一次真实请求。浏览器看到的请求地址始终是http://localhost:8080/api/user/list,属于同源,所以不会产生跨域拦截。changeOrigin的作用是将请求头里的 Host 字段改成目标地址的 Host,有些后端校验 Host 时必须要配。pathRewrite的作用是去掉/api前缀,具体去不去取决于后端接口是否自带/api路径,我建议实测时抓一下后端实际路由再决定。
4.2 生产环境:同源入口服务与路径转发
开发环境转发解决了本地调试问题,生产环境则是另一套逻辑。通常前端静态资源部署在入口服务上,接口请求也通过同一个入口服务转发到后端服务。这样浏览器请求的域名和静态页面域名完全一致,从根源上避开了跨域。
一个典型的入口服务配置片段如下:
# 入口服务配置:同源转发 server { listen 80; server_name www.example.com; # 前端静态资源 location / { root /data/www/frontend; index index.html; try_files $uri $uri/ /index.html; } # 接口转发 location /api/ { proxy_pass http://backend-service:9090/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里最需要注意的坑是proxy_pass结尾的/。如果写成http://backend-service:9090,请求/api/user/list会被原样转发为/api/user/list;如果写成http://backend-service:9090/(带了斜杠),转发时会去掉匹配的前缀部分,变成/user/list。前后端如果对路径约定不清晰,这个细节能让人排查半天。
实际部署中,后端服务可能内网访问,前端和接口共用对外域名。这种结构下,Cookie 的作用域也更容易管理,因为页面和接口都是同一个域,不会出现跨域 Cookie 被拒的麻烦。所以当你负责一个新项目的架构设计时,优先考虑“前端 + 入口服务 + 后端”的三层结构,而不是前端直接跨域调用后端接口,这是我在几个项目里对比之后最省心的方案。
4.3 转发方案与 CORS 方案的取舍逻辑
转发和 CORS 并不互斥,它们解决的是不同层级的问题。转发解决“请求怎么从浏览器走同源路径到后端”,CORS 解决“浏览器如何放行跨域响应”。我的建议是:
- 团队内部前后端联调、项目处于快速迭代期:优先用开发转发,减少后端配置负担,前端体验好;
- 后端接口要开放给多个不同域名的前端(比如 PC 站点、H5、小程序管理后台):用 CORS 配置白名单,统一管理;
- 生产环境有条件控制入口:用同源转发,配合少量 CORS 作为补充。
不过也要提醒一点,转发方案只是让“浏览器视角”看起来同源,实际请求还是在中转层完成握手。如果中转层配置不当,比如鉴权信息没有传递、路径重写错误、超时时间过短,都会暴露新的问题。上线前必须完整走一遍从浏览器到中转层再到后端的链路,逐个节点抓包验证。
4.4 本地测试与线上多环境的联动
本地转发配置往往要应对多个环境,比如测试环境地址、预发布环境地址。我的做法是把目标地址提取成环境变量,不同环境启动时读取不同配置:
const apiTarget = process.env.API_TARGET || 'http://default-backend:9090'; module.exports = { devServer: { proxy: { '/api': { target: apiTarget, changeOrigin: true } } } };这样本地、测试、预发之间切换只改环境变量,不用反复改代码。如果团队用了云端开发环境,甚至可以通过平台自身的全局配置注入,连启动命令都不用改。这个工作流一旦跑通,联调效率能提升不少。
5. 跨域场景扩展:iframe、WebSocket 与 postMessage
5.1 iframe 嵌套与 document.domain 方案
跨域限制同样作用于 iframe。如果你的页面里嵌入了另一个域的页面,且想要操作对方窗口里的 DOM,默认是被拒绝的。有一种传统方案是通过document.domain把两个页面的域名改成相同主域,前提是两个页面都属于同一个主域,比如a.example.com和b.example.com。
// 在 a.example.com 的页面里 document.domain = 'example.com'; // 在 b.example.com 的页面里 document.domain = 'example.com';设置之后两个页面就互相视为同源,可以相互访问对方的 DOM 和 Cookie。但这个方案的限制也很硬:两个页面必须同主域,且设置document.domain会改变页面的源,引发其他安全行为,近年来浏览器也在限制它的使用场景。如果你的页面之间只是需要通信,而不是直接操作 DOM,建议使用下面的postMessage,更现代也更安全。
5.2 WebSocket 与跨域的关系
WebSocket 连接的建立不受同源策略限制,浏览器在发起 WebSocket 握手时携带Origin头,服务端可以据此判断要不要接受连接。也就是说,跨域本身不会阻止 WebSocket 建立连接,但它把“是否允许”的决策权交给了服务端。
// 前端建立跨域 WebSocket 连接 const ws = new WebSocket('wss://push.example.com/socket'); ws.onopen = () => { ws.send(JSON.stringify({ type: 'subscribe', channel: 'notify' })); }; ws.onmessage = (event) => { console.log('收到消息:', event.data); };这里服务端要做的就是校验Origin,不是把所有来源都放行。有些人误以为 WebSocket 完全不受安全机制约束,于是不校验来自哪个页面,结果被第三方页面恶意建立大量连接。我的建议是:服务端建立连接前,读取握手请求的Origin字段,不在白名单内就直接拒绝。
5.3 postMessage:跨窗口跨 iframe 通信的标准方式
postMessage是 HTML5 提供的跨文档通信 API,允许不同源的窗口之间安全地传递消息。它不会像document.domain那样改变源,也不像 CORS 那样需要服务端配合。典型场景是父页面与嵌套的第三方 iframe 通信。
// 父页面发送消息给 iframe const childFrame = document.getElementById('childFrame'); childFrame.contentWindow.postMessage( { action: 'updateTheme', payload: 'dark' }, 'https://child.example.com' ); // 父页面接收 iframe 回传的消息 window.addEventListener('message', (event) => { // 一定要校验来源,不能省略 if (event.origin !== 'https://child.example.com') return; console.log('收到 iframe 消息:', event.data); });在 iframe 内部,接收消息也要做同源校验:
// iframe 页面里监听消息 window.addEventListener('message', (event) => { if (event.origin !== 'https://parent.example.com') return; // 处理消息 event.source.postMessage({ received: true }, event.origin); });postMessage用得最多的地方是第三方支付页面、地图组件、聊天窗口这类需要与宿主页交互的场景。核心红线是:接收到消息时,必须校验event.origin,否则任意页面都能往你这个窗口发消息,轻则功能被干扰,重则数据被窃取。我在一个项目里见过忘记校验来源的情况,结果客服聊天窗口被刷了一堆伪造指令,排查半天才知道是消息来源没过滤。
6. 现场排查流程与避坑经验
6.1 先从 Network 面板确定问题阶段
跨域问题千变万化,但排查路径是固定的。第一步永远是打开开发者工具的 Network 面板,刷新页面,过滤出报错的那条请求。重点看两件事:
- 请求是否发出来了?如果请求在 Network 里根本不存在,说明请求被前端代码或浏览器策略阻止了,比如混合内容、请求地址拼接错误;
- 请求有响应但状态异常?如果请求状态是 200,但控制台报了 CORS 错误,说明请求已经到达服务端并返回了,只是响应头不符合 CORS 规则,浏览器不交出数据。
这两种情况的处理完全不同。前者要查前端代码,后者要找后端或入口配置。很多人上来就凭感觉改前端,结果浪费大量时间。我记得有一次项目排查,前端坚持认为是同源策略问题,后端坚持说响应头齐全,最后把响应头截图拉出来才发现:Access-Control-Allow-Origin返回的是*,但代码里同时设置了credentials: include,这个组合浏览器直接拒绝,跟响应头缺没缺是两码事。
6.2 高频问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 报 CORS but 请求显示 200 | 响应头缺失或 Origin 不匹配 | 抓响应头,检查 Allow-Origin 是否包含当前源 |
| 预检请求 OPTIONS 返回 404/5xx | 后端未处理 OPTIONS 请求 | 中间件或路由层统一放行预检,返回 204 |
| 带 Cookie 的跨域请求失败 | Allow-Origin 为*或未设置 Allow-Credentials | 改为动态 Origin 回填,并设置 credentials 为 true |
| 自定义请求头导致预检失败 | 后端 Allow-Headers 未包含该头 | 后端将 Authorization 等自定义头加入允许列表 |
| 生产环境偶发跨域,本地正常 | 入口服务与后端路径不一致 | 检查转发路径重写、Host 头、域名白名单 |
| 字体或图片加载正常但 canvas 报错 | 资源 CORS 头缺失 | 给静态资源附加 CORS 头,并给标签加 crossorigin |
| 页面报 localStorage 无法访问 | 跨域 iframe 读取本地存储 | 改用 postMessage 通信,由父页面代理存储 |
这张表是我在多个项目里总结出来的高频活,贴到团队文档里很有用。排错的时候先在表里找现象,命中率不低。
6.3 联调阶段的三个好习惯
第一,接口文档里必须写明 CORS 策略。允许哪些域名、是否携带 Cookie、允许哪些方法、自定义头都有哪些。很多跨域问题不是技术问题,是文档没写清楚,前后端各做各的。
第二,后端同学调试 CORS 配置时,不要只看“浏览器能访问”,要看“非预检请求的响应头正确情况下,预检请求是否也正确”。这两个环节独立配置,容易漏。
第三,联调现场至少保留一张“失败响应头截图”。很多人报错只贴控制台文字,但真正有用的信息全在响应头里。养成截图习惯,能节省大量沟通成本。
6.4 不建议的做法与风险提示
网上流传着一些“临时关闭浏览器安全策略”“使用某些浏览器启动参数绕过跨域”的做法。这东西在本地调试某些纯前端 Demo 时确实能快速看到效果,但我强烈不建议你在真实项目里用。原因有三个:
- 它会让你看不到真实的 CORS 错误,误以为代码没问题;
- 关闭安全策略后,页面可以任意访问跨域资源,如果此时你在页面上登录过任何系统,风险极高;
- 你无法保证所有同事和测试环境都用了同样的方式,等于把问题藏到了上线前最后一刻。
正确做法永远是让后端补响应头,或者用开发转发。项目再急,也不要靠“关防火墙验伤”来混过去。
我在实际项目里最后留下的经验是:大多数跨域疑难杂症都不是什么魔法,而是团队协作时的契约没有对齐。接口文档里写没写允许的源?后端有没有正确处理预检请求?Cookie 的 SameSite 属性配没配?这些细枝末节,往往比你在前端写的任何代码都关键。所以当你下次再看到 CORS 报错时,别急着改前端代码,先打开 Network 面板,把红色报错里的响应头截图发到联调群里,大概率对方一看就明白问题了。