做前端开发的,十有八九都见过这行报错:“No 'Access-Control-Allow-Origin' header is present on the requested resource”。刚入行的同事往往会满头问号——接口用 Postman 测得好好的,代码看起来也没问题,怎么一放到浏览器里就报跨域?这不是代码写错了,而是浏览器的同源策略在“执行公务”。同源策略是浏览器最基础也是最重要的安全机制之一,它决定了页面只能访问和自己“出身”相同的资源。可如今前后端分离架构几乎是标配,前端跑在 localhost:8080,接口部署在 api.example.com,同源策略就成了开发路上绕不开的一道坎。解决跨域的办法不少,CORS、JSONP、nginx 反向代理各有适用场景,而其中 nginx 反向代理因为配置简单、性能稳定、还能顺带解决开发和生产的部署问题,是很多团队的最终选择。这篇文章会把同源策略的来龙去脉讲清楚,再把 CORS、JSONP、nginx 代理这些方案从头到尾演示一遍,适合正在被跨域问题折磨的前端、后端和运维同学参考。
1. 同源策略:浏览器到底在拦什么
1.1 同源的定义:协议、域名、端口三件套
“源”(origin)由三部分组成:协议、域名、端口。只有三者完全一致,才叫同源。举个例子:页面地址是https://www.example.com,那么https://www.example.com/api是同源,因为域名和协议端口都一样;http://www.example.com协议不同,是跨域;https://api.example.com域名不同,是跨域;https://www.example.com:8080端口不同,也是跨域。
这里有个容易忽略的细节:默认端口。https 默认走 443,http 默认走 80,所以https://www.example.com:443和https://www.example.com会被浏览器视为同一个源。但你要是写成https://www.example.com:8443,那就是另一个源了。还有localhost和127.0.0.1看起来是同一个东西,浏览器却严格按字符串比较,认为它们是两个不同的源。这个坑我踩过不止一次:开发环境里前端页面用 localhost 打开,接口地址却写 127.0.0.1,结果死活报跨域,查了半天才发现居然是这个原因。所以排查跨域问题,第一步永远是先把“源”的三要素在纸上列清楚。
1.2 为什么要设这道坎:从 CSRF 说起
同源策略不是故意给开发者添堵,它保护的是用户的数据安全。设想一个场景:你在银行网站 A 的账户页面登录了,浏览器里保留着 A 的登录态 Cookie。这时你打开了另一个恶意网站 B,B 页面里放了一张图片或一个表单,请求指向 A 的“转账”接口。如果没有同源策略,B 发出的请求会带着 A 的 Cookie 一起送到银行服务器,银行接口收到请求后以为是用户本人操作,钱就被悄悄转走了。这就是经典的 CSRF(跨站请求伪造)攻击。
同源策略把这个漏洞堵上了:B 页面的脚本只能读取 B 自己的数据,发往 A 的请求会被浏览器拦截住,A 的接口根本收不到带用户身份信息的请求。理解了这一点,你就明白了——我们做跨域访问,不是在“绕过”同源策略,而是要用浏览器能认可的安全方式,让非同源资源之间合法通信。这也是为什么 CORS 要由服务器端显式声明允许来源,为什么 nginx 反向代理能成为主流方案,因为这两种方式都没有破坏安全模型,只是让“源”的判断结果发生了变化。
1.3 同源策略的边界:哪些被拦、哪些不拦
同源策略管的其实不是所有网络请求,它主要拦的是“跨域请求的响应读取”。换句话说,发起跨域请求本身并不全被禁止,比如<img>标签加载跨域图片、<script>标签加载跨域 JS、<link>标签加载跨域 CSS,这些都是允许的。真正受限的是通过fetch或XMLHttpRequest发起的跨域请求,以及页面脚本对这些请求响应的读取。
还有一个非常关键的事实:跨域请求发出去了,服务器也正常处理了,数据也返回了,但浏览器在最后一步把响应扣下了,控制台才报错。很多后端同学说“我接口明明通了,数据库都查到了数据,前端还报跨域”,原因就在这里——请求到了,响应也回了,只是浏览器基于安全策略不让页面脚本拿到。这个认知很重要,否则你会反复在“接口可用性”上做无用功,而真正的瓶颈在浏览器这一层的校验规则。
2. 跨域问题的常见解法与选型思路
2.1 方案一:后端开启 CORS,最正统的解法
CORS(Cross-Origin Resource Sharing,跨域资源共享)是 W3C 的标准方案,核心思路是“由服务器声明允许哪些来源访问”。后端在响应头里加几个关键字段就能实现:
Access-Control-Allow-Origin:允许的来源,可以是具体域名https://www.example.com,也可以是*通配所有来源,但*不能和允许凭证功能同时使用。Access-Control-Allow-Methods:允许的 HTTP 方法,常见写法是GET, POST, PUT, DELETE, OPTIONS。Access-Control-Allow-Headers:允许的自定义请求头,比如Content-Type, Authorization。- 如果需要携带 Cookie,还要加
Access-Control-Allow-Credentials: true。
CORS 分“简单请求”和“预检请求”两种情况。满足以下条件的叫简单请求:方法只能是 GET/POST/HEAD,请求头只用浏览器自动加的那些,Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain之一。简单请求直接发出,不需要提前打招呼。但只要你用了application/json的 Content-Type,或者带了Authorization这类自定义头,浏览器就会先发一个 OPTIONS 预检请求,问服务器“我这样跨域行不行”,服务器回应了正确的 CORS 头,浏览器才发真正的业务请求。
这里就是后端同事一头雾水的地方:日志里看到一堆 OPTIONS 请求,以为有人在扫描,其实那是浏览器在“探路”。后端接口如果只处理了 GET 和 POST,OPTIONS 直接返回 404 或空响应,预检就失败了,前端看到的依然是跨域报错。标准做法是后端对 OPTIONS 请求统一返回 204,并带上允许的 Headers 和 Methods。
2.2 方案二:JSONP,老古董但某些场景还能用
JSONP 的思路非常“野路子”:既然<script>标签加载跨域 JS 不受限制,那就让服务器返回一段 JS 代码,把数据包在一个回调函数里,前端提前定义好这个函数。比如前端写:
<script> function handleData(data) { console.log(data); } </script> <script src="https://api.example.com/user?callback=handleData"></script>服务器返回的内容是一段handleData({ "name": "张三" }),浏览器把它当作 JS 执行,数据就到了前端。这个方案的好处是不需要后端配置任何 CORS 头,缺点是只能支持 GET 请求,没法用 POST、PUT,也不能带自定义请求头,错误处理还很别扭,接口超时或报错时根本没有统一的状态码告诉你。
现在主流后端框架基本都支持 CORS 了,JSONP 的使用场景越来越窄,主要是些老系统、或者第三方公开接口只提供 JSONP 时才会用到。我的建议是:新项目不要主动选 JSONP,除非你面对的是一个完全没有 CORS 支持且只能 GET 的存量接口。
2.3 方案三:代理转发,开发和生产的通用路子
第三种解决思路最关键:既然浏览器是“看来源”的,那让页面请求的地址和页面自己完全同源,问题不就消失了?具体做法是:前端页面部署在 A 域名,后端接口在 B 域名,中间加一层代理,前端请求统一写 A 域名下的某个路径(比如/api),代理服务把/api开头的请求原样转发给 B 域名的真实接口,再把响应原样返回。
浏览器看到的是:页面地址是https://www.example.com,请求地址也是https://www.example.com/api,完全同源,同源策略根本不会介入。而真正的接口调用发生在代理服务器和后端服务器之间,服务器之间没有同源策略的限制,想怎么请求就怎么请求。这个方案在前后端分离项目里使用率非常高,开发环境用 Node 的 devServer 代理,生产环境用 nginx 反向代理,一套思路贯穿始终,切换成本极低。
2.4 方案对比:什么时候选哪个
| 方案 | 改动成本 | 适用场景 | 局限性 |
|---|---|---|---|
| CORS 响应头 | 后端加配置,成本低 | 接口要开放给多个域名调用 | 预检请求处理不当容易踩坑 |
| JSONP | 前端后端都要改 | 老接口、只读数据 | 仅 GET,错误处理弱 |
| nginx 反向代理 | 运维配置一次,成本低 | 前后端都是一方部署 | 需要可控的服务器环境 |
| postMessage | 前端代码改动 | iframe 跨窗口通信 | 不适用于普通接口请求 |
| WebSocket | 前后端各自支持 | 实时推送、长连接 | 需要额外处理握手升级 |
选型逻辑其实很清晰:如果接口将来要面向第三方开放,CORS 是必须的;如果只是自己团队的前后端项目,nginx 反向代理通常是最稳健的。二者不是互斥的,很多生产系统会并存——对外提供 API 的网关用 CORS,对内前后端同域部署用 nginx 转发。
3. nginx 反向代理:原理与配置详解
3.1 反向代理为什么能解决跨域
nginx 的配置本身不难,难的是理解它在整个链路里的位置。所谓“反向代理”,就是 nginx 作为中间人,接收浏览器的请求,按照配置规则把请求转发给后面的真实服务器,再把真实服务器的响应送回给浏览器。对浏览器来说,它只知道自己正在和 nginx 通信,根本不知道 nginx 后面还有别的服务器。
这正是我们想要的“障眼法”:浏览器以为一切都是同源的。生产环境最常见的架构是:一个 nginx 监听 80/443 端口,location /指向前端静态文件目录(比如 Vue 或 React 打包后的 dist),location /api用proxy_pass指向后端接口服务(比如http://127.0.0.1:9000)。浏览器访问https://www.example.com拿到前端页面,页面里请求https://www.example.com/api/users,nginx 把请求转发到http://127.0.0.1:9000/api/users,后端返回数据,nginx 原样吐给浏览器。全程同源,零跨域报错。
3.2 基础配置:静态文件加接口转发
一个最小可用的配置长这样(以 Ubuntu 系统、配置文件位于/etc/nginx/sites-available/为例):
server { listen 80; server_name www.example.com; # 前端静态资源 location / { root /var/www/dist; index index.html; try_files $uri $uri/ /index.html; # 前端 history 路由必需 } # 接口反向代理 location /api/ { proxy_pass http://127.0.0.1:9000/; } }其中try_files $uri $uri/ /index.html是给单页应用用的。前端路由如果采用 history 模式,用户直接访问https://www.example.com/user/123时,磁盘上没有这个文件,nginx 需要把所有请求回退到index.html,由前端路由自己解析。少了这一行,刷新页面就是 404。很多同学配完 nginx 发现“首页能开、一刷新就白屏”,原因就在这。
3.3 最容易踩的坑:proxy_pass 的斜杠问题
proxy_pass最后有没有斜杠,转发结果完全不一样,这是新手翻车率最高的地方。规则可以总结成几点:
location /api/加proxy_pass http://127.0.0.1:9000/:请求/api/users时,location 匹配到的/api/会被替换成/,后端收到的路径是/users,也就是说/api前缀被吃掉了。location /api/加proxy_pass http://127.0.0.1:9000(末尾没有斜杠):请求/api/users原样转发,后端收到的还是/api/users。location /api(不带尾斜杠)加proxy_pass http://127.0.0.1:9000/users:此时/api会被替换成/users,请求/api/list转发后变成/users/list。
我建议你改写配置时在纸上画一遍路径拼接,或者直接用curl验证到底转成了什么。实际项目中最常见的判断依据是:后端接口本身带不带/api前缀。如果 Spring Boot 这类后端用/api作为统一路由前缀,nginx 就写location /api/ { proxy_pass http://127.0.0.1:9000; }保持原路径;如果后端接口是干净的/user、/order,nginx 就需要写带尾斜杠的proxy_pass http://127.0.0.1:9000/;把/api剥掉后再转发。
3.4 缺一不可的请求头与超时配置
前面那三行配置在开发环境够用,但一上线就会遇到各种“玄学”问题,排查到最后基本都是请求头和时间超时没配。推荐在location /api/里补上这一组:
location /api/ { proxy_pass http://127.0.0.1:9000/; 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_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 10s; proxy_send_timeout 60s; proxy_read_timeout 60s; }为啥要设置Host头?因为后端服务器有时会根据 Host 做路由或域名校验,不设置的话,nginx 默认把后端的地址当作 Host 传过去,后端可能直接拒绝。设置成$host,后端看到的就是浏览器访问的原始域名。X-Real-IP和X-Forwarded-For则是把真实客户端 IP 透传给后端,否则后端的访问日志里清一色都是 nginx 服务器的 IP,做统计、做限流、按 IP 封禁的规则全部失效。
超时配置也很有讲究。proxy_connect_timeout控制的是 nginx 和后端建立连接的超时时间,后端没起来或者网络不通时,这里会先报错;proxy_read_timeout控制的是 nginx 等待后端响应的时间,遇到查询慢的接口,比如导出报表花费一两分钟,不把这段调大,前端就会收到 504。这三行看起来不起眼,排查线上问题的时候能救命。
3.5 带 Cookie 的请求和 WebSocket 的特殊配置
如果前端请求带了登录凭证 Cookie,nginx 默认就会透传 Cookie 头,一般不需要额外处理,但有一个前提:前端用fetch或 axios 时,要把credentials设置为include或same-origin。如果走的是 nginx 同源代理,浏览器看到的是同源请求,不触发 CORS 校验,Cookie 默认携带,这块反而省心。真正要注意的是后端返回的Set-Cookie,如果带着Secure属性,在纯 HTTP 环境下浏览器不会写入,需要保证全链路 HTTPS。
WebSocket 的代理配置是另一个高频盲区。想通过 nginx 转发ws://升级请求,需要这样配:
location /ws/ { proxy_pass http://127.0.0.1:9500/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }关键在第 3、4 行:WebSocket 握手是 HTTP 协议带着Upgrade: websocket头发起的,nginx 默认走 HTTP/1.0 连接,不支持这种协议升级,必须把proxy_http_version设为 1.1,并把 Upgrade 头透传过去。proxy_read_timeout也要调大,因为 WebSocket 是长连接,默认 60 秒没有任何数据交互就会被掐断,实时的聊天、行情推送功能就会时不时掉线。
4. 实操复盘:一个前后端分离项目的跨域改造
4.1 场景描述与整体思路
我用一个典型项目来演示完整流程:前端是 Vue 3 项目,开发时跑在http://localhost:8080;后端是 Spring Boot,跑在http://localhost:9000,接口路径是/api/login、/api/user/list这种带/api前缀的形式。生产环境计划把前端构建产物 dist 部署到服务器上,用 nginx 同时托管静态文件和转发后端接口,最终统一走https://www.example.com。
整体思路分三段:开发环境用 vite 的 devServer 代理解决跨域,生产环境用 nginx 反向代理解决跨域,两处原理完全一致,都是“前端请求同源路径、代理转发到真实后端”。先跑通开发环境,再迁移到 nginx,每一步都有明确的验证方法,出问题知道查哪里。
4.2 开发环境:vite 代理配置
Vue 项目使用 vite 的话,在vite.config.js里加一段:
export default defineConfig({ server: { port: 8080, proxy: { '/api': { target: 'http://localhost:9000', changeOrigin: true // 后端接口本身不带 /api 前缀时,打开下一行 // rewrite: path => path.replace(/^\/api/, '') } } } })前端代码里所有请求统一写/api/xxx,vite 启动的 Node 服务接手请求后转发给http://localhost:9000。changeOrigin: true会把请求头里的 Host 改成目标地址的 Host,避免后端做域名校验时报错。浏览器看到的请求地址始终是http://localhost:8080/api/xxx,同源,不报跨域。rewrite注释那行和 nginx 斜杠问题的逻辑一样:后端带/api前缀就保留原路径,不带就去掉前缀,按实际情况选择。
4.3 生产环境:nginx 完整配置
生产环境我贴一份可以直接“抄作业”的完整配置,记得把域名、路径替换成自己的:
upstream backend_server { server 127.0.0.1:9000; keepalive 32; } server { listen 443 ssl; server_name www.example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; ssl_protocols TLSv1.2 TLSv1.3; gzip on; gzip_types text/plain text/css application/javascript application/json; location / { root /var/www/example/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend_server; 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_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_connect_timeout 10s; proxy_read_timeout 60s; } }这里用upstream定义后端服务器组,后续后端要扩容成多台机器,只需在 upstream 里多加几行server IP:PORT;,nginx 自动做负载均衡,前端和配置主体完全不用动。keepalive 32让 nginx 和后端之间保持长连接,避免每次请求都重新建连,高并发场景下收益明显。gzip 压缩对前端静态资源的体积改善非常直观,尤其是未压缩的 JS 和 CSS 能缩小到原来的四分之一。
配置写完后,先执行nginx -t检查语法,再systemctl reload nginx平滑重载。如果改了 listen 端口或证书这类关键配置,reload 可能不奏效,需要systemctl restart nginx。用nginx -t先验证是铁律,见过不止一次同事直接 restart,语法写错导致服务起不来,线上秒级告警,场面一度很紧张。
4.4 验证方法:从 curl 到浏览器开发者工具
配置完别急着宣布“搞定”,我有一套固定的验证顺序。第一步,用 curl 直接测 nginx 转发链路:
curl -i "http://localhost/api/user/list"返回了后端数据,说明 nginx 到后端的链路是通的。注意这一步是在服务器本机执行,浏览器没有参与,所以不会触发跨域校验,看不到跨域报错是正常的,别误判。
第二步,打开浏览器访问前端页面,按 F12 打开开发者工具,切到 Network 面板,找到/api/xxx那个请求,看响应头和响应体。同源代理场景下,响应头里通常不需要Access-Control-Allow-Origin,因为浏览器根本不会做跨域校验。如果后端自己加了 CORS 配置,也不冲突,但要留意别出现重复配置导致“两边都在管”的混乱。
第三步,清掉浏览器缓存再测一次。跨域报错有时候是真的,有时候是浏览器缓存了旧响应,尤其是有 Service Worker 的项目,缓存能把问题掩盖很久。实测中“改了配置没生效,最后发现是浏览器缓存”的比例高得惊人,尤其是本机同时开着多个前端服务的情况。
5. 常见问题与排查技巧实录
5.1 浏览器报错汇总速查表
把这些年遇到的高频跨域类报错整理成一张速查表,方便照着定位:
| 报错现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| No 'Access-Control-Allow-Origin' header is present | 后端没配 CORS,或代理未到后端 | 用 curl 直接请求后端接口看响应头 |
| Response to preflight request doesn't pass access control check | OPTIONS 预检没被正确响应 | 检查后端对 OPTIONS 的处理,确认 Allow-Headers 覆盖了实际请求头 |
| The request client is not a secure context | 页面是 HTTP,但浏览器要求安全上下文 | 全链路升级 HTTPS |
| Network Error(部分浏览器不给细节) | 代理目标不可达、端口不通、防火墙拦截 | 查 nginx error.log,ping 和 telnet 目标端口 |
| 504 Gateway Timeout | 后端处理超时 | 调大 proxy_read_timeout,查后端慢接口 |
| 页面能开,接口 404 | proxy_pass 斜杠问题或路径前缀不匹配 | 核对 location 与 proxy_pass 的斜杠组合 |
| 刷新前端路由 404 | 缺少 try_files 回退配置 | 补上try_files $uri $uri/ /index.html; |
这张表在团队内部一直贴着,遇到跨域问题先对着查一遍,大部分情况能当场定位。
5.2 案例一:预检请求 404,排查了半小时
有一次同事找我,前端 POST 一个 JSON 格式请求,控制台报跨域,后端日志里却只有一条 OPTIONS 请求 404,没有任何 POST 记录。这就是典型的预检失败。他们的后端用的老框架,路由里根本没有 OPTIONS 方法对应的处理器,预检请求直接被 404 弹回去。解决办法是在后端加一个对 OPTIONS 请求的统一处理:只要方法是 OPTIONS,直接返回 204,并带上下面这些头:
Access-Control-Allow-Origin: https://www.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With注意Access-Control-Allow-Headers里的值必须覆盖前端实际发送的每一个自定义头,少一个预检就失败。浏览器报错信息其实已经把答案说得很清楚——“Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response”,可惜很多人没耐心读完这条英文就到处查网络配置了。
5.3 案例二:Cookie 带不上,后端不认登录态
另一个高频问题是前端登录成功之后,后续请求总是 401。排查下来发现是 axios 默认不携带 Cookie,需要在请求配置里打开withCredentials: true。如果用的是 fetch,要写成:
fetch('/api/user/info', { credentials: 'include' })还有一个隐藏条件:如果走的是直连后端接口的 CORS 方案,服务器返回的Access-Control-Allow-Origin不能是*,必须写具体域名,同时Access-Control-Allow-Credentials要为true。而走 nginx 同源代理时基本不用为这些发愁,因为同源请求不涉及 CORS 校验,Cookie 默认就会带上。如果走了代理 Cookie 还是丢,重点检查 Cookie 的SameSite属性是否设得太严格,以及前端代码是否在请求拦截器里手动删掉了 Cookie 头。
5.4 案例三:本地没问题,部署服务器就 502
本地开发一切正常,部署到服务器后接口全挂 502,这种问题十有八九出在环境差异。第一反应是查 nginx 的 error.log,路径通常在/var/log/nginx/error.log。常见原因有三类:
第一,后端服务没起来或者端口不对。在服务器上直接执行curl http://127.0.0.1:9000/api/user/list,连不通就回后端看systemctl status和后端日志。
第二,防火墙或云安全组没放行。关键是:即使 nginx 和后端在同一台服务器,通常不太会撞防火墙,但后端在另一台机器时,nginx 所在机器必须能访问目标机器的 9000 端口,安全组和 iptables 都得查。
第三,SELinux 捣乱。这是 CentOS 系服务器的专属坑,SELinux 默认策略可能拦截了 nginx 发起网络连接的权限。临时验证可以执行setenforce 0,如果立刻恢复,就是 SELinux 的问题。长期解决是给 nginx 打开对应布尔值,比如setsebool -P httpd_can_network_connect 1。
5.5 实用排查思路:链路三分法
最后分享一个我一直在用的跨域排查思路,叫“链路三分法”——把完整链路拆成“浏览器到 nginx”“nginx 到后端”“后端到数据库”三段,每段单独验证:
- 浏览器到 nginx:看 Network 面板里请求是否发出、返回什么状态码、响应头有没有异常。
- nginx 到后端:在服务器上 curl 代理地址,和 curl 后端直连地址的返回做对比,不一致说明 nginx 转发环节有问题。
- 后端到数据库:看后端日志,确认 SQL 是否执行、是否报错。
只要某一段是通的,就说明那一段没问题,逐段排除之后,问题必然集中在“不通的那一段”。这套思路不需要多高深的技巧,但比对着报错瞎猜高效得多,特别适合新手建立排查的信心。跨域问题的本质永远是“源”的判断和服务器的响应,把这两件事想清楚,大部分坑都能提前避开。
结合这几年的实际操作,我个人的体会是:不要一上来就想着绕过同源策略,也不要盲目选方案。CORS 适合接口要开放给多个外部域名调用的场景;nginx 反向代理适合前后端都是自己团队的 Web 项目,因为它在解决跨域的同时,还顺手把静态资源托管、HTTPS、负载均衡这些事全办了,一套配置打通整个生命周期。如果给刚入行的前后端同事一个建议,那就是先把“源”的概念彻底搞明白,再用 curl 把 nginx 转发链路验证一遍,剩下的都是水到渠成的事。最后分享一个小技巧:nginx 配置改完之后,习惯性地开着tail -f /var/log/nginx/access.log,你在浏览器里的每一次点击请求,日志里都会出现对应记录。它能让请求“到底到没到 nginx”一目了然,排查效率翻倍。