1. 跨域问题到底是怎么出现的
先直接说结论:调用后端接口报跨域,不是你代码写得不对,而是浏览器出于安全策略主动拦截了响应。后端接口本身可能返回了正常数据,但浏览器拿到之后发现“这个响应和我当前页面不在同一个源”,直接丢掉了,然后在控制台给你抛一个红色的报错。这是Web开发里最容易让人血压升高的报错之一,尤其是前后端分离的项目,第一次联调接口的时候十有八九会撞上。
要理解跨域,先得知道浏览器的“同源策略”。所谓同源,指的是协议(Protocol)、域名(Host)、端口(Port)三者完全一致。只要有一个不一样,浏览器就会判定为跨域。举个例子,你的前端页面跑在http://localhost:8080,后端接口跑在http://localhost:9090,端口不一样,这就跨域了。更别说前端在https://admin.example.com,后端在https://api.example.com,域名不一样,同样跨域。
很多人第一次遇到这个问题时会觉得莫名其妙:明明用Postman调接口返回数据很正常,怎么放到页面上就报跨域?原因就在于Postman这类HTTP客户端没有实现同源策略,它发请求、接响应都是原原本本的,而浏览器会多做一个“安全检查”。这个安全检查不是针对请求本身,关键是针对响应。也就是说,请求可能已经发出去了,后端也可能处理了,但浏览器不让页面拿到返回的数据。
这种机制的初衷是保护用户:如果没有同源策略,你在A网站打开的页面就可以随意请求B网站的接口,读取B网站的登录态和数据,那整个互联网的账号体系就全乱套了。理解这一层之后,你再看跨域解决方案,思路就清晰了——所有方案的本质,都是想办法让浏览器认为这个响应是“安全”的。
在实际开发环境里,最常见的情况就是前后端分离。前端工程用Vite或Webpack起一个本地开发服务器,后端是独立的Spring Boot或者Nginx代理的网关服务,两边端口不同、域名不同,跨域几乎是必然发生的。生产环境虽然通常会通过Nginx做反向代理把接口和页面收敛到同一个域名下,但开发阶段、以及接口被第三方系统直接调用的场景,跨域还是绕不开。所以这个问题不是偶发的小毛病,而是前后端开发者必须掌握的基础功。
2. 主流的跨域解决方案选型
网上关于跨域解决方案的文章很多,但大多数只讲了怎么配置,没讲为什么选这个方案。我先把主流的方案列一个对比表,然后针对不同的项目场景说明怎么选。
| 方案 | 实现位置 | 是否需要后端配合 | 支持请求类型 | 典型场景 |
|---|---|---|---|---|
| CORS(跨域资源共享) | 后端加响应头 | 必须 | 所有HTTP方法 | 前后端分离、对外开放API |
| Nginx反向代理 | 网关层配置 | 不需要改代码 | 所有HTTP方法 | 生产环境收敛域名 |
| 开发代理(Vite/Webpack proxy) | 前端开发服务器 | 不需要 | 所有HTTP方法 | 本地联调 |
| JSONP | 前端动态script标签 | 必须 | 仅GET | 老系统兼容、第三方接口 |
| WebSocket | 协议天然不受同源限制 | 不需要特殊处理 | 双向通信 | 实时消息推送 |
| Fiddler代理转发 | 本地调试工具 | 不需要改代码 | 所有HTTP方法 | 调试第三方接口、验证响应头 |
这几种方案里,CORS是目前最正规、最通用的做法,也是后端开发者最常被问到的问题。它的核心思想是:浏览器发请求时带上Origin头(表示当前页面来源),后端在响应里通过Access-Control-Allow-Origin这个响应头告诉浏览器“这个来源的页面可以拿我的数据”。浏览器拿到响应头一看,哦,允许的,那就放行。就这么简单。
Nginx反向代理则是一项绕过机制:因为同源策略是浏览器限制的,所以如果前端页面和接口在浏览器看来是同一个域名,那就根本不存在跨域。做法是让Nginx监听某个独立域名(比如https://api.example.com),然后把所有进入这个域名的请求转发到真正的后端服务上。对外暴露的是统一的域名,后端细节全被隐藏了。
开发代理的原理和Nginx相似,但只存在于本地开发环境。Vite、Webpack这类开发服务器内置了代理功能,你请求/api开头的路径,开发服务器会帮你转发到目标后端地址,浏览器的视角里请求始终只发给了当前站点,跨域自然不成立。
JSONP是个老古董了,它的原理是用<script>标签加载外部资源不受同源策略限制的特性,把接口数据塞进一个JavaScript回调函数里返回。但这个方案只能支持GET请求,而且有安全隐患,新项目我基本不推荐。除非是接第三方老系统的数据,人家只提供JSONP接口,那没办法。
Fiddler代理配置跨域这个方案有点特殊,它属于“前端自己搞定”的一招。你本地装一个Fiddler,设置一个代理转发规则,把请求先打到本地,再由Fiddler转发到目标后端,并且在后端响应里追加CORS响应头。适合用来联调那种不允许你改代码的第三方接口,或者后端同事暂时还没加上CORS响应头时临时救急用。
整体选型建议如下:开发阶段优先用Vite/Webpack代理,不依赖后端任何改动;生产环境用Nginx反向代理收敛域名;接口要对第三方系统开放时,老老实实让后端加CORS响应头。前两个是“绕”,第三个是“允许”,绕是开发效率的权宜之计,允许才是对外服务的正牌方案。
3. 后端接口加CORS响应头的完整实操
后端加CORS响应头是最直接、最底层的解法。不管你用的什么语言、什么框架,核心就是添加几个HTTP响应头。我先把标准响应头讲清楚,再给出不同框架的具体配置方式。
3.1 CORS响应头参数拆解
后端接口要放行跨域请求,至少需要设置以下响应头:
| 响应头 | 作用 | 示例值 |
|---|---|---|
Access-Control-Allow-Origin | 允许哪个来源访问 | https://admin.example.com或* |
Access-Control-Allow-Methods | 允许哪些HTTP方法 | GET, POST, PUT, DELETE, OPTIONS |
Access-Control-Allow-Headers | 允许请求携带哪些自定义头 | Content-Type, Authorization, X-Requested-With |
Access-Control-Allow-Credentials | 是否允许携带Cookie | true |
Access-Control-Max-Age | 预检请求结果的缓存时间 | 3600 |
第一个响应头是最核心的,几乎决定了整个配置的对错。如果后端只写一个Access-Control-Allow-Origin: *,意思是任意来源都能访问,这个配置在接口完全是公开数据时可以用。但它在两种情况下会翻车:
一种是你需要携带Cookie。浏览器规定,如果请求需要携带凭证(Cookie),Access-Control-Allow-Origin不能是*,必须明确写成具体的来源域名,同时Access-Control-Allow-Credentials必须设为true。用通配符时,浏览器检查到Allow-Credentials: true和Allow-Origin: *同时存在,会直接判定为非法配置,拒绝放行。
另一种是你需要区分不同环境的来源。比如说测试环境前端跑在http://test.example.com,生产环境跑在https://admin.example.com,如果后端写死一个来源,那另一个环境又失灵了。这就要么在后端配置里做成动态读取请求的Origin头,要么维护一个白名单列表。
再强调一下Access-Control-Allow-Headers。前端发请求时如果带了诸如Authorization(用来做登录态鉴权)、Content-Type: application/json这类请求头,浏览器在预检阶段就会问后端:“我能不能带这些头?”如果后端返回的Allow-Headers里没有包含对应的头,浏览器一样拦截。很多项目配置了Allow-Origin和Allow-Methods,但漏了Authorization,结果前端明明带了token请求,接口还是报跨域。这个坑我在联调时踩了好几次。
3.2 预检请求(OPTIONS)必须处理
跨域分两种情况:简单请求和预检请求。
简单请求是GET、POST(Content-Type限定为普通表单格式)这类不会触发预检的请求。浏览器直接发送请求,后端响应里带上CORS头就算完事。
但一旦请求带了自定义头、或者Content-Type是application/json、或者用了PUT/DELETE方法,浏览器会在正式请求之前先发一个OPTIONS请求来“探路”,这就是预检请求。许多后端项目用Spring Security或者自定义拦截器,默认拦下了OPTIONS请求,结果预检返回403,前端正式请求根本不存在发出去。报错信息往往是:“CORS preflight response did not return HTTP status 200”之类的,方向完全不对。
正确的做法有两种:一种是让OPTIONS请求直接放行,不经过登录鉴权;另一种是单独写一个拦截器,只要请求方法是OPTIONS就直接返回200,并且附带CORS响应头。不管用什么框架,都要确保预检请求能拿到正确的CORS头,这一步到位了,主请求才会放行。
3.3 各主流后端框架的配置方式
拿Java的Spring Boot举例,最省事的方式是写一个配置类,实现WebMvcConfigurer接口,重写addCorsMappings方法:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有个细节:allowCredentials(true)时,方法名是allowedOriginPatterns而不是allowedOrigins。因为allowedOrigins("*")在SpringBoot 2.4之后的版本里和allowCredentials(true)会冲突,直接用allowedOriginPatterns("*")更省心。
如果项目里用了Spring Security,光配置WebMvcConfigurer可能不够,因为Spring Security的过滤器链执行顺序在MVC之前,CORS头会被安全过滤器先拦掉。需要在Security配置里也加上CORS支持:
http.cors().and()然后在安全规则里对OPTIONS请求放行:
authorizeRequests() .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated()Node.js的Express服务,用cors中间件几行就搞定:
const cors = require('cors'); app.use(cors({ origin: ['https://admin.example.com', 'http://localhost:8080'], methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 3600 }));如果你不想引中间件,也可以在请求处理的最前面手工设置响应头,原理是一模一样的。
PHP后端设置响应头的方式比较直接,在入口文件或者每个接口的公共逻辑里加上:
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') { http_response_code(200); exit(); }后面这个OPTIONS判断很重要。PHP项目很多老代码没有分层,接口各自为战,如果不在公共入口统一处理预检请求,每个接口的跨域配置就会写得很分散,排查起来非常痛苦。建议所有PHP项目把CORS配置抽到入口文件(比如index.php)或者公共中间层,统一管理。
3.4 网关层统一处理CORS才是长远之计
如果你负责的项目是微服务架构,或者后端有多个服务,每个服务各配一套CORS响应头是个灾难。比如说你有用户服务、订单服务、支付服务,前端一次请求可能要调动其中两三个,如果各自配置不统一,排查的时候一会儿好的、一会儿坏的,非常难追。
这种情况下建议在网关层统一处理。以Nginx为例,在location级别加上CORS配置即可:
add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"; add_header Access-Control-Allow-Credentials true; add_header Access-Control-Max-Age 3600; if ($request_method = 'OPTIONS') { return 204; }这里用$http_origin而不是写死具体域名,是为了动态回显请求来源,方便后续扩展多个域名来源。但注意,如果后端接口涉及重要数据,配合Nginx的map指令做一个域名白名单校验更稳。比如固定只允许admin.example.com和app.example.com两个来源,其他的一律拒绝。
在网关层解决CORS还有个额外好处:各个后端服务代码里完全不用关心跨域配置,逻辑更干净,这也是我对接多服务项目时最喜欢的方式。但要注意Nginx的add_header在PostAction阶段只在200和204等部分状态码上生效,对于4xx、5xx错误响应,默认不会带上CORS头,如果需要错误响应也能被前端读取,得用always参数:add_header Access-Control-Allow-Origin $http_origin always;。这个细节容易被忽略,前端拿到的错误信息往往是“Blocked by CORS policy”,实际上后端已经返回了500,但响应头里没有CORS头,浏览器连错误详情都不给你看。
4. 前端处理跨域的实操方案
后端接口能在代码里改,那怎么都好说。但很多时候你是在联调阶段或者其他团队的系统,后端代码改不动,或者改起来要排期,这时候前端就得自己想办法绕过去。
4.1 Vite和Webpack代理配置
本地开发阶段,我几乎无脑推荐用脚手架自带的代理功能。Vite项目的配置在vite.config.js里:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })这段配置的意思是:页面里所有以/api开头的请求,Vite开发服务器把它转发到http://localhost:9090。changeOrigin: true的作用是把请求头里的Host字段改成目标地址,避免后端有些业务逻辑通过Host来校验来源。
rewrite那一步是把路径中的/api前缀去掉再转发给后端,这取决于后端接口到底带不带/api前缀。如果后端接口本身就是/api/user/list这种带前缀的,那就不用rewrite;如果后端接口是/user/list,而前端约定统一加/api前缀方便代理识别,就一定要rewrite。这个细节很容易栽跟头,配置之后接口404,多半就是这个原因。
Webpack项目的配置逻辑完全一样,位置在webpack.config.js的devServer.proxy:
devServer: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true, pathRewrite: { '^/api': '' } } } }用了代理之后,前端代码里请求地址不要写全路径http://localhost:9090/user/list,而是写相对路径/api/user/list。这样开发环境走代理,生产环境拆掉代理逻辑或者换Nginx转发,代码基本不用动。
4.2 Fiddler代理配置跨域的实操细节
Fiddler本质是个HTTP调试代理,平时大家用它抓包看请求响应,但它也能做请求转发——这就为“前端临时突破跨域”提供了一个思路。
场景是这样的:后端接口已经部署在测试环境,但测试环境接口没有配CORS响应头,你本地页面直接请求它必跨域。这时候Fiddler可以帮你做一个“响应头附加器”:请求照常发到后端,Fiddler在收到响应后,往响应头里附加Access-Control-Allow-Origin: *,这样浏览器认为自己收到了合法的跨域响应,就不再拦截。
在Fiddler里先开启代理监听:菜单栏选择Tools -> Options -> Connections,勾选Allow remote computers to connect,记住默认代理端口是8888。然后把浏览器的代理设置为127.0.0.1:8888(Chrome可以用SwitchyOmega扩展快速切换,或者直接用--proxy-server=127.0.0.1:8888启动参数)。
接下来在Fiddler的OnBeforeResponse脚本里添加一段逻辑,在响应头里注入CORS字段。打开FiddlerScript Editor,找到OnBeforeResponse函数,里面对所有Content-Type以json开头的响应添加响应头:
if (oSession.ResponseHeader.Exists("Access-Control-Allow-Origin")) { oSession.ResponseHeader.Remove("Access-Control-Allow-Origin"); } oSession.ResponseHeader.Add("Access-Control-Allow-Origin", "*");保存之后,Fiddler会根据脚本重写每次响应的CORS头,浏览器就再也不报跨域了。
这个做法的优点是完全不依赖后端配合,适用于联调阶段。缺点也很明显:Fiddler是个桌面工具,只能在你本地用,不能帮到团队其他人;而且全局代理会影响所有网络请求,调试其他项目的时候可能会有干扰。另外,Fiddler修改的是本地调试代理上的响应,应用在生产环境是不现实的——生产环境还是得靠真正的Nginx或CORS配置。所以我的定位是:临时救急可以,长期方案别这么做。
4.3 JSONP在什么情况下值得用
JSONP是零几年就出现的老方案,到现在基本只活跃在老旧系统里了。它的实现思路是利用<script>标签加载资源不受同源策略限制这一点,让后端返回一段JavaScript代码,把数据包在回调函数里。
前端代码如下:
function handleResponse(data) { console.log(data); } const script = document.createElement('script'); script.src = 'http://api.example.com/geo/get?callback=handleResponse'; document.body.appendChild(script);后端识别到回调参数callback=handleResponse之后,返回的内容不是普通JSON,而是:
handleResponse({"name": "张三", "id": 123})浏览器加载这个脚本,等于执行了一个函数调用,数据就进入前端的回调函数了。
但在2024年的今天,我不建议任何新项目主动选JSONP。首先它只支持GET,想POST数据很难看;其次它要求后端配合改造接口返回格式,后端把数据拼进JS代码里,安全隐患和调试难度都上升;再加上现代浏览器对JSONP的跨域限制虽然没有取消,但各大站点已经逐步禁用基于顶层导航的第三方脚本行为,这种方案越来越不好使了。
那什么时候值得用?我遇到的真实场景是:对接一个老旧的第三方支付平台,对方只提供JSONP接口查询订单状态,改接口得走版控流程。这种时候捏着鼻子也得用JSONP,但用的时候要做好安全性剥离开的预期——任何用JSONP返回的数据都不要直接拼进DOM里,防止XSS注入。
5. 排查跨域问题的思路与常见坑
跨域报错是前端最容易碰到、也最容易踩坑的一类问题。报错信息五花八门,有说No 'Access-Control-Allow-Origin' header is present的,有说Response to preflight request doesn't pass access control check的,还有说Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'的。
5.1 一套标准的排查思路
碰到跨域报错,先别慌,按照这个顺序查:
第一步,确认改动边界。问一下自己:这个问题是我最近改动代码才出现的,还是首次联调就遇到?如果是首次联调,多半是后端压根没配CORS响应头,或者代理配置路径不对。如果是改代码后突然出现,多半是后端某个响应头被改动了,或者请求从简单请求变成了预检请求(比如新增了自定义请求头)。
第二步,打开DevTools的Network面板,看请求到底发出去了没有,以及响应是什么状态。如果请求显示为(cors blocked),说明请求连预检都没通过;如果请求有响应但内容是红色报错,说明请求发出去了但浏览器不允许读取。这是两个完全不同的方向,前者重点查后端是否处理OPTIONS,后者重点查响应头是否完整。
第三步,在Network里点开那个被拦截的请求,看两个东西:请求头里的Origin是什么,响应头里有没有Access-Control-Allow-Origin。如果响应头完全没有CORS字段,后端配置缺失,直接找后端;如果响应头里有CORS字段但和Origin不匹配,是白名单配置不对,也找后端;如果请求压根在Network里没出现,那就是前端代理配置没生效或者路径不对。
第四步,确认是不是Cookie引发的冲突。如果后端配置了Access-Control-Allow-Origin: *,同时前端请求又设置了withCredentials: true,浏览器会直接拒绝。原因前面讲过,允许携带凭证时,来源必须是具体域名,不能用通配符。看到报错信息里有credential字样,就往这个方向查。
5.2 常见问题速查表
| 报错现象 | 可能性原因 | 排查方向 |
|---|---|---|
| No 'Access-Control-Allow-Origin' header is present | 后端完全没配置CORS头 | 后端加响应头 |
| CORS preflight did not succeed | OPTIONS请求被拦截或返回非2xx | 放行OPTIONS请求 |
| Credential is not supported if the CORS header is '*' | Allow-Origin写死*且Allow-Credentials为true | 改为具体域名来源 |
| Request header field authorization is not allowed | Allow-Headers没包含Authorization | 后端加Authorization |
| 前端代理配置后接口404 | 路径rewrite规则写错 | 检查/api前缀是否被正确替换 |
| 代理后接口频繁断连 | changeOrigin未设true或代理目标地址不稳定 | 检查目标域名是否加了http://协议 |
| 用了Nginx转发还是报跨域 | add_header缺always参数,错误响应没带CORS头 | 加always |
5.3 几个我用经验换来的提醒
第一,Access-Control-Allow-Origin: *和Access-Control-Allow-Credentials: true最好别同时用。就算某些情况下后端能配置出来,浏览器也可能因为版本不同而表现得飘忽不定。如果接口需要携带登录态Cookie,建议后端维护一个可信来源域名列表,配置成精确域名。
第二,生产环境的跨域问题,几乎别让代码改来解决。正确的顺序是:先看Nginx层能不能通过add_header来解决,再考虑后端代码加CORS头,最后才考虑前端改代理。原因无他,线上环境不可能为了调试而去刷新浏览器缓存、改代码配置,靠网关层统一收敛是最可控的。
第三,开发环境用代理解决跨域时,一定要确认代理配置是否真的生效。我见过很多开发者改了vite.config.js后只刷新页面,没重启DevServer,代理配置完全不生效,白折腾了半小时。Vite的代理配置改动是需要重启服务才能生效的,这是官方文档都写了但很少有人注意到的地方。
第四,调试跨域问题不要用Postman来判断“接口到底通不通”。Postman压根不执行浏览器同源策略,接口通不通和浏览器能不能访问是两码事。我用过最舒服的调试方式就是直接看浏览器DevTools的Network面板,接口请求和响应在那里展示得一清二楚,比任何外部工具体验都好。
6. 一次跨域问题的完整排查实录
分享一个真实的案例。前阵子帮一个电商后台项目做权限模块的联调,前端工程跑在http://localhost:3000,后端服务跑在http://192.168.31.85:8080,中间没有Nginx,纯开发环境联调。
前端同学说调用登录接口报跨域,控制台报错信息是Access to XMLHttpRequest at 'http://192.168.31.85:8080/api/login' from origin 'http://localhost:3000' has been blocked by CORS policy。
我第一反应是后端没配置CORS。打开后端代码一看,登录接口所在的Controller确实没加任何CORS相关注解,不过鉴权过滤器里倒是有个统一的跨域处理逻辑,但那个过滤器只对带有效token的接口生效,登录接口走的是匿名认证链,压根没经过过滤器。这个坑很有意思:其他业务接口都有token,过滤器会附加CORS头,所以联调时发现其他接口都能通,就登录接口跨域。前端同学一度以为是登录接口代码的问题,还去核对了好半天请求参数格式,完全跑偏。
解决办法是在后端的Spring Security配置里增加一个独立的CORS配置源,处理/api/login、/api/refresh-token这类匿名接口的OPTIONS预检和CORS头。这个案例很好地说明了一个道理:跨域配置放在拦截器、过滤器的层级里时,一定要确认不同请求路径、不同认证状态下的覆盖范围是否一致,否则就是出现了“部分接口通,部分接口不通”的诡异现象。
后来又排查了一个更隐蔽的问题:前端某个请求报跨域,但Network面板里能看到响应头包含完整的CORS字段。后来仔细一看,发现后端配置了Access-Control-Allow-Origin: *,但前端发送前设置了withCredentials: true(因为有些接口需要带Cookie做状态同步),浏览器直接否决了。后端同事把Allow-Origin改成精确来源之后,问题才消停。
这两个案例让我更加确定,跨域排查时最忌讳的是只盯着一行报错信息就下结论。报错信息只是浏览器给出的最终判断,它背后的因果关系可能要沿着“请求头→响应头→拦截器/过滤器→全局配置”这条链路一层层剥开才能找到。
最后给大家一个我常用的实操习惯:在项目初期,就让后端在Nginx层统一把CORS响应头配好,前端开发环境用Vite代理,生产环境走Nginx转发,全部收口成一种方式。这样前后端各管一段,接口联调时几乎没有多余的跨域噪音。如果你已经有一个线上项目在跑,而且之前一直没管过跨域,那建议先在网关层把CORS头加上,这是改动成本最低、收益最大的操作。改完之后再用DevTools刷新页面验证几个关键接口,只要返回头带上了Access-Control-Allow-Origin,整个流程就顺了。