拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

浏览器跨域全解析:同源策略、CORS、预检与 Nginx 代理实战

浏览器跨域全解析:同源策略、CORS、预检与 Nginx 代理实战

上周帮一个朋友看他的后台系统,前端页面能打开,登录按钮点下去控制台一片红,满屏都是Access to XMLHttpRequest at 'http://xxx' from origin 'http://yyy' has been blocked by CORS policy。他折腾了一下午,改了三版 Nginx 配置,重启了七八次服务,最后发现问题根本不在 Nginx 上——请求压根没走到网关,被前端的代理规则拦下来了。这件事让我觉得有必要把浏览器跨域这套东西从头到尾捋一遍。

跨域不是一个"配置项",它是浏览器同源策略在具体请求上的一次判定结果。同一个报错,背后可能是协议不同、端口不同、域名不同,也可能是预检请求被拒、凭证模式不匹配、响应头被中间层吃掉。所以"9 种解决方法"这种说法,本质上是 9 种绕开或满足同源策略的路径,每一条都有明确的适用边界,用错了比不用还麻烦。这篇内容适合正在被跨域卡住的前端、后端、运维,也适合想把这套机制一次性搞明白的同学,我会从原理讲到落地配置,把踩过的坑一并写上。

1. 先把这堵墙看清楚:跨域到底卡在哪个环节

很多人一遇到跨域就去搜"怎么解决",结果抄了一段配置贴上去,有时候好了,有时候还是红的,原因就是没搞清到底是谁在拦截。跨域拦截发生在浏览器侧,是浏览器主动执行的策略,服务端其实早就把数据返回了,只是浏览器读到响应头之后觉得"这个响应不该给这个页面的 JS 看",于是把响应体丢掉,同时在控制台抛一个错误。理解这一点非常关键:你在后端打断点能看到请求进来了、数据出去了,但前端就是拿不到,这不是后端的问题。

1.1 同源策略的三个判定维度

同源的定义很干脆:协议、域名、端口三者完全一致才算同源,任何一项不同都算跨域。注意这里有个容易踩的点,域名比较是字符串比较,不做 IP 归一化。也就是说http://localhost:8080和http://127.0.0.1:8080在你眼里是同一台机器,在浏览器眼里是两个完全不同的源。我自己就被这个坑过一次:本地调接口一直通,同事拉下来代码死活不行,最后发现他习惯用127.0.0.1打开页面,而我用的是localhost。

端口这一项也有个隐藏细节,协议的默认端口会被省略,http://a.com等价于http://a.com:80,https://a.com等价于https://a.com:443,但http://a.com:80和https://a.com:443依然跨域,因为协议不同。子域名同样算跨域,api.a.com和www.a.com是两个源,这一点在做微服务拆分的时候特别容易出现——主站和接口站分到不同二级域名,前端一上线就全红。

还有一个常被忽略的维度:Cookie 的作用域是按域划分的,不是按源。这解释了为什么跨域请求默认不带 Cookie,即使你把Access-Control-Allow-Origin配对了,凭证还是丢的,因为还有一道withCredentials和Access-Control-Allow-Credentials的校验在等着。

1.2 简单请求与预检请求的分界线

浏览器不是对每个跨域请求都发预检的,它先给请求分个类。满足下面全部条件的叫简单请求,直接发出去,不回预检:

  • 方法是GET、HEAD、POST三者之一;
  • 请求头只用了Accept、Accept-Language、Content-Language、Content-Type这几个安全头;
  • Content-Type的值只能是text/plain、multipart/form-data、application/x-www-form-urlencoded。

只要有一条不满足,比如你用了PUT、DELETE,或者Content-Type: application/json,又或者加了个自定义头Authorization,浏览器就会先发一个OPTIONS请求去问服务器"我能不能这么干"。这个OPTIONS就是预检请求,它不带业务数据,只带Access-Control-Request-Method和Access-Control-Request-Headers两个头,服务器必须正确回应它,真正的 POST 才会被发出去。

提示:预检结果会被缓存,缓存时长由Access-Control-Max-Age决定,默认各家浏览器不一样,Chrome 大概 5 秒,Firefox 上限 24 小时。这就解释了为什么改完配置之后有时候刷新一下就好了,有时候要等一会儿——不是配置没生效,是缓存还没过期。

理解了这个分界线,后面 9 种方案就好对号入座了:有些方案是让请求变成"不跨域",有些是让服务端正确回应预检,有些则是干脆绕开浏览器的这套判定。

2. 九种解法逐个拆解

下面这 9 种方案,我按"从老到新、从土到稳"的顺序排,不是说后面的就一定比前面的好,每种的适用场景我在最后一节做了对照表。先说清楚一点:没有任何一种方案是万能的,选择的关键在于你能控制哪一端。能改后端就改后端,能改网关就改网关,只能改前端那就走代理,什么都改不了只能换思路。

2.1 方法一:JSONP——最老的那把钥匙

JSONP 的思路是钻了一个历史空子:<script>标签的src不受同源策略限制,可以加载任意域名的 JS 文件。所以服务端把数据包成一个函数调用返回,前端提前定义好这个函数,脚本一加载就自动执行了。

前端写法大致是这样:

function handleData(res) { console.log('拿到数据了', res); } const script = document.createElement('script'); script.src = 'http://api.a.com/user?callback=handleData'; document.body.appendChild(script);

服务端返回的内容要长这样,注意Content-Type必须是application/javascript:

handleData({"id": 1, "name": "张三"});

JSONP 的硬伤有三个,而且是致命的。第一,只能发 GET 请求,因为<script>加载天然就是 GET,你想传个 JSON body 是做不到的。第二,没有任何错误处理能力,脚本加载失败只会在控制台报个语法错误或者网络错误,你拿不到状态码,没法区分是 404 还是 500。第三,安全性很成问题,服务端返回的内容会被当成 JS 直接执行,如果这个接口被第三方控制,等于把任意代码执行的权限交了出去。

注意:JSONP 在现代项目里基本不该用了。但如果你的接口要对接一些老系统,或者面试被问到,还是得知道它是怎么回事,特别是它的回调参数名不一定要叫callback,很多老框架用的是jsoncallback或者cb,对接之前一定要问清楚。

2.2 方法二:CORS 响应头配置——现在的主力方案

CORS 是 W3C 的标准方案,核心思想是服务端通过响应头告诉浏览器"这个源我允许"。它不需要前端做任何特殊处理,前端还是正常发fetch或者axios,只要服务端把头发对了,浏览器自然就放行。

响应头里最关键的是Access-Control-Allow-Origin,它的值可以是一个具体的源,也可以是*。这里有个必须记住的规则:如果请求带了凭证(Cookie、HTTP 认证),这个头就不能是*,必须回显具体的源。很多人配*之后发现登录态丢了,就是这个原因。

Access-Control-Allow-Origin: https://www.a.com Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With Access-Control-Expose-Headers: X-Total-Count Access-Control-Max-Age: 86400

几个头的含义得说透。Allow-Methods和Allow-Headers是给预检请求看的,值必须覆盖你实际用到的所有方法和自定义头,漏一个都会被拒。Expose-Headers决定了前端 JS 能读到哪些响应头,默认只能读到Cache-Control、Content-Language、Content-Type等几个基础头,如果你想在前端读X-Total-Count做分页,就必须显式暴露出来,这个坑我在做分页表格的时候踩过,后端明明返回了,前端response.headers里就是没有。

后端框架的配置方式不太一样,Node/Express 可以用中间件:

app.use((req, res, next) => { const origin = req.headers.origin; const allowList = ['https://www.a.com', 'https://m.a.com']; if (allowList.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); } res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); });

注意:OPTIONS 请求必须在业务逻辑之前被拦截并直接返回 204,不能让它走到需要鉴权的中间件里。我见过一个项目,鉴权中间件放在最前面,预检请求没带 token,直接被 401 拒掉,然后真正的 POST 就没发出去,前端看到的错误信息是"预检请求响应无效",排查了半天。

2.3 方法三:开发环境代理——本地调试的标配

本地开发的时候,前端跑在localhost:8080,后端跑在localhost:3000,端口不同就是跨域。这时候最舒服的做法是让前端开发服务器转发请求,让浏览器以为自己在访问同源接口。

Vue CLI 的vue.config.js里这么写:

module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };

Vite 的写法略有不同,但思路一样:

export default { server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } };

changeOrigin: true这个参数一定要加,它的作用是让代理服务器在转发时把Host头改成目标地址的域名。有些后端框架会根据Host头做校验或者生成回调地址,不改的话会出各种奇怪的问题。pathRewrite则用来去掉前缀,让后端收到的还是原本的路径。

这里有个很多人问的问题:配了代理之后,怎么拿到自己的真实请求地址?答案是在浏览器控制台里看到的http://localhost:8080/api/user是发给代理的,代理再转给http://localhost:3000/user,真实的第三方地址前端根本看不到。如果你需要知道最终转发的地址,只能去看代理的日志,或者在后端打印req.originalUrl。这也是为什么代理方案只适合开发环境——它掩盖了真实的请求链路,线上出了问题没法排查。

2.4 方法四:Nginx 反向代理——生产环境的常规选择

线上没有devServer,代理这活儿交给 Nginx。核心就是配一个location,把匹配到的请求转发到后端,并且保持路径一致,让浏览器认为前后端同源。

server { listen 80; server_name www.a.com; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend: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; proxy_set_header X-Forwarded-Proto $scheme; } }

proxy_pass末尾那个斜杠是精髓,加不加结果完全不同。写成http://backend:3000/会做路径替换,/api/user变成/user;写成http://backend:3000不做替换,后端收到的还是/api/user。这个细节我在给一个团队做迁移的时候专门强调过,他们的后端接口路径本来就带/api前缀,结果配了斜杠之后全部 404,找了一小时才想起来是这里。

X-Forwarded-For和X-Forwarded-Proto这两个头也建议加上。前者用来让后端拿到客户端真实 IP,否则日志里全是 Nginx 的地址;后者让后端知道用户实际用的是 http 还是 https,涉及生成回调链接、判断安全 Cookie 的时候会用到。

2.5 方法五:同源部署——让跨域从根上不存在

这一条严格说不是"解决"跨域,而是"消除"跨域。把前端静态资源和后端接口挂在同一个域名、同一个端口下,浏览器压根不会触发同源检查,什么 CORS、什么预检,统统不需要。

具体做法有两种。一种是后端直接托管前端打包产物,比如 Spring Boot 把dist目录配置成静态资源目录,Node 的 Express 用express.static指向dist。另一种是 Nginx 同时承担静态资源服务和接口转发,就是我上面那个配置——location /给前端,location /api/给后端。

提示:同源部署还有个附带好处,Cookie 的SameSite属性可以设成Lax甚至Strict,安全性比跨域场景下的None高不少。如果你的项目对安全有要求,能从架构上做成同源就不要偷懒走 CORS。

这种方式最适合中小项目,一套 Nginx 配置全搞定。但它也有代价:前后端必须一起发版,前端改了 Nginx 里的路径规则,后端也得跟着调。如果团队是前后端完全分离、各自独立发布的模式,这个方案的协调成本会比较高。

2.6 方法六:postMessage——窗口之间的通信通道

前面几种方案都在解决"页面和接口"的跨域,但还有一种场景是页面和页面之间的跨域,最典型的是父页面嵌入了一个第三方域名的 iframe,需要互相传数据。这时候postMessage就是标准答案。

父页面向 iframe 发消息:

const iframe = document.getElementById('child'); iframe.contentWindow.postMessage( { type: 'userInfo', data: { id: 1 } }, 'https://child.a.com' );

iframe 内部接收并回消息:

window.addEventListener('message', (event) => { if (event.origin !== 'https://parent.a.com') return; if (event.data.type === 'userInfo') { console.log('收到', event.data.data); event.source.postMessage({ type: 'ack' }, event.origin); } });

这里最重要的就是必须校验event.origin。message事件任何窗口都能发过来,如果你不判断来源,第三方页面可以给你的页面发任意消息,诱导你的代码执行危险操作。我在别人的项目里见过一段代码,收到消息直接eval(event.data),这等于开了一个远程代码执行的口子。

注意:第二个参数targetOrigin千万别写成*。写*意味着消息可以被发送到任意域名的窗口,如果 iframe 中途被跳转到恶意页面,你的数据就泄露出去了。一定要写明确的完整源。

2.7 方法七:WebSocket——协议层面不受同源限制

WebSocket 是个特殊存在。它的握手过程是一个 HTTP 请求,但这个请求不适用同源策略,浏览器不会因为源不同就拦截连接,服务端也不会自动检查来源。也就是说,ws://a.com的页面可以随意连接ws://b.com的服务,不需要任何 CORS 头。

这听起来很方便,但恰恰意味着安全责任全部落在服务端。因为谁都能连你的 WebSocket,你必须自己校验。常用的做法是在握手阶段检查Origin请求头:

const WebSocket = require('ws'); const wss = new WebSocket.Server({ noServer: true }); server.on('upgrade', (req, socket, head) => { const origin = req.headers.origin; const allowList = ['https://www.a.com']; if (!allowList.includes(origin)) { socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); });

同时还得做身份鉴权,通常是在连接 URL 上带一个短时效的 token,服务端验证通过才建立连接。我见过一些项目直接用 WebSocket 传敏感数据却不做任何校验,这就相当于把一个没有门锁的接口挂在公网上。

WebSocket 适合的场景是实时推送、协同编辑、在线聊天这类需要长连接和双向通信的业务。只是为了传个表单数据就用它,属于杀鸡用牛刀,复杂度反而上去了。

2.8 方法八:document.domain——主域相同时的降级手法

当两个页面来自同一个主域的不同子域,比如a.example.com和b.example.com,可以通过把双方的document.domain都设成example.com来让它们"看起来同源",从而互相访问 DOM 或者共享 Cookie。

// 两个页面都执行这一行 document.domain = 'example.com';

这个方案现在已经被废弃了。Chrome 从 115 版本开始,如果响应头带Origin-Agent-Cluster: ?1,设置document.domain会直接报错,因为这种写法会破坏源隔离带来的安全边界。而且它只能解决相同主域的情况,主域不同就没戏。

注意:新项目绝对不要用这个方案。维护老系统的时候如果看到这行代码,先确认一下 Chrome 版本和响应头,很可能升级之后就直接挂了。现在推荐的做法是postMessage加明确的源校验,或者干脆把子域合并成一个源。

2.9 方法九:服务端转发与网关聚合

最后一种是彻底绕开浏览器限制的思路:既然浏览器不让我跨域,那我让自己的后端去请求第三方接口,前端只跟自己的后端打交道,自然就不跨域了。

用 Node 做一个很简单的转发:

const express = require('express'); const axios = require('axios'); const app = express(); app.get('/api/weather', async (req, res) => { try { const resp = await axios.get('https://third-party.example.com/weather', { params: { city: req.query.city }, timeout: 5000 }); res.json(resp.data); } catch (err) { res.status(502).json({ message: '上游服务异常' }); } });

这种方式的好处是前端调用路径统一,不用关心第三方接口的域名;坏处是后端多了一层转发,接口耗时增加,而且必须做好超时和错误处理,否则第三方接口一慢,你的后端线程全被占满。

如果公司有统一的 API 网关,那就把这件事交给网关做。网关可以根据路径规则把/third/weather路由到第三方,同时统一处理鉴权、限流、日志。这种方式在微服务架构里很常见,前端只需要知道一个网关地址就够了。

3. 从本地开发到线上部署的完整实操链路

前面讲了 9 种方法,但实际项目里你往往不是只用一种,而是组合使用。这一节我把一个典型 Vue + Node 项目从本地开发到上线部署的完整链路走一遍,把每一步为什么这么做讲清楚。

3.1 本地开发阶段的配置顺序

第一步,先确认你能改哪一端。如果后端就在同一台机器上跑,你其实有两条路:让后端加 CORS 头,或者让前端配代理。我的建议是优先配代理,原因是 CORS 头一旦加上,很容易忘记在生产环境收窄白名单,留一个*在那里,等保测评的时候会被挑出来。代理只在开发服务器生效,打包之后自动失效,不会有这个隐患。

第二步,配置路径前缀。约定所有接口以/api开头,前端请求统一写相对路径:

const service = axios.create({ baseURL: '/api', timeout: 10000 });

用相对路径而不是绝对地址,好处是开发环境和生产环境可以复用同一份代码。开发环境/api被 devServer 拦下来转发,生产环境/api被 Nginx 拦下来转发。如果你写死了http://localhost:3000,打包上线之后必然要改代码,一不小心就漏了。

第三步,处理凭证。如果需要带 Cookie,:withCredentials必须设成true:

const service = axios.create({ baseURL: '/api', timeout: 10000, withCredentials: true });

同时后端要配Access-Control-Allow-Credentials: true,并且Allow-Origin不能用*。这三处必须同时对,少一处登录态就丢。

3.2 打包部署后接口 404 的两种典型成因

项目上线之后最常见的现象是页面能打开,接口全 404。这种情况我遇到过很多次,成因基本就两类。

第一类是代理规则没写到 Nginx 里。开发环境靠devServer.proxy转发,打包之后devServer不存在了,必须由 Nginx 接手。很多人以为打包会把代理配置一起打进去,实际上vue.config.js里的devServer只在开发期生效,vite.config.js里的server.proxy同理。所以上线前一定要检查 Nginx 配置里有没有对应的location。

第二类是路径前缀被吃掉了。比如前端请求/api/user,Nginx 配的是proxy_pass http://backend:3000/,末尾带斜杠,那么转发过去就变成了/user,而后端的路由是/api/user,自然 404。解决方式有两个:把proxy_pass末尾的斜杠删掉,或者在pathRewrite里把前缀去掉,两边保持一致就行,关键是前后端要约定好前缀归谁处理。

提示:排查这类问题最快的办法是看 Nginx 的 access log,里面会打印实际的请求路径。tail -f /var/log/nginx/access.log,然后刷新页面,看请求打到哪了、返回什么状态码,一目了然。比在前端反复改配置快得多。

还有一个容易忽略的点:如果前端用的是 history 模式路由,Nginx 里必须加try_files $uri $uri/ /index.html;。否则用户刷新www.a.com/user/123这种地址,Nginx 会去找真实的文件路径,找不到就 404。这个跟跨域没关系,但经常和跨域问题一起出现,容易被混为一谈。

3.3 生产环境的最终形态

一个稳定运行的生产环境,通常是这样的形态:Nginx 监听 443 端口,证书配好,location /指向前端静态目录,location /api/转发到后端服务,后端服务只在内网监听,不暴露公网端口。这样前端和后端在浏览器看来是同一个源,跨域问题从根本上不存在。

如果架构上确实需要跨域,比如前端部署在对象存储、后端在另一台服务器,那就老老实实配 CORS。这时候要注意三点:白名单用具体的域名列表而不是*,Max-Age设大一点减少预检次数,Expose-Headers把需要前端读取的自定义头都列上。这三条做好了,基本不会出问题。

4. 报错信息对照表与排查手法

跨域的报错信息其实很明确,浏览器会告诉你具体是哪一条规则没满足。难点在于报错文本比较长,很多人扫一眼就去搜解决方案,其实里面已经把答案写出来了。

4.1 常见控制台报错逐条翻译

报错关键词真实含义处理方向
No 'Access-Control-Allow-Origin' header is present响应里根本没有这个头服务端加 CORS 配置,或检查中间件是否生效
has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header contains multiple values这个头出现了两次检查 Nginx 和业务代码是不是都加了一遍
credential mode is 'include' but the 'Access-Control-Allow-Origin' header is '*'带凭证时用了通配符把*换成具体源并回显
Request header field authorization is not allowed自定义头没在允许列表里在Allow-Headers里补上
Method PUT is not allowed方法没在允许列表里在Allow-Methods里补上
Response to preflight request doesn't pass access control check预检请求本身失败了检查 OPTIONS 请求是否被鉴权中间件拦截
net::ERR_CONNECTION_REFUSED压根没连上这不是跨域问题,是服务没起来或端口不对

这张表里最后一行特别值得强调。很多问题看起来像跨域,其实是服务根本没启动、端口写错了、或者防火墙拦了。请求发不出去的时候浏览器不会给你 CORS 相关的提示,只有一个连接错误。所以排查的第一步永远是确认目标服务是否可达,别一上来就改 CORS 配置。

4.2 排查顺序:从浏览器到网关逐层定位

我自己的排查顺序是这样的,从外往里一层层剥。

第一步,打开浏览器开发者工具的 Network 面板,找到那条失败的请求,看它的Request URL和Status。如果是(failed)或者net::开头的错误,说明请求根本没到服务端,检查地址和网络。如果是 200 但带 CORS 报错,说明服务端返回了,只是头不对。

第二步,看有没有OPTIONS请求。如果只有一条失败的请求,没有OPTIONS,说明是简单请求,问题出在响应头缺失。如果有OPTIONS且它失败了,那问题就在预检配置上。

第三步,点开请求的Headers标签,看 Response Headers 里到底有没有Access-Control-Allow-Origin,值是什么。这一步能直接定位问题。如果没有这个头,说明你的 CORS 中间件没生效;如果有两个,说明重复添加了。

第四步,如果浏览器里看不出问题,用curl直接打服务端,绕过浏览器的所有限制:

curl -i -X OPTIONS http://backend:3000/api/user \ -H "Origin: https://www.a.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type,authorization"

看返回的响应头。如果curl能拿到正确的 CORS 头,但浏览器还是报错,那问题一定在浏览器和你们服务之间的中间层上,比如 CDN、负载均衡、WAF。

4.3 几个容易被忽略的坑

坑一:响应头被中间层覆盖。有些 CDN 或者网关会重写响应头,把你后端设的Access-Control-Allow-Origin改成自己的值,或者干脆删掉。我就遇到过这种情况:直连后端一切正常,走域名就报错,最后发现是 CDN 的响应头改写规则在作怪。

坑二:错误响应没有 CORS 头。很多框架的 CORS 中间件只对正常响应加头,一旦业务代码抛异常走了全局异常处理器,头就没了。结果就是接口正常时能调通,报错时浏览器提示的是 CORS 错误,把真正的业务错误掩盖了。解决方式是在异常处理器里也加上 CORS 头,或者把 CORS 中间件放在最外层。

坑三:Cookie 的 SameSite 属性。跨域带 Cookie 时,SameSite必须是None,而且必须同时设Secure,也就是说必须走 HTTPS。如果你在 http 环境下测跨域 Cookie,浏览器会直接拒绝写入。这个坑在本地测试第三方登录回调的时候特别常见。

坑四:Max-Age设得太大。有人为了减少预检请求,把Access-Control-Max-Age设成 86400 甚至更大。结果后来改了允许的方法或头,前端一直不生效,因为预检结果被缓存了。调试阶段建议设小一点,稳定之后再调大。

坑五:多个域名共用一个后端。如果有多个前端域名要调同一个后端,回显的时候要注意,Access-Control-Allow-Origin只能填一个值。这时候要么用白名单动态回显,要么用 Nginx 做一层分发,让每个域名走各自的location。

5. 九种方案的选型对照与我的实际取舍

讲完了原理和实操,最后一节聊聊怎么选。我把这 9 种方案的关键特性整理成一张表,方便你对号入座。

5.1 一张表看清每种方案的适用边界

方案适用场景能否带 Cookie是否改动后端主要缺点
JSONP老系统对接、只读接口不能需要只支持 GET,无错误处理,安全性差
CORS前后端分离的正式方案能需要配置项多,容易漏
开发代理本地调试能不需要仅开发环境有效
Nginx 反代生产环境常规做法能不需要需要运维配合
同源部署中小项目、单体架构能需要前后端发版耦合
postMessage页面与 iframe 通信不涉及不需要需严格校验来源
WebSocket实时推送、双向通信不涉及需要安全责任全在服务端
document.domain老系统遗留能不需要已被废弃,新项目禁用
服务端转发调第三方接口能需要增加一层转发开销

看完这张表,其实结论已经很清楚了。新项目要做前后端分离,就走 CORS;能做成同源就做成同源;本地开发一律用代理;跨窗口通信用 postMessage;实时业务用 WebSocket;调第三方接口用服务端转发。JSONP 和document.domain留着看老代码用,别在新代码里出现。

5.2 我自己常用的一套组合

以我现在手上的项目为例,前端是 Vue,后端是 Node,本地开发环境用 Vite 的server.proxy把/api转发到localhost:3000,前端代码里所有请求都用相对路径,不带任何域名。生产环境用 Nginx 部署,静态资源和接口在同一个域名下,location /api/转发到内网的 Node 服务,浏览器看来完全是同源,一行 CORS 配置都不需要写。

需要调第三方接口的时候,我在 Node 层加一个转发路由,前端调自己的/api/proxy/xxx,由后端去请求第三方。这样第三方接口的密钥不会暴露在前端,超时和重试也能统一控制。唯一需要注意的是加上超时和熔断,别因为第三方接口慢把整个服务拖垮。

如果哪天因为业务需要,前端确实要部署到别的域名上,那我会在 Nginx 层面加 CORS 头,用一个白名单变量动态回显Origin,同时把Max-Age设成 600 秒,方便调试。这样即使出问题,十分钟之后缓存也会过期,改了配置就能看到效果。

提示:CORS 配置上线之前,一定要用真实的域名测一遍完整的预检流程,别只在 Postman 里测。Postman 不执行同源策略,你在里面调一百遍都是通的,一放到浏览器里就全红。这是我见过最多的"明明我测过了"的翻车现场。

最后再分享一个小经验:如果你的项目里出现了"有时候能调通、有时候不行"这种玄学现象,八成是预检缓存和某个中间层的头重写共同作用的结果。这时候别急着改代码,先打开无痕窗口复现一遍,无痕模式不共享缓存,能最快确认是不是缓存问题。确认之后再逐层看头,比盲目改配置效率高得多。

返回列表