一个再普通不过的跨域请求,打开 Network 却先看到一个 OPTIONS:
OPTIONS /api/article/save 204
PUT /api/article/save 200
你没写过 OPTIONS,请求却多了一次;有时它还会直接报错:
Request Method: OPTIONS
Status Code: 405 Method Not Allowed
Access to fetch at 'https://api.b.com' from origin 'https://a.com'
has been blocked by CORS policy: Response to preflight request
doesn't pass access control check.
这个自动冒出来的 OPTIONS 就是预检请求(preflight request)。它不是你的代码发的,是浏览器发的,用来在正式请求落地前先问服务器"准不准"。
一、预检到底是什么
浏览器发起跨域请求时,只要命中"非简单请求",就会先自动发一个 OPTIONS 请求去探路,问服务器是否允许稍后那次真正的跨域访问。服务器同意后,浏览器才发送真正的请求。
浏览器 服务器
① 预检 OPTIONS /api/save ─────────▶ 我可以用 PUT 和
Origin: https://a.com X-Token 吗?
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: x-token
◀───────── Access-Control-Allow-Methods: PUT
② 实际 PUT /api/save ─────────▶ 真正带业务数据的请求
关键点:预检失败时,真正的请求根本不会发出。这一点是理解它价值的前提。
二、为什么需要预检
同源策略出现的背景是:像 <form>、<img>、<script> 这类标签本来就能跨域发请求,而且请求会真的到达服务器、真的执行。同源策略只能阻止读取响应,拦不住请求本身。
也就是说,一个跨域 POST 完全可能已经把服务器数据改了,攻击者只是读不到返回结果。CORS 出现后:
- 简单请求:浏览器照样把请求发出去,CORS 只是不让你读响应——副作用已经发生了。
- 非简单请求:浏览器先发
OPTIONS预检,服务器明确同意后才发真正请求。副作用被提前拦住了。
所以预检的目的不是保护响应数据,而是在可能产生副作用的请求真正落地之前,给服务器一次否决的机会,同时确认服务器确实支持跨域,避免被跨站利用。
三、什么时候触发预检
不是所有跨域请求都会预检,只有"非简单请求"才会。简单请求需同时满足:
- 方法是
GET、HEAD或POST; - 请求头只在安全列表内:
Accept、Accept-Language、Content-Language、Content-Type、Range等; Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain;- 没有注册
XMLHttpRequestUpload事件监听,请求里也没用到ReadableStream。
只要有一项不满足,就触发预检。最常见的几种:
// 1. 用了非简单方法
fetch(url, { method: 'PUT' })
// 2. Content-Type 是 application/json
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
// 3. 带了自定义头
fetch(url, { headers: { 'X-Token': 'abc' } })
这也是为什么"带 token 的跨域接口"几乎必然会看到 OPTIONS——Authorization 或 X-Token 属于自定义头,直接命中预检。
四、请求头和响应头
预检请求携带(由浏览器自动加上):
OPTIONS /api/save HTTP/1.1
Origin: https://a.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, x-token
Origin:请求来自哪个源。Access-Control-Request-Method:接下来真正请求要用的方法。Access-Control-Request-Headers:接下来真正请求会带的自定义头。
服务器要回(缺一项都可能预检失败):
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, X-Token
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Access-Control-Allow-Origin:允许的来源。带 Cookie 时不能是*,必须回具体源。Access-Control-Allow-Methods/Access-Control-Allow-Headers:必须覆盖实际要用的方法和头。Access-Control-Allow-Credentials:是否允许携带凭证(Cookie)。为true时上面两项和Allow-Origin都不能用通配符。Access-Control-Max-Age:预检结果缓存秒数,缓存期内同一请求不再重复预检。
预检响应本身必须是 2xx 状态码并带上面这些头,否则浏览器判定失败。
五、服务端怎么处理
预检是 OPTIONS 方法,如果服务端没显式处理,很容易被路由拦成 405。以 Next.js Route Handler 为例:
// app/api/save/route.ts
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': 'https://a.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Token',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
})
}
export async function PUT(req: Request) {
// 真正的业务逻辑,同样要带上 CORS 响应头
return Response.json({ ok: true }, {
headers: { 'Access-Control-Allow-Origin': 'https://a.com' },
})
}
注意两点:
OPTIONS只是探路,不应执行业务逻辑,直接返回 CORS 头即可。- 真正请求(这里是
PUT)的响应也要带 CORS 头,否则浏览器阶段仍然会拦。
六、常见坑
Allow-Headers漏了头:实际请求带了X-Token,但服务端只写了Content-Type,预检直接失败。- 带凭证却回了
*:credentials: 'include'时Allow-Origin: *无效,必须回具体源。 - 预检没返回 2xx:
405、403都会让预检失败,哪怕实际接口逻辑是对的。 - 网关/中间件拦掉
OPTIONS:Nginx、鉴权中间件常把OPTIONS当成未授权请求拦掉,需要放行。 - 改动响应头导致缓存失效:
Access-Control-Max-Age到期后要重新预检,调大它能减少请求数。
七、速查表
| 项目 | 说明 |
|---|---|
| 谁发起 | 浏览器自动发起,不是业务代码 |
| 方法 | OPTIONS |
| 触发条件 | 非简单请求(非 GET/HEAD/POST、非安全 Content-Type、有自定义头等) |
| 请求头 | Origin、Access-Control-Request-Method、Access-Control-Request-Headers |
| 必需响应头 | Allow-Origin、Allow-Methods、Allow-Headers(带凭证时还有 Allow-Credentials) |
| 结果缓存 | Access-Control-Max-Age |
| 失败后果 | 真正的请求不会发出,控制台报 CORS 错误 |
| 场景 | 是否预检 |
|---|---|
GET,无自定义头 | 否 |
POST + application/x-www-form-urlencoded | 否 |
POST + application/json | 是 |
任意方法 + X-Token 等自定义头 | 是 |
PUT / DELETE | 是 |
小结
- 预检是浏览器自动发的
OPTIONS请求,用来在正式跨域请求前征求服务器同意。 - 它能拦住的是请求副作用,而不只是响应读取,这是它和简单请求的本质差别。
- 触发条件就是"非简单请求":非简单方法、非安全
Content-Type、自定义头。 - 服务端必须显式处理
OPTIONS并返回完整 CORS 头,且真正的请求响应也要带头。