资讯动态

跨域问题全解析:从同源策略到CORS、JSONP与服务端转发

发布时间:2026/10/18 6:13:48 来源:尧图企业网站定制
做前端开发的几乎没有没被跨域坑过的人。某个周三下午我同事在联调一个管理后台接口页面一直报Access to fetch ... has been blocked by CORS policy排查了一下午最后发现是后端少返回了一个响应头。这种场景你一定不陌生。很多教程上来就丢给你几种解决方案却没说清楚它们各自适用什么场景更没说清为什么浏览器要管这么宽。这篇文章从同源策略的根上讲起把 CORS、JSONP、服务端转发、跨窗口通信依次展开每个方案都配上能直接复制的代码和我在项目里踩过的坑适合正在联调接口、接手老项目或者刚开始接触前后端分离的开发者。1. 跨域问题的本质同源策略与浏览器限制1.1 同源的定义与判断规则所谓同源指的是协议、域名、端口三者完全一致。只要有一项不同就属于跨域。一个页面的源由scheme://host:port三部分组成比如你本地开发时访问http://localhost:8080这个源就是httplocalhost8080。页面地址请求地址是否同源原因http://localhost:8080/pagehttp://localhost:8080/api同源协议/域名/端口全一致http://localhost:8080/pagehttps://localhost:8080/api跨域协议不同http vs httpshttp://localhost:8080/pagehttp://localhost:9090/api跨域端口不同http://localhost:8080/pagehttp://api.example.com/api跨域域名不同http://www.example.com/pagehttp://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: includefetch(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 crossoriginanonymous srchttps://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 的对比选择维度JSONPCORS请求方法支持仅 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 与 postMessage5.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 面板把红色报错里的响应头截图发到联调群里大概率对方一看就明白问题了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑