在前后端分离的架构下,跨域请求几乎无法避免。不少开发者在浏览器控制台看到过类似“Response to preflight request doesn't pass access control check”的报错,或者在Network面板里发现真实请求发出之前,多了一个方法为OPTIONS的神秘请求。这个请求就是预检请求,英文叫Preflight Request。要理解它为什么存在、什么时候触发、服务端怎么正确响应,需要先从浏览器的同源策略和CORS机制说起。

同源策略与CORS的基本原理
浏览器出于安全考虑,实行同源策略:协议、域名、端口三者只要有一个不同,就被视为跨域。跨域时浏览器默认会限制页面脚本读取响应内容。但这并不意味着请求发不出去,而是请求发出后,响应被浏览器拦截,脚本拿不到数据。
为了在保证安全的前提下允许合法的跨域访问,W3C提出了CORS(Cross-Origin Resource Sharing,跨域资源共享)标准。它的核心思路是:浏览器在跨域请求中自动附加一些头部信息,例如Origin表示请求来源,服务端通过返回Access-Control-Allow-Origin等头部声明自己允许哪些来源访问,浏览器据此判断是否把响应放行给页面脚本。
关键在于,这个校验过程有两种模式:一种是直接发真实请求,浏览器拿到响应后检查头部,这就是简单请求模式;另一种是先发一个“试探性”的OPTIONS请求,确认服务端允许之后再发真实请求,这就是预检请求模式。预检的作用相当于浏览器替开发者先问一句:“我接下来要用PUT方法、带自定义头X-Token访问这个接口,你允许吗?”服务端明确同意后,真实请求才会发出。
哪些请求会触发预检:简单请求与非简单请求的界限
CORS规范中把不会触发预检的请求称为简单请求(Simple Request),它必须同时满足几个条件:请求方法是GET、HEAD或POST;请求头只能包含Accept、Accept-Language、Content-Language、Content-Type等少数几个字段,且Content-Type仅限于text/plain、application/x-www-form-urlencoded和multipart/form-data三种;请求中没有注册ReadableStream对象等额外限制。
只要任何一条不满足,浏览器就会先发送预检请求。日常开发中最常见的触发场景有三个:第一,前后端交互普遍使用JSON格式,而Content-Type: application/json不属于简单请求允许的类型;第二,携带自定义头部,例如鉴权用的Authorization或X-Token;第三,使用了PUT、DELETE、PATCH等非简单方法。这也解释了为什么RESTful接口几乎每次都要面对预检。
预检请求本身是一个方法为OPTIONS的HTTP请求,浏览器会自动附带两个重要头部:Access-Control-Request-Method告知真实请求将使用的方法,Access-Control-Request-Headers列出真实请求会携带的非简单头部。注意预检请求是浏览器行为,前端代码无法跳过或取消它,任何试图在业务代码里“不发预检”的做法都是徒劳的。
服务端如何正确响应预检请求
服务端收到OPTIONS请求后,需要在响应中返回对应的CORS头部,告诉浏览器允许的操作范围。核心的响应头包括:Access-Control-Allow-Origin指定允许的来源,可以是具体的域名或者*,但如果请求携带Cookie,则必须返回具体域名且不能是*;Access-Control-Allow-Methods列出允许的方法;Access-Control-Allow-Headers列出允许的请求头,必须覆盖预检请求中Access-Control-Request-Headers声明的所有头部;Access-Control-Allow-Credentials设为true表示允许携带Cookie。
以Nginx为例,一个典型的跨域配置如下:
location /api/ {
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-Token";
add_header Access-Control-Allow-Credentials "true";
# OPTIONS 预检请求直接返回 204,不转发到后端
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:8080;
}后端框架的处理方式类似。以Spring Boot为例,可以通过全局配置类优雅地处理跨域和预检:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}这里有个容易踩的坑:有些后端接口对OPTIONS请求做了鉴权拦截,直接返回401,导致预检失败,真实请求根本发不出去。排查时要注意区分是预检失败还是业务请求失败,看Network面板中OPTIONS请求的状态码即可。预检请求不会携带业务自定义头的实际值,也不应该参与登录态校验,正确做法是在过滤器中把OPTIONS请求放行。
预检带来的性能开销与缓存优化
预检请求本质上多了一次完整的网络往返,对于接口密集的应用来说,每个请求前都先来一次OPTIONS,延迟会明显增加。尤其在移动端弱网环境下,这个开销不可忽视。好在CORS规范提供了预检缓存机制:服务端在预检响应中返回Access-Control-Max-Age头部,告诉浏览器这次预检的结果可以缓存多少秒。在缓存有效期内,同一来源对同一URL的相同类型请求不会再触发预检。
上面Spring Boot配置中的maxAge(3600)就是设置缓存一小时。需要注意的是,不同浏览器对最大缓存时间有上限,Chrome上限是两小时,Firefox曾经是二十四小时,超出上限的值会被截断。此外,缓存是以“来源+目标URL+请求条件”为粒度的,任何一项变化都会导致缓存失效重新预检。
如果应用对性能极其敏感,还可以从架构层面减少预检:比如让前端请求代理到同源服务(开发环境用webpack或Vite的proxy配置,生产环境用Nginx反向代理),跨域问题在网关层解决,浏览器视角里就是同源请求,自然不存在预检。这种方式在前后端分离项目中非常常见,也是生产环境推荐的方案之一。
常见问题排查思路
遇到跨域报错时,建议按以下顺序排查:先确认Network面板中是否真的发出了OPTIONS预检请求,以及它的响应状态码和响应头;再核对Access-Control-Allow-Origin与页面实际来源是否完全一致,注意协议和端口也要匹配;然后检查Access-Control-Allow-Headers是否漏掉了请求中使用的自定义头;最后如果请求携带了Cookie,确认Access-Control-Allow-Credentials为true且Origin不是*。
还有一个细节值得注意:某些开发者发现设置Access-Control-Allow-Origin: *后仍然报错,多半是因为请求中带了withCredentials,浏览器规范明确禁止通配符与凭证模式同时使用。理解了预检请求的完整流程和这些细节,跨域问题就不再是黑盒,排查起来会有清晰的思路。