干这一行搞前后端分离,几乎没人能在跨域这道坎上完全绕道走。我记得第一次带团队做项目联调,前端同事跑过来指着屏幕说接口挂了,我凑过去一看,浏览器控制台红字写着"No 'Access-Control-Allow-Origin' header is present",后端同学一脸无辜说接口用Postman测得好好的。这种场景太典型了,新手上路基本都会在这里卡上一阵子。后来发现解决方案无非两条路:要么后端开CORS,要么用nginx做反向代理。这篇就把同源策略的来龙去脉、几种跨域方案的取舍,以及nginx代理的具体配置一次讲清楚,给正在跟跨域死磕的朋友一份能直接抄作业的参考。
1. 同源策略到底在管什么:先搞清楚游戏规则
1.1 什么样的请求算“同源”,什么样的算“跨域”
同源策略是浏览器内置的一套安全机制,它判定两个页面或请求是否属于同一个“源”。判定标准就三条:协议、域名、端口,三者全部一致才算同源。只要有一个对不上,浏览器就认为这是跨域请求。
拿实际场景举例:假设前端页面跑在http://localhost:8080,后端接口是http://localhost:3000。前者协议是 http,域名是 localhost,端口是 8080;后者协议是 http,域名是 localhost,端口是 3000。域名一样,但端口不一样,于是这就算跨域。
再比如https://www.example.com和http://www.example.com,域名都是www.example.com,但协议一个是 https 一个是 http,同样跨域。www.example.com和api.example.com域名变了,跨域;example.com和www.example.com,虽然很多人觉得是同一个站,但对浏览器来说,子域和主域也算不同源。
这个概念挺像小区门禁:门禁卡只能刷自己那栋楼的单元门,换个楼就进不去,哪怕两栋楼长得一模一样。域名、端口、协议三个维度就是那扇门的锁芯,任何一个对不上,门就不开。
这里有个容易混淆的点:跨域限制是浏览器行为,不是服务器行为。服务器收到请求之后该处理就处理,该返回就返回,但浏览器收到响应后,会先检查响应头里有没有放行标识,如果没有,就拦截下来不给页面JS使用。所以后端的日志里可能明明记着请求成功处理了,前端却一直报错——这是排查跨域问题时第一个要建立的认知。
1.2 同源策略限制了三件事,也放行了一类请求
浏览器不是什么都拦,同源策略主要限制三块内容:
- Cookie、LocalStorage、IndexedDB 等本地存储的跨源读取
- 跨源情况下操作另一个窗口的 DOM
XMLHttpRequest和fetch发起的跨源请求
前两条主要防的是恶意页面偷你的登录态、篡改你在其他标签页里的页面内容。第三条是我们日常开发和接口联调时打交道最多的地方。浏览器在处理XHR/fetch跨源请求时,会检查服务端返回的响应是否携带允许跨域的响应头,没有就拦截。
那为什么很多网页可以引用第三方CDN上的JS、加载其他域名的图片?因为这些属于“标签请求”,<script src>、<img>、<link>这些标签天然不受同源策略限制。最早期的跨域方案JSONP,就是钻这个空子实现的,后面会细说。
同源策略存在的意义,往大了说是为了防 CSRF(跨站请求伪造)和 XSS(跨站脚本攻击)。如果没有这层限制,你登录了某个网站,再去逛一个恶意站点,恶意站点的脚本就能顺势向那个网站发起带Cookie的请求,借你的身份做转账之类的操作。所以浏览器宁可把规则定死一点,也不给恶意脚本留可乘之机。
2. 跨域方案怎么选:CORS、JSONP、代理一次讲透
2.1 CORS:服务端开门,浏览器才放行
CORS(跨源资源共享)是目前最正统、最主流的跨域方案。它的思路很简单:浏览器不会凭空白放行跨域请求,除非服务端在响应头里明确声明“这个源可以访问我”。
后端在做Java、PHP、Node开发时,只要在接口响应里加几个响应头:
Access-Control-Allow-Origin:允许哪些源访问,可以指定具体源,也可以配*表示所有源Access-Control-Allow-Methods:允许哪些HTTP方法,常见的有GET、POST、PUT、DELETE、OPTIONSAccess-Control-Allow-Headers:允许请求携带哪些自定义头,比如Content-Type、AuthorizationAccess-Control-Allow-Credentials:是否允许携带Cookie。注意一点,它设为true的时候,Allow-Origin不能是*,必须写成具体的源Access-Control-Max-Age:预检请求结果的缓存时间,单位秒。配了之后浏览器在一段时间内不需要重复发送OPTIONS请求
CORS的请求分两种。一种是简单请求,比如用 GET 或 POST(且Content-Type是application/x-www-form-urlencoded、multipart/form-data、text/plain这类)发起的请求,浏览器直接带上实际请求发过去。另一种是复杂请求,比如自定义了Authorization请求头、或者Content-Type用了application/json,浏览器会先发一个OPTIONS预检请求,服务端确认允许后,才发真正的业务请求。
实际踩过的坑是:后端只给业务接口加了CORS响应头,却忘了处理OPTIONS预检请求,导致前端在网络面板里看到一片OPTIONS请求返回404或非2xx状态码,业务请求也发不出去了。所以写后端跨域配置时,最好对整个路径范围统一加响应头,别只盯着业务接口。
各语言配置CORS其实都不难。后端用 PHP 的话就是在入口处加header("Access-Control-Allow-Origin: *");。Java SpringBoot 可以有注解@CrossOrigin或者写一个全局的WebMvcConfigurer配置类。Node 里用 Express 的话加个cors中间件就行。具体看团队后端用的什么框架,关键是理解上面那几个响应头的含义,别硬背代码。
2.2 JSONP:上古方案还能撑住GET接口
JSONP 属于钻空子方案,利用的是script标签不受同源策略限制这个特性。原理是:前端动态创建一个<script>标签,它的src指向跨域接口地址,并带上一个回调函数名参数。后端收到请求后,把数据包在一个 JavaScript 函数调用里返回。前端拿到响应后,这个“脚本”会直接执行,从而把数据传给回调函数。
比如前端请求http://api.example.com/getUser?callback=handleUser,后端返回handleUser({ "name": "张三" })。浏览器把它当脚本执行,handleUser就被调用了。
JSONP 的局限非常明显:只能支持 GET 请求,没法拿到 POST、PUT、DELETE;而且script标签加载失败时很难精确捕获错误状态,排障体验很差。还有个安全隐患,如果接口被恶意站点拿去用,回调函数名又没做校验,容易被人利用。所以现在新项目里基本不推荐 JSONP,最多在维护老系统、对接别人已经写死的接口时用一用。
2.3 iframe+postMessage:跨窗口通信的另类思路
如果两个页面挂在不同域名下,又想互相传数据,可以用postMessage方案。它允许不同源的窗口之间安全地发送消息,用的时候在主窗口监听message事件,在 iframe 里调用parent.postMessage(data, targetOrigin)发送数据。
这套方案的适用场景是页面嵌入了第三方 iframe,需要双方通信,比如嵌入登录框、支付组件。它解决的不是普通的接口跨域问题,而是“跨窗口通信”问题。如果做的是标准的前后端分离,前后端之间走HTTP接口,优先考虑的还是CORS或者nginx代理。
2.4 方案选型对比:什么场景用什么
写个简单的选型参考,直接做决策用:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| CORS | 前后端都能改,接口对外提供服务的场景 | 标准方案、支持各种请求方法、浏览器兼容好 | 后端需要改代码;跨域配置太多会显得繁琐 |
| JSONP | 只读接口、老系统维护、后端无法改响应头的场景 | 实现简单、兼容IE老版本 | 仅支持GET;错误不好捕获;有安全隐患 |
| nginx反向代理 | 前后端分离联调、生产环境统一入口、后端不想/不能改CORS | 前端后端都不用改代码;浏览器角度看完全同源 | 需要一台nginx服务器或本地环境,配置文件要维护 |
| postMessage | iframe嵌套跨域页面需要通信 | 能实现跨窗口双向通信 | 事件监听容易混淆;不适合替代HTTP接口调用 |
从我个人的实际情况看,CORS是每个后端开发都得会的基础能力;nginx代理则是前端开发、运维、全栈在联调和部署阶段的必备武器。很多团队“开发环境用代理、生产环境用CORS”的搭配,就是结合这两条路的优势来的。
3. nginx反向代理实战:一套配置解决前后端分离跨域
3.1 nginx安装与基本操作
在聊代理配置之前,先把环境准备好。nginx的安装方式挺多,不同操作系统不一样。
在Linux(Ubuntu/Debian)上,可以执行sudo apt install nginx直接装;CentOS/RHEL系用sudo yum install nginx。装完用nginx -v验证版本,再用systemctl start nginx启动服务。
在Windows开发机上,去nginx官网下载Windows版的压缩包,解压到一个不带中文的路径(比如D:\nginx-1.24.0),直接双击nginx.exe就能跑起来。Windows下没有service脚本,所以停止服务得用nginx.exe -s stop,重载配置用nginx.exe -s reload。
如果本机装了Docker,一条命令也能拉起nginx:
docker run -d -p 80:80 --name nginx -v /path/to/nginx.conf:/etc/nginx/nginx.conf nginx个人习惯是在开发机上直接用原生nginx,因为改配置后 reload 特别快,也不会出现端口映射造成的认知偏差。生产环境的话我偏好用Docker部署,配置文件、证书、静态资源都挂在目录里,迁移和回滚都方便。
无论哪种方式,修改配置文件之前养成一个习惯:先跑nginx -t检查语法。这个命令会告诉你配置文件有没有写错,错在哪里。改完配置之后记得nginx -s reload重新加载,而不是重启,reload是平滑重载,不会中断已有连接。
3.2 反向代理为什么能绕开同源策略
先想清楚一个关键问题:同源策略是浏览器检查页面源和请求目标源是否一致。nginx反向代理做的事情,是让浏览器发出的所有请求都打到nginx上,再由nginx转发给后端服务器。
浏览器只感知到“我访问的和页面所在的是同一个源”,因为请求的URL、端口跟页面完全一致。后端接口返回的数据经过nginx原样带回给浏览器,浏览器不会拦。整个过程中,浏览器没有直接向后端发请求,自然也就不存在跨域问题。
这就好比你在公司内部有个需要刷卡才能进的保密室,正常情况下外人进不去,但你雇了个前台小哥(nginx),访客只需要到前台报一声需求,小哥进去把资料拿出来给访客。访客全程没接触保密室的门禁,所以压根不需要刷卡资格。
对开发来说,这套方案的最大价值在于:前端不用改任何代码,后端也不用配CORS。特别是项目里后端接口动不动有几十个,团队之间又要频繁联调,配nginx代理是效率最高的方式。
3.3 前后端分离下的完整配置示例
假设开发环境的实际布局是:前端项目跑在http://localhost:8080(webpack dev server),后端接口跑在http://localhost:3000。期望的效果是:浏览器通过http://localhost/api/user访问后端接口。用nginx在80端口做统一入口。
配置如下:
server { listen 80; server_name localhost; # 前端静态资源目录,比如构建产物 root /data/www/frontend; index index.html; # 前端页面路由 location / { try_files $uri $uri/ /index.html; } # 后端接口代理 location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有几个点值得展开说。
第一,root指向前端静态文件目录,放的是构建产物(比如Vue项目的dist目录、React项目的build目录)。浏览器访问http://localhost/时,nginx直接把这个目录下的index.html返回给前端。
第二,try_files $uri $uri/ /index.html;主要解决前端路由的 history 模式刷新404问题。单页应用里的路由切换靠的是前端JS,但用户手动刷新/user/list这个路径时,nginx会先找对应的真实文件,找不到就回退到index.html,让前端路由接管。少了这一行,刷新二级页面容易出现404。
第三,location /api/里配置proxy_pass http://127.0.0.1:3000;,代表所有以/api/开头的请求都被转发到http://127.0.0.1:3000。后端接口如果有/api前缀,这一条就完全够用;如果后端接口本来没有/api前缀,比如实际路径是/user,转发时想去掉/api部分,proxy_pass后面就要加一个斜杠,写成http://127.0.0.1:3000/;。
proxy_pass带不带末尾斜杠,这是个特别经典的坑。举例说明:请求进来是/api/user,如果proxy_pass http://127.0.0.1:3000;(不带斜杠),转发给后端的是/api/user;如果proxy_pass http://127.0.0.1:3000/;(带斜杠),转发给后端的是/user。按后端实际的路由来选,别凭感觉写。
第四,proxy_set_header这三行是标配。Host $host把请求的Host头传给后端;X-Real-IP和X-Forwarded-For传递真实客户端IP。后端如果要记录用户IP、做限流,这些都依赖代理把这些头带过去,否则拿到的一律是nginx的地址。
这个配置改完后,前端开发时请求地址直接写http://localhost/api/xxx,就不会再被跨域拦。前端项目的 devServer 或者代码里配置的 baseURL 也要相应改到http://localhost/api下,保持统一。
3.4 多后端服务、WebSocket等进阶场景
实际项目里,前端页面同时调好几个后端服务也是常事。一个nginx脚本可以轻松挂多个location块,每个块代理到不同的后端端口:
server { listen 80; server_name localhost; location /api/user/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; } location /api/order/ { proxy_pass http://127.0.0.1:4000; proxy_set_header Host $host; } }这样前端可以统一请求http://localhost/api/user/login和http://localhost/api/order/list,浏览器视角全是同源请求,后端各自处理各自的前缀,互不干扰。如果团队里还有人负责Java、有人负责Go,这个方法可以省掉一堆CORS配置沟通。
再一个常见场景是WebSocket代理。前端用ws://localhost/ws建立长连接,如果后端单独起了一个WebSocket服务,需要单独配一个location并显式设置升级请求头:
location /ws/ { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 60s; }不配置Upgrade和Connection这两个头的话,WebSocket握手会直接失败,表现为连接一直在pending状态然后断开。proxy_read_timeout 60s是长连接空闲超时设置,如果业务里聊天消息不频繁,建议调大一些,避免连接被nginx主动断开。
在生产部署里,nginx还能顺带托管前端静态资源、做HTTPS证书配置、对静态文件启用缓存。比如给图片、JS、CSS加缓存头:
location ~* \.(js|css|png|jpg|gif|svg|webp)$ { expires 7d; add_header Cache-Control "public, max-age=604800"; }这样静态资源由nginx直接返回,后端只需要专心处理接口,前端和后端彻底收敛到同一个源下,跨域问题直接消失。
4. 跨域排查实录:那些让你怀疑人生的报错
4.1 浏览器缓存:伪装成跨域的老熟人
有一种很坑的情况:后端明明配置好了CORS,网上搜到的教程也都试了,但浏览器照样报跨域错误。这时候先别急着怀疑配置,大概率是浏览器缓存捣的鬼。
Chrome会对OPTIONS预检请求的结果做缓存,缓存时间由Access-Control-Max-Age控制。如果开发过程中你调过一次接口,当时后端还没配好CORS,浏览器可能已经缓存了那次“不允许访问”的响应;后面后端把配置补上了,但浏览器短期内存里还记着旧结果,继续拦截。
处理办法也简单:第一,在浏览器里强刷一次页面,快捷键是Ctrl+Shift+R,或者直接开一个无痕窗口;第二,后端给OPTIONS响应头加上Cache-Control: no-cache;第三,调低Access-Control-Max-Age,开发环境建议干脆不配,避免调试时一直吃缓存。
还有个很容易看走眼的点:浏览器的开发者工具Network面板里,如果请求显示为(cancelled),很多情况下不是跨域,而是前端页面自己取消了请求(比如组件卸载、路由切换)。别一看到cancelled就当成跨域排查。
4.2 localhost和127.0.0.1的“身份”问题
浏览器认为http://localhost:8080和http://127.0.0.1:8080是不同源,虽然它们指向的都是本机。开发的时候前端如果一边用http://localhost:8080打开页面,一边又在CORS配置里写了http://127.0.0.1:8080,那请求照样是跨域,而且报错信息里的Origin字段会明确写着http://localhost:8080,跟后端配置里对不上。
这个问题的排查成本很低,但坑过不少人。解决方案是开发时统一访问地址,要么全用localhost,要么全用127.0.0.1。前后端联调时最好把这个约定写进团队文档里,否则每家本地环境用的地址不一样,联调效率会很难看。
4.3 HTTPS页面访问HTTP接口的双重拦截
页面部署在HTTPS环境下,接口地址却是http://开头,这在浏览器里会触发“混合内容”拦截。Chrome会把这类请求直接block掉,报错信息也不一定直接写“跨域”,有时候是Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure resource。
这种问题的本质是跨域+混合内容的组合。解决办法就是让前端页面和接口统一走HTTPS,或者用nginx反代把HTTPS请求转发给HTTP后端。配HTTPS时nginx里要加ssl_certificate和ssl_certificate_key配置,证书怎么申请这里不展开,但思路是明确的:页面和接口的源尽量保持一致,让前端的请求在同源下结束。
4.4 nginx配置不生效的排查路径
如果走nginx代理方案却发现没生效,按下面的顺序排查,基本能把问题定位到具体环节:
- 先跑
nginx -t,确认配置语法没问题。 - 查看
logs/error.log(Linux下一般是/var/log/nginx/error.log),看看有没有路由匹配相关的报错。 - 确认配置修改后执行过
nginx -s reload,这个命令是平滑重载,不会断连接。 - 检查80端口有没有被占用。Windows下
netstat -ano | findstr :80,Linux下ss -lntp,看看端口是不是被别的服务抢占了。 - 用curl直接验证转发是否正常:
curl -v http://localhost/api/user,看返回的到底是什么内容。如果curl拿到的响应是对的,说明nginx配置没问题,问题在浏览器侧。
这里有一条独家经验:配置nginx代理时,要先用curl打通链路,再去开浏览器调试。curl不带浏览器的那套同源限制,如果curl都拿不到后端数据,说明是域名解析、端口、location匹配的问题,跟跨域无关;如果curl正常、只有浏览器报错,那问题一定出在浏览器端,继续查缓存和Origin即可。
常见问题做个小表,直接对着查:
| 症状 | 可能原因 | 处理方向 |
|---|---|---|
| 控制台报 No 'Access-Control-Allow-Origin' | 后端未配置CORS响应头 | 后端配置响应头,或用nginx代理 |
| 请求出现在Network面板但状态是failed | 浏览器拦截了预检请求 | 检查OPTIONS请求是否被正确处理 |
| 修改nginx配置后没效果 | 忘了reload,或配置语法错误 | 执行nginx -t再nginx -s reload |
| 刷新页面后路由404 | 前端history路由没配try_files | 在location / 里加try_files回退到index.html |
| 页面是HTTPS,接口是HTTP | 混合内容被浏览器直接拦截 | 接口反代成HTTPS,统一源 |
| localhost和127.0.0.1本机却跨域 | 浏览器认为子域和主域、不同host为不同源 | 统一用同一个host访问 |
这些坑我一个一个都踩过。尤其是proxy_pass的斜杠问题和浏览器OPTIONS缓存,几乎每次教别人做跨域都要提一遍。
我个人实际用的套路组合是:开发联调阶段一律用nginx代理,前端代码里的baseURL指向本地nginx地址,后端不用动任何代码;生产环境先看后端能不能配CORS,能配就配,不能配就也用一个nginx入口把前端静态资源和后端接口挂到同一个域名下。nginx配置文件和注意事项一定放进项目仓库的docs目录里,新同事拉下来照着跑,半小时内本地环境就通了,不用四处打听“跨域怎么解决”。
最后再分享一个排查小技巧:Chrome的Network面板里,跨域报错会挂在Console里,但具体的请求细节要从Headers页面看Origin和Access-Control-Allow-Origin两个字段对不对应。浏览器控制台的报错信息其实已经写清楚了是谁放的拦路牌,大多数时候是后端响应头缺失,看一眼响应头就知道该找谁改配置,省得前后端互相甩锅。