1. 跨域问题到底卡在哪儿:先搞懂浏览器的同源策略
刚入行那会儿,我第一次遇到跨域报错,浏览器控制台甩出一行红字,大意是“请求被拦截,响应头里缺少某个字段”。当时我的第一反应是:接口明明在浏览器里直接打开能返回数据,怎么放到页面里用fetch调就不行了?后来才明白,这不是后端挂了,也不是网络不通,而是浏览器在中间当了一道“门卫”。
这道门卫就是同源策略。所谓同源,指的是协议、域名、端口三者完全一致。只要有一项对不上,浏览器就判定为跨域。比如你的页面跑在http://localhost:3000,接口在http://localhost:8080,端口不同,跨域;页面是https,接口是http,协议不同,跨域;页面在a.example.com,接口在b.example.com,域名不同,也跨域。
很多人会误以为“跨域是前端的问题”,其实更准确地说,跨域是浏览器施加给前端的安全限制。请求本身往往已经发出去了,服务端也处理并返回了,只是浏览器在把响应交给你的 JavaScript 之前,检查了一下响应头,发现没“放行”,于是把结果扣下了。这就解释了一个经典现象:在 Network 面板里能看到请求返回了 200,但代码里就是拿不到数据。
同源策略存在的意义是防止恶意站点随意读取另一个站点的数据。设想一下,你登录了某个网银页面,这时又打开了一个恶意网站,如果没有同源策略,那个恶意网站就能拿着你的登录态去请求网银接口,把你的账户信息读走。所以这道限制不是故意为难开发者,而是保护用户的底线机制。
理解了这一点,后面所有的解决方案其实都在回答同一个问题:如何在保证安全的前提下,让浏览器认可这次跨域请求。围绕这个问题,业界演化出了好几套思路,各有各的适用场景,没有哪一种是万能的。下面我按实际项目里最常用的顺序,把这几种方案逐一拆开讲,包括它们背后的原理、具体怎么配、以及我踩过的坑。
2. 方案一:CORS,当前最主流的正规解法
2.1 CORS 的核心机制与两类请求
CORS 全称是跨域资源共享,它本质上是一套由服务端通过响应头来授权的机制。浏览器发现跨域请求时,会去看响应里有没有Access-Control-Allow-Origin这类头,有且匹配,就放行;没有,就拦截。这套机制是目前最推荐的做法,因为它把控制权交给了服务端,安全边界清晰。
CORS 把请求分成两类,这个区分非常关键,直接决定了你要不要处理预检请求。
简单请求需要同时满足几个条件:方法是GET、HEAD、POST之一;请求头只包含若干安全字段,比如Accept、Accept-Language、Content-Language、Content-Type;而且Content-Type只能是text/plain、multipart/form-data、application/x-www-form-urlencoded三种之一。满足这些条件的请求,浏览器直接发出去,服务端在响应里带上Access-Control-Allow-Origin就行。
非简单请求则是除此之外的情况,比如用了PUT、DELETE,或者Content-Type是application/json,又或者自定义了请求头。这类请求浏览器会先发一个OPTIONS请求去“探路”,这叫预检请求。服务端必须正确响应这个OPTIONS,告诉浏览器“我允许你用这个方法和这些头”,浏览器才会发真正的请求。
我见过太多人卡在预检上:接口用POST加application/json,结果浏览器先发OPTIONS,后端没处理,直接返回 405,真正的请求根本没发出去。所以只要你的接口用了 JSON 提交,就一定要把OPTIONS处理好。
2.2 服务端配置 CORS 的实操写法
不同后端框架配置方式不同,但核心就是设置那几个响应头。以常见的 Node.js Express 为例,最省事的是用cors中间件:
const cors = require('cors'); // 允许所有来源,仅建议开发环境使用 app.use(cors()); // 生产环境建议精确控制 app.use(cors({ origin: ['https://your-site.com', 'https://admin.your-site.com'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, // 允许携带 Cookie maxAge: 86400 // 预检结果缓存 24 小时 }));如果不用中间件,手动设置也行,原理一样:
app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', 'https://your-site.com'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); res.setHeader('Access-Control-Allow-Credentials', 'true'); if (req.method === 'OPTIONS') { return res.sendStatus(204); // 预检请求直接返回,不进入业务逻辑 } next(); });这里有几个参数值得单独说清楚。Access-Control-Allow-Origin如果设成*,表示允许任何来源,但一旦你要携带 Cookie,就不能用*,必须写具体域名,同时Access-Control-Allow-Credentials设为true。这是很多人配了 Cookie 却带不上的根本原因。
maxAge这个参数容易被忽略,但它对性能影响不小。预检请求本身也是一次网络往返,如果每个非简单请求都先来一次OPTIONS,接口调用量直接翻倍。设置maxAge后,浏览器会在一段时间内缓存预检结果,不再重复发OPTIONS。我一般设成 86400 秒,也就是一天。
2.3 携带 Cookie 时的三个硬性条件
带 Cookie 的跨域是最容易出问题的地方,我把它单独拎出来讲。要让 Cookie 正常跨域传递,必须同时满足三个条件,缺一不可:
- 前端请求要设置
credentials。用fetch是credentials: 'include',用axios是withCredentials: true,用原生XMLHttpRequest是xhr.withCredentials = true。 - 服务端
Access-Control-Allow-Credentials必须为true。 - 服务端
Access-Control-Allow-Origin必须是具体域名,不能是*。
另外还有一个隐藏的坑:Cookie 本身的SameSite属性。现在浏览器默认把SameSite当成Lax,跨站请求下 Cookie 可能不会被发送。如果你的场景确实需要跨站带 Cookie,服务端下发 Cookie 时要显式设置SameSite=None; Secure,而Secure又要求必须走 HTTPS。这一连串约束经常让新手绕晕,我的建议是:能用 Token 放请求头就别用 Cookie,能省掉一大堆麻烦。
提示:CORS 的报错信息有时候很含糊,浏览器只告诉你“被拦截了”,不告诉你具体哪个头不对。排查时优先看 Network 面板里预检请求的响应头,逐个字段核对,比盯着控制台红字有效得多。
3. 方案二:开发环境代理,前端最顺手的临时方案
3.1 代理为什么能绕过跨域
本地开发时,前端跑在 3000 端口,后端跑在 8080 端口,跨域几乎是必然的。这时候最省事的做法不是去改后端,而是让前端开发服务器帮忙转发请求。原理很简单:浏览器只认“当前页面所在的源”,如果请求发给同源的开发服务器,就不存在跨域;开发服务器再在服务端层面把请求转发给真正的后端,服务端之间的通信不受同源策略约束。
打个比方,同源策略管的是“浏览器这个门卫放不放行”,而代理相当于让你的请求先送到隔壁同源的房间,再由那个房间的人帮你跑腿去拿数据。门卫只看到你在同源房间里进进出出,自然不会拦。
3.2 主流构建工具的代理配置
Vite 的配置在vite.config.js里:
export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } }Webpack DevServer 的写法类似,在webpack.config.js里:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }changeOrigin这个选项建议始终设为true,它的作用是让转发出去的请求头里的Host变成目标地址,避免后端因为 Host 不匹配而拒绝。rewrite或pathRewrite则是用来去掉路径前缀的,比如前端请求/api/user,转发到后端时变成/user,具体要不要去掉取决于你后端的路由设计。
3.3 代理方案的边界与注意事项
代理方案最大的优点是前端自己就能搞定,不用求后端改代码,特别适合前后端并行开发、后端还没配 CORS 的阶段。但它有几个明确的边界,必须心里有数。
第一,代理只在开发环境生效。打包上线后,devServer就没了,代理配置不会跟着走。生产环境要么后端配 CORS,要么用 Nginx 之类的服务器做反向代理,不能指望开发代理。
第二,代理解决的是“开发时跨域”,不是“跨域本身”。它只是把跨域问题转移到了服务端之间的通信,本质上没有改变同源策略。所以别把代理当成终极方案,它更像是一块开发期的垫脚石。
第三,配置代理后,前端代码里的请求地址要写成相对路径,比如/api/user,而不是写完整的http://localhost:8080/user。如果写死了完整地址,请求就直接发给后端了,代理根本不会介入。这个细节我见过不少人搞错,配了代理却没生效,最后发现是请求地址写成了绝对路径。
注意:代理配置改完记得重启开发服务器,很多构建工具的配置文件不支持热更新,不重启不生效。
4. 方案三:Nginx 反向代理,生产环境的稳妥选择
4.1 生产环境为什么更推荐反向代理
到了生产环境,前后端往往部署在同一个域名下,比如前端静态资源在https://site.com,接口通过https://site.com/api访问。这时候用 Nginx 做反向代理,把/api开头的请求转发到后端服务,浏览器看到的所有请求都是同源的,跨域问题从根上就不存在了。
相比让后端配 CORS,反向代理有几个实际优势。一是前端不用改任何代码,请求地址保持相对路径即可;二是Cookie、鉴权头都能自然携带,不受 CORS 那套约束;三是可以对后端服务做一层保护,后端不必直接暴露在公网。所以在正式项目里,反向代理几乎是标配。
4.2 Nginx 配置的关键片段
一个典型的配置大概长这样:
server { listen 443 ssl; server_name site.com; # 前端静态资源 location / { root /var/www/frontend; try_files $uri $uri/ /index.html; } # 接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; 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_pass末尾的斜杠。如果写成http://127.0.0.1:8080/(带斜杠),那么/api/user转发过去会变成/user,前缀被替换掉了;如果写成http://127.0.0.1:8080(不带斜杠),转发过去还是/api/user,前缀保留。到底用哪种,取决于你后端的路由是不是带/api前缀。我建议前后端约定好,要么统一带前缀,要么统一去掉,别一半带一半不带,否则调试起来很痛苦。
try_files $uri $uri/ /index.html;这行是给单页应用用的,作用是当访问的路径在服务器上找不到对应文件时,统一回退到index.html,交给前端路由处理。少了这行,用户刷新非根路径的页面就会 404。
4.3 反向代理与 CORS 的取舍
有人会问:既然反向代理能解决跨域,那还需要 CORS 吗?答案是看场景。如果前后端同域部署,反向代理就够了,CORS 可以完全不配。但如果你的接口需要被第三方站点调用,比如开放平台、给合作方用的 API,那 CORS 就是必须的,因为第三方站点的域名你控制不了,只能通过 CORS 授权。
还有一种混合场景:主站用反向代理,但某些接口要开放给外部调用,那就主站走代理,开放接口单独配 CORS。两者并不冲突,按需组合即可。
5. 方案四:JSONP,老项目里的历史遗留方案
5.1 JSONP 的原理与局限
JSONP 是个“曲线救国”的老办法,现在新项目基本不用了,但维护老系统时还是会碰到。它的原理是利用<script>标签不受同源策略限制这一点:动态创建一个script标签,把请求地址指向接口,同时带上一个回调函数名作为参数;服务端返回的不是纯 JSON,而是一段“调用这个回调函数并传入数据”的 JavaScript 代码;脚本加载执行后,回调函数被调用,数据就到手了。
function jsonp(url, callbackName) { return new Promise((resolve, reject) => { const script = document.createElement('script'); window[callbackName] = (data) => { resolve(data); document.body.removeChild(script); delete window[callbackName]; }; script.src = `${url}?callback=${callbackName}`; script.onerror = reject; document.body.appendChild(script); }); } jsonp('http://api.example.com/data', 'handleData') .then(data => console.log(data));JSONP 的局限非常明显:只支持 GET 请求,因为script标签只能发 GET;没有错误处理机制,服务端出错时脚本加载失败,你拿不到具体的错误信息;存在安全风险,因为本质上是执行服务端返回的脚本,如果接口被劫持,可能执行恶意代码。所以它只适合那些只读的、可信的、老接口的场景。
5.2 什么时候还会用到 JSONP
说实话,现在还在用 JSONP 的场景,基本都是历史包袱。比如某些第三方服务只提供 JSONP 接口,或者老系统里已经写死了 JSONP 调用,改动成本太高。如果你正在维护这类项目,理解它的原理能帮你快速定位问题;如果是新项目,直接上 CORS 或代理,别碰 JSONP。
6. 方案五:WebSocket 与 postMessage,特殊场景的补充手段
6.1 WebSocket 天然不受同源策略约束
WebSocket 建立连接时用的是ws://或wss://协议,它不受同源策略限制,服务端只要接受连接,跨域通信就能建立。所以对于实时性要求高的场景,比如聊天、推送、协同编辑,用 WebSocket 反而绕开了跨域这个烦恼。
不过要注意,WebSocket 虽然不受同源策略约束,但服务端仍然应该做来源校验,否则任何站点都能连上你的 WebSocket 服务,存在安全隐患。常见的做法是在握手阶段检查Origin头,只允许可信来源连接。
6.2 postMessage 解决窗口间通信
还有一种跨域场景是页面之间的通信,比如父页面嵌了一个不同源的 iframe,想和 iframe 里的页面交换数据。这时候用window.postMessage:
// 父页面发送 iframe.contentWindow.postMessage({ type: 'ping' }, 'https://child.example.com'); // iframe 内接收 window.addEventListener('message', (event) => { if (event.origin !== 'https://parent.example.com') return; // 校验来源 console.log(event.data); });postMessage的关键是接收方一定要校验event.origin,否则任何页面都能给你发消息,可能被利用来注入数据。这个校验步骤很多人会漏掉,属于典型的安全疏忽。
7. 常见问题排查与避坑速查
跨域问题的排查,最忌讳的就是“凭感觉改”。我整理了一张速查表,把高频问题和对应思路列出来,遇到报错时按表排查,比盲目试错快得多。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 请求返回 200 但代码拿不到数据 | 响应头缺少Access-Control-Allow-Origin | 检查服务端是否配置 CORS |
| 预检请求返回 405 或 404 | 服务端未处理OPTIONS方法 | 在路由或中间件里放行OPTIONS |
| 带 Cookie 时 Cookie 丢失 | 三个条件未同时满足 | 检查credentials、Allow-Credentials、具体域名 |
| 代理配置后请求仍跨域 | 请求地址写成了绝对路径 | 改为相对路径,重启开发服务器 |
| 生产环境刷新页面 404 | 单页应用未配置回退 | Nginx 加try_files回退到index.html |
报错提示Allow-Origin不匹配 | 服务端返回了*但带了凭证 | 改为具体域名 |
| 预检频繁导致接口变慢 | 未设置maxAge | 配置预检结果缓存时间 |
除了表里的内容,再补充几个我踩过的坑。第一个是多个Access-Control-Allow-Origin头:有时候 Nginx 和后端应用都设置了 CORS 头,结果响应里出现了两个同名头,浏览器直接判定无效。排查时用curl -I看响应头,确认只有一个。第二个是大小写问题:请求头字段名不区分大小写,但配置里写错拼写照样不生效,比如把Authorization写成Authorizaton,这种低级错误肉眼很难发现,建议复制粘贴而不是手敲。
第三个坑是缓存导致的假象:有时候改了 CORS 配置,浏览器却还用旧的预检缓存,看起来像没生效。这时候清一下浏览器缓存,或者把maxAge临时设成 0 再测。第四个坑是HTTPS 与 HTTP 混用:页面是 HTTPS,接口是 HTTP,浏览器会直接拦截,这属于混合内容问题,跟跨域是两码事,但报错信息容易混淆,排查时要先确认协议是否一致。
提示:排查跨域时,善用
curl -I -X OPTIONS直接模拟预检请求,能绕开浏览器看到服务端的真实响应头,比在浏览器里反复刷新高效得多。
8. 方案选型的实战判断逻辑
讲了这么多方案,最后落到实际项目里,到底该怎么选?我总结了一套自己的判断顺序,基本能覆盖绝大多数场景。
开发阶段,优先用构建工具的代理,前端自己就能搞定,不依赖后端,效率最高。生产环境同域部署,用 Nginx 反向代理,前端代码不用动,Cookie 和鉴权头都自然携带。接口需要开放给第三方,老老实实配 CORS,把允许的来源、方法、头都列清楚,别图省事用*。实时通信场景,直接上 WebSocket,绕开跨域。维护老项目碰到 JSONP,理解原理能改就改,改不动就维持现状。
有一点我想强调:不要为了省事在生产环境用Access-Control-Allow-Origin: *。这等于对所有网站敞开大门,任何站点都能调用你的接口,如果接口涉及用户数据,风险极大。精确控制来源虽然麻烦一点,但这是安全底线。
另外,跨域配置改完之后,一定要在真实浏览器里验证,别只看接口工具的结果。Postman、curl 这类工具不受同源策略约束,它们能调通不代表浏览器里能调通。我见过有人用 Postman 测通了就以为没问题,结果上线后前端全挂,就是因为 Postman 根本不检查 CORS。
我个人在实际项目里的习惯是:本地开发用代理,测试和生产用 Nginx 反向代理,只有对外开放的接口才配 CORS,并且来源列表精确到具体域名。这套组合用了几年,基本没再被跨域问题卡住过。真正麻烦的从来不是配置本身,而是搞不清楚问题出在哪一层——是浏览器拦截、是预检没处理、还是 Cookie 没带上。把每个方案的原理和边界弄清楚,排查时就能快速定位,而不是靠猜。