1. 从"为什么会有OPTIONS请求"说起:CORS的预检机制
很多人第一次发现跨域问题,是在浏览器Console里看到一行红色报错:Access to XMLHttpRequest at 'xxx' from origin 'yyy' has been blocked by CORS policy。点开Network面板,里面躺着一个让人摸不着头脑的OPTIONS请求,状态码可能还是200,但前端就是拿不到数据。这个OPTIONS到底是什么、为什么它在正式请求之前出现、又为什么有时候有、有时候没有——不把这些搞清楚,跨域问题就永远是玄学。
先解释一下机制。整个CORS流程里,浏览器会把跨域请求分成两类:简单请求和非简单请求。简单请求要求同时满足几个条件:方法是GET、HEAD、POST三者之一;Content-Type只允许application/x-www-form-urlencoded、multipart/form-data、text/plain;不能自定义额外的请求头。只要超出这个范围,比如使用application/json传参、加了自定义Header(像Authorization、X-Requested-With)、或者用了PUT/DELETE,浏览器就会认为这是一个"可能对服务器数据产生副作用的请求",于是先发一个OPTIONS请求去打探一下——服务器到底允不允许跨域、允许哪些方法、允许哪些请求头、允许哪些来源。
服务端必须在OPTIONS的响应里通过Access-Control-Allow-*系列响应头明确"放行规则",浏览器才会把真正的业务请求发出去。这个过程在CORS规范里叫预检,英文术语是Preflight。所以我们可以直接把OPTIONS请求理解成"正式请求的探路先锋"。
这套设计不是浏览器闲得慌。浏览器出于安全模型考虑,本身不允许一个源的页面直接读取另一个源的资源响应,这就叫同源策略。如果完全不允许跨域,像前端调用API、加载CDN这类场景就全部瘫痪;如果完全放开,任何一个恶意网站都能以用户身份去读取银行的数据。CORS就是在这两者之间找平衡——合理放行、明确授权。OPTIONS预检正是授权检查的执行环节。
网络热词里有一条"跨时钟域",它和跨域里的"域"字面相同但完全是两个概念——硬件数字电路里的时钟域跨越问题,不在CORS讨论范围,别搞混。但有意思的是,这两者背后有相似的思维:跨边界访问都需要一套明示的握手协议来保证安全,而不是天然默认允许。
好,机制背景清楚了,下面把OPTIONS相关的几个关键问题逐个拆开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 什么时候触发OPTIONS:简单请求与非简单请求的分界线
很多开发者最困惑的一点是:为什么同样调后端接口,有些请求会多一次OPTIONS,有些不会?答案就在"简单请求"和"非简单请求"的分类上。搞清楚这条分界线,不仅能解释现象,还能在写代码的时候主动避开不必要的预检,从而减少一次网络往返。
2.1 简单请求的判定条件
按规范,一个请求要成为简单请求,必须同时满足以下条件:
- 方法限于GET、HEAD、POST三者
- 除浏览器自动带上的请求头外,只能手动设置特定集合里的请求头(如Accept、Accept-Language、Content-Language、Content-Type等)
- Content-Type仅限于三种值:
application/x-www-form-urlencoded、multipart/form-data、text/plain - 不能使用
XMLHttpRequest上传事件监听器 - 请求中不能使用
ReadableStream对象
对应到实际开发里,最常见的触发条件就是两个:用了application/json、加了自定义请求头。只要踩中其中之一,浏览器就会补一次OPTIONS。
试想一个场景:你调用后端登录接口,用fetch发送一个JSON对象,代码是这样写的:
javascript复制fetch('https://api.example.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Version': '2.1.0'
},
body: JSON.stringify({ username: 'admin', password: '123456' })
})
这个请求同时踩中了"非简单Content-Type"和"自定义请求头"两个雷区,必然触发预检。浏览器会先发送一个这样的OPTIONS请求:
code复制OPTIONS /login HTTP/1.1
Host: api.example.com
Origin: https://web.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-client-version
注意Access-Control-Request-Headers的值,浏览器会把前端设置的Content-Type和X-Client-Version统统列出来,并转成小写。服务端看到这些头之后,必须在响应里明确告诉浏览器:我允许哪一个来源、允许POST、允许哪些请求头。任何一项对不上,预检就失败,正式请求不会发出。
2.2 为什么现在POST最常见的反而是OPTIONS
如果你说"我没用JSON也没自定义头,为什么还是看到了OPTIONS"——还有一种常见情况:跨域请求在浏览器自动判断下不是简单请求,但其实很多服务端框架默认就处理了OPTIONS,所以响应是200,导致前端看起来"多了一个成功请求"。
举个例子,一个普通的fetch请求如果设置了Content-Type: text/plain,方法是POST,按理说属于简单请求,不会预检。但如果你给请求加了credentials: 'include',同时服务端的Access-Control-Allow-Origin配置的是具体的某个来源而不是*,预检就不一定会发生——这里规范允许有差异,主流浏览器对withCredentials的简单请求不会预检。
真正需要警惕的是某些前端库或请求封装层悄悄改了默认行为。比如axios在浏览器环境用XHR,如果你没有显式设置Content-Type,它默认使用application/json;charset=utf-8——这是非简单Content-Type,所以每个POST都会先发OPTIONS。这是导致"为什么axios发POST必然有两次请求"的经典原因。
2.3 从浏览器视角验证
想知道一个请求到底是不是简单请求,最快的办法是直接看Network面板:如果有OPTIONS请求出现在业务请求前面,就说明这个请求被判定为非简单。你可以在Console里手动敲一行不带Content-Type的fetch、用默认表单方式POST,再对比一下加Content-Type: application/json的版本——两次Network记录之间的差异,就是预检触发条件最直观的教学。
3. OPTIONS预检请求的响应头全解:后端需要返回什么
预检请求到达后端以后,如果没有框架自动处理,后端会把它当成普通请求并返回。这里有一个隐藏问题:很多后端/中间件默认只处理业务路由,OPTIONS /login这个请求甚至可能404。也就是说,不是后端不配置CORS,而是预检请求压根没有进入业务处理逻辑。先理解响应头该怎么写,再理解在什么层级去写,排查会顺很多。
3.1 标准响应头逐个拆解
一个成功的预检响应,大致长下面这样:
code复制HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://web.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Custom-Header
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
Vary: Origin
各头作用如下:
| 响应头 | 作用 | 关键注意点 |
|---|---|---|
| Access-Control-Allow-Origin | 声明允许哪个来源访问资源 | 可以是具体Origin,也可以是*;一旦使用*就不能与Allow-Credentials共存 |
| Access-Control-Allow-Methods | 声明允许哪些HTTP方法 | 必须覆盖前端Access-Control-Request-Method声明的方法 |
| Access-Control-Allow-Headers | 声明允许哪些请求头 | 必须覆盖前端Access-Control-Request-Headers里所有头;可以写*,但旧浏览器兼容不佳 |
| Access-Control-Max-Age | 预检结果的缓存时间(秒) | 减少重复预检;但更新CORS配置后可能因缓存不生效 |
| Access-Control-Allow-Credentials | 是否允许携带凭证(Cookie等) | 为true时Allow-Origin不能是* |
| Access-Control-Expose-Headers | 允许前端JS读取哪些响应头 | 前端想读自定义响应头时需要配置 |
规范里比较贴心的一点:Access-Control-Allow-Headers如果缺失,某些实现会反过来要求浏览器必须精确匹配;只要前端声明的自定义头里有一个不在Allow-Headers列表里,预检就失败。我排过不少"明明看起来响应头都对,前端还是报错"的问题,最后都是Allow-Headers少写了一个头,或者大小写不一致导致的。
3.2 为什么OPTIONS不能直接返回业务响应
有一种特殊场景:后端在处理OPTIONS时,没有在响应头写入任何CORS相关头,而是直接返回了业务的正常响应(比如某个POST接口的鉴权结果)。前端这时会看到预检请求返回200,但Console依然报CORS错误。原因特别直接——预检响应里缺少Access-Control-Allow-Origin,浏览器不认这次预检通过,后续请求不会发出去。
有个容易让人误判的点:OPTIONS响应是有状态码的,204是规范建议的标准状态码;如果后端返回200但响应体里带了一堆业务JSON,很可能是路由把OPTIONS错误地转发到了业务处理器。这种情况下状态码是200,浏览器却依然阻止后续请求,Console里的报错就能看出来。
3.3 网关层返回和业务层返回的区别
在很多后端项目里,业务代码并不直接处理OPTIONS——反而是网关、Nginx、Spring Security过滤器等等在最外层返回了预检响应。这个设计的合理性在于:CORS策略本质上属于站点级安全策略,应该让统一入口处理,而不是让每个接口都重复实现。
一个典型的Nginx配置片段如下:
nginx复制location /api/ {
if ($request_method = 'OPTIONS') {
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-Max-Age 86400;
return 204;
}
# 其余请求转到后端服务
proxy_pass http://backend_server;
}
但注意:Nginx的add_header有一个容易踩的坑——如果当前配置块里还有其他add_header指令,Nginx默认只会继承最内层定义的header,不会继承外层已有的同名header。也就是说,如果用add_header在location里加了CORS头,而同一location里又加了其他响应头,CORS头有可能会被覆盖或遗漏。这块我后面专门讲。
4. 一个典型的全链路排查:从浏览器报错到后端日志
跨域排查最怕的是"猜"。我见过开发者在服务端配置了一堆CORS代码,结果发现根本没生效;也见过折腾半天,最后发现是少写了协议头,http://和https://不一致导致Origin不匹配。下面用一个模拟场景串起完整的排查链路,你可以照着这个思路来。
4.1 场景回放与日志对照
假设页面在http://localhost:5173,接口在http://api.example.com。前端用axios发起POST请求,直接触发预检。浏览器Console报错内容通常有两类:
Access to XMLHttpRequest at 'http://api.example.com/user' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource....The 'Access-Control-Allow-Origin' header has a value 'http://api.example.com' that is not equal to the supplied origin.
报错字面已经给了很明确的线索。第一类是说预检响应里根本没有Access-Control-Allow-Origin头——通常是后端没配置,或者配置没生效;第二类说的是Allow-Origin的值和前端的Origin不匹配——通常是后端配置里写死了某个地址,而当前前端的协议/域名/端口不在白名单里。
这时候不要盲目改代码,先去后端访问日志确认两件事:这个OPTIONS请求到底到达后端没有、后端的响应状态码和响应头是什么。用curl模拟是最快的方式:
bash复制curl -i -X OPTIONS 'http://api.example.com/user' \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type'
-i会打印完整响应头,手动加上Origin和Access-Control-Request-*头就能复现浏览器的预检。如果curl返回的响应头里没有Access-Control-Allow-Origin,说明问题一定在后端配置;如果有,说明可能是浏览器拿到响应前的某一层(代理、缓存)出了变故。
4.2 排查过程中最容易被忽略的"前置层"
很多人改完服务端配置,满心欢喜刷新页面,发现还是报错。一个容易被忽略的地方是开发代理。本地开发时,前端常常配了proxy(Vite或webpack-dev-server),让/api开头的请求代理到目标服务器。这种情况下浏览器里看到的请求路径是http://localhost:5173/api/user,请求是同源的,CORS根本不会触发,因为代理层把请求转发到后端、再把响应传回——对浏览器而言,这相当于"前端页面请求了同一个服务器的接口"。
反过来,如果代理没有正确转发,或者前端直接写了http://api.example.com的完整地址,才会真正触发跨域。所以排查第一步应该先看Network里的请求URL:如果是localhost:5173,说明走了代理;如果是api.example.com,说明是直连。前者的问题出在代理配置,后者的重点才是CORS。
还有一层是浏览器插件。某些安全类插件会在响应头里插入额外内容,甚至拦截OPTIONS请求。遇到非常诡异的"同一段代码,同事的浏览器正常,我的浏览器报错",先把浏览器的扩展全部禁用再试一次,往往会有惊喜。
4.3 响应头明明有Access-Control-Allow-Origin,为什么还是报错
还有一种经典场景:后端返回的响应头里确实有Access-Control-Allow-Origin: *,但浏览器依然拦截。原因有几个可能:
- 请求设置了
withCredentials为true,而Allow-Origin是*,两者冲突,浏览器直接拒绝。 - 后端返回的响应头里
Access-Control-Allow-Origin虽然存在,但被代理层/网关层重写了,实际浏览器收到的是另一个值。 - 如果预检请求返回的是3xx重定向,浏览器会认为预检失败,因为重定向会丢失CORS头。
针对第一种可能,解决方案是把Allow-Origin从*换成前端真实的Origin来源,并且加上Access-Control-Allow-Credentials: true。你也可以在前端临时去掉withCredentials试试,如果去掉之后不再报错,那问题就定位在凭证策略上。
5. 后端到底该怎么配?主流框架的CORS配置范式
讲完原理和排查,下面给实际操作方案。不同后端框架对CORS的处理方式有很大差异,有的框架依赖Spring Security的filter链,有的框架自己实现了CORS处理器。完整给出所有框架代码不现实,我这里把最主流的Spring Boot、Express/NestJS、以及Nginx网关三种情况展开,给出可直接用、可解释的配置。
5.1 Spring Boot:从Filter到WebMvcConfigurer
在Spring Boot里,实现CORS的常见方式有两种。一种是写一个Filter,自己判断OPTIONS并添加响应头。好处是思路直白,不依赖框架的版本特性:
java复制@Component
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
HttpServletRequest request = (HttpServletRequest) req;
String origin = request.getHeader("Origin");
if (origin != null && origin.startsWith("http://localhost")) {
response.setHeader("Access-Control-Allow-Origin", origin);
}
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Max-Age", "86400");
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
return;
}
chain.doFilter(req, res);
}
}
注意两个细节:判空Origin很重要,因为非跨域请求或某些服务端类型的请求不带这个头;对OPTIONS直接返回204并终止过滤器链,避免业务拦截器继续处理预检请求。很多项目在预检上栽跟头,就是因为Filter设置了头部,但后续的权限拦截器又把请求拦截了,没有真正走到写响应头之后的代码。
另一种是Spring Boot的官方CORS支持,配置类更简洁,并且能够和Spring Security联动:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("http://localhost:*", "https://*.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(86400);
}
}
注意allowedOriginPatterns和allowedOrigins的区别:如果开启了allowCredentials(true),allowedOrigins不允许再使用*,但allowedOriginPatterns支持通配符匹配,是官方推荐的做法。使用allowedOrigins直接指定一个固定的http://localhost:5173是可行的,线上环境替换成自己的域名就行。
5.2 Node.js生态:Express与NestJS
Express场景下最省事的做法是使用cors中间件。这个npm包把CORS头处理好了,包了一层很友好的API:
javascript复制const cors = require('cors');
app.use(cors({
origin(origin, callback) {
// 白名单判断:允许本地调试来源,线上来源从环境变量读取
const allowList = ['http://localhost:5173', 'https://admin.example.com'];
if (!origin || allowList.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
}));
// 让所有请求显式经过CORS中间件
app.options('*', cors());
origin回调里的!origin判断很关键:很多同源请求或非浏览器请求不会带Origin头,如果不放行,会导致服务端脚本或部分监控系统无法访问API。而官方cors包默认对OPTIONS请求也会做处理,但如果你把中间件挂在具体路由之前,并且请求不是简单请求,app.options('*', cors())这一行可以确保任意路径的预检都有响应,不会因为路由不匹配而404。
NestJS基于Express内核,推荐使用enableCors方法:
typescript复制const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ['http://localhost:5173', 'https://admin.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
});
await app.listen(3000);
NestJS底层会把enableCors转成cors中间件的配置,所以原理和Express完全一致。
5.3 Nginx头覆盖问题:add_header的继承规则
Nginx处理CORS会有一个特别混淆的细节。add_header指令的继承规则是:只有在当前层级没有定义任何add_header时,上一层的add_header才会生效。这意味着如果你在server块定义了通用响应头,在location块又定义了一个Access-Control-Allow-Origin,那location块里的响应头会完全覆盖server块的所有add_header,而不是"合并"。
实际案例:一个接口反向代理到后端,我需要同时输出CORS头和X-Frame-Options安全头。我把CORS头放在location里,把X-Frame-Options放在server里,结果跨域调试时发现X-Frame-Options丢了,而CORS是正常的。原因就是location里的add_header让server层的add_header全部失效。解决办法是把所有需要输出的头部统一放到location层:
nginx复制location /api/ {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization' always;
add_header X-Frame-Options 'SAMEORIGIN' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass http://backend_server;
}
用$http_origin而非写死域名,配合always参数,能避免某些场景下默认只有在响应码为200/201/204等成功码时才会添加响应头的限制。如果你使用后端框架来生成CORS头(比如Spring Boot),那么Nginx这一层就不需要重复添加,重复添加反而可能造成头冲突或二义性。
6. OPTIONS请求的怪癖行为与排查Checklist
看完配置,再聊几种容易让人困惑的行为。这部分我把个人调试过程中见过的各类"非典型"OPTIONS现象梳理一下,附上定位思路。
6.1 为什么有时候OPTIONS不见了:预检结果缓存
浏览器为了性能,会把预检结果缓存起来,缓存时长由Access-Control-Max-Age控制。也就是说,一个页面在短时间内多次跨域请求同一接口,只在第一次发送较慢的OPTIONS,后续请求直接跳过预检。这是正常优化,不是请求丢了。
但缓存特性也会带来麻烦。我遇到过一种情况:后端调整了Access-Control-Allow-Methods,把某个方法从白名单里去掉了,但前端联调时旧请求头还被浏览器缓存。前端开发者刷新页面、清Cache、强刷都没用,因为在同一浏览器会话内,预检缓存依然有效——有时代码改完了,但预检缓存里的旧规则还在,导致测试结果看起来"没有生效"。这时最简单的办法是关闭浏览器标签页重新打开,或者临时给Access-Control-Max-Age设一个很小的值(甚至设为0),等调试完再改回来。
这也是为什么Chrome的Network面板里偶尔能看到OPTIONS请求的状态是(from disk cache)或者干脆没有这条记录。如果想让开发阶段每次都能看到完整的预检过程,可以在DevTools的Network里勾选"Disable cache",它能绕过HTTP缓存的干扰。
6.2 同源判断的移动端注意点
移动端WebView场景也有一些CORS相关的坑。最经典的来自file://协议——页面如果是以file://方式打开的,它的Origin值是字符串"null",浏览器向任何http接口发跨域请求时,Origin头都是null。此时如果服务端配置的是具体域名白名单,预检永远无法通过;如果用curl手动指定Origin: null,又会发现后端收到的是字符串"null"而不是"无来源"。
解决办法是把页面放到本地服务器上访问,比如用http://localhost:端口起一个静态服务器,前端页面Origin就变成一个正常HTTP来源。如果项目必须支持file://打开的页面(例如某些本地工具前端),那么后端需要把null也加入Allow-Origin白名单,但需要注意,这实际上会让任何"无来源"请求都可以跨域访问,安全上要谨慎。
另外,WebView内核的差异也值得一提。Android系统WebView在4.2以前对CORS支持非常不完整,很多老设备根本不发预检请求。开发阶段尽量用高版本内核调试;如果非要兼容旧内核,服务端的做法只能是配置最宽松的Access-Control-Allow-Origin: *(前提是不需要携带凭证),否则很难让旧浏览器按照你期望的规则去工作。
6.3 为什么总是看到"Access-Control-Allow-Origin不能为*"的错误
再补充一个和后端框架相关的点。前端经常喜欢在fetch或axios里加上credentials: 'include';这通常是"为了让Cookie跟着请求走"。但如果后端配置的Access-Control-Allow-Origin是*,浏览器会在预检阶段就明确报错,因为规范允许携带凭证的请求不能把Allow-Origin设置成*。这个设计的初衷是:*表示"任何来源都可以",而携带凭证的请求意味着后端可能要识别用户身份,两者组合会让安全边界失效。
针对这个场景,后端要改成"返回当前请求Origin的具体值"。Spring Boot的allowedOriginPatterns就是为此设计的;手动Filter也可以直接response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")),但要确认自己做了白名单判断,而不能把任意Origin原封不动返回给任意请求——否则任何钓鱼页面都能带着用户Cookie跨域请求你的接口了。
6.4 排查Checklist
把这些经验浓缩成一份可以直接上手的清单:
- 先确认请求是否真的跨域:Network面板看请求URL,如果是同一个域名,检查代理配置。
- 确认请求是否属于非简单请求:看方法、Content-Type和自定义Header,判断是否会有预检。
- 看后端Access Log里有没有OPTIONS记录:没有就是请求没到达后端(网关/代理/浏览器插件层拦截)。
- 用curl模拟预检:带上
Origin和Access-Control-Request-*头,看响应头和状态码。 - 检查
Access-Control-Allow-Origin和请求的Origin是否完全匹配,包含协议、域名、端口。 - 检查
Access-Control-Allow-Headers是否包含前端请求的所有自定义Header,且大小写不敏感匹配。 - 检查Allow-Methods是否包含预检请求中的
Access-Control-Request-Method。 - 如果携带了credentials,确认Allow-Origin不是
*,且后端设置了Access-Control-Allow-Credentials: true。 - 检查是否有安全拦截器、过滤器把OPTIONS请求拦截,或对它做了重定向。
- 确认是否存在Nginx add_header覆盖问题,保证所有头部统一在一个层级配置。
- 如果配置突然"不生效",尝试清掉浏览器预检缓存(新开无痕窗口最快)。
- 最后,真机WebView有问题而PC浏览器正常时,优先怀疑内核版本和file协议下的Origin为"null"。
7. 借OPTIONS理解整个CORS设计:安全模型与你的责任边界
聊了这么多OPTIONS的细节,有一件事需要强调:CORS表面上是浏览器的"跨域限制",但实际上它是浏览器和服务器协同完成的策略。浏览器负责拦截、发送预检;服务器负责声明允许谁。你做的前端配置、后端配置,本质上都是在两边准备配套的规则。
也正因为如此,解决跨域问题不能只盯着一端。前端的fetch加了credentials,后端就得处理Allow-Origin和Allow-Credentials;后端设置了Access-Control-Allow-Origin: *,前端携带Cookie的请求就会失败。每一方都有自己的责任区间,调试跨域时,我总是建议把它当成一个"两方协议"来处理,而不是"浏览器卡我了"。
认识Preflight不是为了让服务端"禁用"它、也不是为了绕过它。从工程角度出发,真正要做的只是确保响应头合法、缓存合理、白名单正确。很多开发者在遇到跨域问题时第一个想法是让后端直接Access-Control-Allow-Origin: *,图省事。但一旦项目涉及登录态、会话管理,这种"省事"配置很快就会埋下安全风险。规范设计出一个预检流程,不是为了给你增加额外请求,而是为了在"浏览器要发出高风险请求之前",给服务器一次足以确认策略的机会。合理利用这套机制,会让你的接口既对合法的跨域调用开放,又对非法的来源保持封闭。
我自己在实际项目里的体会是:好好规划CORS配置,是API设计的一部分。提前把哪些来源允许、哪些请求头必须通过、是否允许Cookie这些规则和业务一起定下来,就不会在联调阶段反复修补。跨域不是复杂的难题,它就是一套不太常被人认真读的协议。把它读透之后,再看到Network面板里的OPTIONS请求,你会觉得它像一个尽责的安检员——只是工作方式稍微显眼了一点。
