POST请求发两次?一次讲透CORS预检机制,面试不再翻车
一个经典的前端面试题,暴露了你对浏览器安全机制的理解深度。
一个让面试官沉默、候选人尴尬的开场
先看一个真实面试场景:
面试官:你在 Network 面板里看到 POST 请求发了两次,第一次是 OPTIONS,第二次才是 POST,这是为什么?
候选人:哦,这是为了防止重复提交吧,用户点两次按钮,第一次请求先确认......
面试官(打断):那这个 OPTIONS 请求是干嘛的?它和你说的防重复提交有什么关系?
候选人:......(陷入沉默)
这个对话每天都在面试室里上演。问题的根源在于:把业务层的"防重复提交"和浏览器层的"预检机制"混为一谈。
事实上,POST请求发两次,是浏览器为了跨域安全而自动执行的CORS(跨域资源共享)预检机制。第一次是OPTIONS预检请求,第二次才是真正的POST请求。
今天我们就从现象 → 原理 → 优化 → 实战四个维度,彻底讲透这道题。读完你会明白:
- 什么情况下 POST 会发两次,什么情况下不会
- OPTIONS 预检请求究竟在问什么、答什么
- 如何优化以减少无谓的预检开销
- 面试时怎样回答才能让面试官眼前一亮
第一层:现象------所有 POST 都会发两次吗?
不一定。
打开浏览器的 Network 面板,如果你看到这样的两个请求:
bash
OPTIONS /api/user 204 No Content
POST /api/user 200 OK
那说明你触发了浏览器的 CORS 预检(Preflight) 机制。但并不是所有 POST 都会这样。
浏览器将跨域请求分为两类:
| 类型 | 是否会发 OPTIONS | 典型场景 |
|---|---|---|
| 简单请求 | ❌ 不会 | <form> 提交、<img> 加载、<script> 加载 |
| 非简单请求 | ✅ 会 | 现代 AJAX(JSON 传输、自定义头) |
什么是简单请求?
必须同时满足以下所有条件:
- 请求方法 :
GET、HEAD、POST之一 - Content-Type :仅限于
text/plainapplication/x-www-form-urlencodedmultipart/form-data
- 请求头 :只能包含 CORS 安全头,比如
Accept、Accept-Language、Content-Language,以及值为上述三种之一的Content-Type。
简单请求不会触发 OPTIONS,浏览器直接发送真实请求。
什么是非简单请求?
只要不满足上述任一条件,就是非简单请求。最常见的触发场景:
- 使用
PUT、DELETE、PATCH等方法 Content-Type: application/json------ 这是现代前后端分离项目的标配- 添加自定义头,如
X-Token、Authorization、X-Requested-With
这些请求会触发 OPTIONS 预检。 非简单请求会触发OPTIONS预检,浏览器先问服务器"你允许吗",得到肯定答复后才发送真实请求。
第二层:原理------OPTIONS 预检在问什么?
想象你是一个访客(浏览器),要进入一栋大楼(跨域服务器)。门口有保安(同源策略),你亮出工牌(请求头),保安说:"我得先打个电话问问里面的人(服务器),你这工牌能不能进。"
OPTIONS 就是那个电话。
浏览器在发送真实请求之前,先发一个 OPTIONS 请求,询问服务器:
"我要发一个
POST请求到/api/user,内容格式是application/json,还带了一个自定义头X-Token。你允许吗?"
javascript
fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Custom-Token': 'abc123'
},
body: JSON.stringify({ name: '张三' })
})
浏览器会自动先发一个OPTIONS预检请求:
makefile
OPTIONS /users HTTP/1.1
Origin: https://my-frontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-custom-token
服务器收到后,通过响应头给出许可(或拒绝):
http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://my-frontend.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: content-type, x-token
Access-Control-Max-Age: 86400
关键字段含义:
| 响应头 | 作用 |
|---|---|
Access-Control-Allow-Origin |
允许哪些源访问(* 或具体域名) |
Access-Control-Allow-Methods |
允许哪些 HTTP 方法 |
Access-Control-Allow-Headers |
允许携带哪些自定义头 |
Access-Control-Max-Age |
预检结果可以缓存多久(秒) |
如果服务器不允许(比如没返回正确的CORS头,或返回的源不匹配),浏览器就会直接拦截真实请求,并在控制台报CORS错误。:
csharp
Access to fetch at 'https://api.example.com/user' from origin 'https://my-frontend.com' has been blocked by CORS policy...
为什么要这么设计?
预检机制的核心目的是保护那些不支持CORS的老旧服务器。如果没有预检,浏览器直接发送一个带有自定义头的跨域POST请求,老服务器可能会把这个请求当作正常请求处理,产生不可预知的副作用。预检让浏览器先"打个招呼",确认服务器能理解CORS协议,再发送真实请求。
第三层:完整流程------两次请求到底发生了什么?
现在可以完整回答"POST为什么要发两次"了:
- 第一次(OPTIONS) :不含请求体,只问权限(状态码通常为 204)
- 第二次(真实POST) :带数据,执行实际业务操作(状态码通常为 200)
这是浏览器的自动行为,不是代码bug,也不是业务上的"防重复提交" 。
假设我们在前端用 Axios 调用一个跨域接口:
javascript
// 前端代码(运行在 http://localhost:3000)
axios.post('https://api.example.com/user', {
name: '张三',
age: 28
}, {
headers: {
'Content-Type': 'application/json', // 非简单请求的导火索
'X-Requested-With': 'XMLHttpRequest' // 自定义头
}
})
Network 面板会依次出现:
第一次:OPTIONS 预检
makefile
OPTIONS /user HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-requested-with
- 没有请求体(Body 为空)
- 状态码通常为
204 No Content - 耗时极短(几十毫秒)
第二次:真实 POST
bash
POST /user HTTP/1.1
Origin: http://localhost:3000
Content-Type: application/json
X-Requested-With: XMLHttpRequest
{"name":"张三","age":28}
- 带有业务数据
- 状态码为
200 OK或201 Created - 耗时取决于业务逻辑
关键对比表格:
| 对比项 | OPTIONS 预检 | 真实 POST |
|---|---|---|
| 目的 | 权限询问 | 数据传输 |
| 请求体 | 无 | 有(JSON/Form) |
| 常见状态码 | 204 | 200 / 201 |
| 谁发出的 | 浏览器自动 | 浏览器根据代码发出 |
| 是否可避免 | 部分情况可避免 | 必须发出 |
第四层:边界------什么时候绝对不会发两次?
这才是面试的高分区域。掌握以下五种情况,你可以从容地告诉面试官:OPTIONS 并不是一定会出现的。
1. 同源请求------永远不发OPTIONS
前后端部署在同一域名、协议、端口下,浏览器不触发 CORS,自然没有预检。
arduino
前端:https://myapp.com
后端:https://myapp.com/api
2. 简单请求------即使跨域也不发OPTIONS
只要满足简单请求三条件,跨域也不会发 OPTIONS。比如用 application/x-www-form-urlencoded 提交表单:
javascript
fetch('https://api.example.com/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=张三&age=25'
})
// 不会触发 OPTIONS
3. 有预检缓存------第二次不再发OPTIONS
服务器通过 Access-Control-Max-Age 指定缓存时间(秒),在有效期内,浏览器直接使用缓存的许可结果,不再发 OPTIONS。
javascript
res.setHeader('Access-Control-Max-Age', '86400'); // 缓存24小时
⚠️ 关键细节(面试深挖点) :预检缓存不是"一劳永逸"的,它是基于 URL、请求方法、请求头组合 进行匹配的。如果请求的路径变了(/user → /order),或者方法变了(POST → PUT),或者多带了一个自定义头,浏览器依然会重新发起预检。
4. 使用 mode: 'no-cors'------但读不到响应
javascript
fetch(url, { mode: 'no-cors' })
这样确实不触发 OPTIONS,但响应是 opaque(不透明) 的,你无法读取状态码、响应体。一般只用于发送埋点日志等"只发不管结果"的场景。
5. 配置代理------彻底绕过跨域
开发环境最常用的方案:通过 webpack devServer / Vite 代理 或 nginx 反向代理 ,让浏览器请求同源地址,由代理服务器转发到目标服务器。这是最彻底的解决方案,CORS机制根本不会触发。
Webpack DevServer / Vite 代理:
javascript
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
}
浏览器访问 /api/user 实际被代理到 https://api.example.com/user。由于浏览器只认当前域名,整个请求是同源的,CORS 根本不会触发,OPTIONS 自然消失。
Nginx 反向代理同理,在生产环境中也常用。
第五层:优化------如何减少预检带来的性能损耗?
预检增加了一次网络往返(RTT),在弱网或高频请求场景下会有感知。我们可以这样优化:
1. 合理设置缓存时间
不要用默认值(很多服务器不设置,浏览器会使用一个较短的值),主动设置 Access-Control-Max-Age 为较大值,比如 86400 (24小时)或 7200(2小时)。
javascript
// Node.js Express 示例
app.use((req, res, next) => {
res.setHeader('Access-Control-Max-Age', '86400');
next();
});
2. 评估是否必须用 application/json
如果接口支持表单格式,可以改用 application/x-www-form-urlencoded,将请求降级为简单请求。但通常不推荐,因为 JSON 的可读性和结构化优势更大,且 RESTful API 普遍使用 JSON。
3. 合并请求
如果页面初始化需要多个 API 调用,可以考虑合并为一个批量接口,减少预检触发次数。
4. 开发环境用代理,生产环境用同域部署或网关
前端静态资源和后端 API 放在同一个域名下(通过 CDN 或 API 网关),从根本上消除跨域。
总结:面试时该怎么答?
当面试官再问"POST 为什么要发两次",你可以这样组织语言:
- 定性 :这是浏览器对 跨域非简单请求 执行的 CORS 预检(Preflight) 机制。
- 原因 :当请求的
Content-Type不是三种表单格式之一(如application/json),或带有自定义头时,浏览器会先发OPTIONS询问服务器是否允许该跨域访问。 - 过程 :第一次
OPTIONS无请求体,用于权限确认;第二次才是带业务数据的真实请求。 - 优化 :通过设置
Access-Control-Max-Age缓存预检结果,或在开发/生产环境使用代理/同域部署来规避。
最后,别忘了补一句:"这和业务上的防重复提交完全是两码事,防重是前端或后端在用户行为层面的控制,而预检是浏览器的自动安全行为。"
现在,你可以在面试中自信地给出满分回答,也可以在日常开发中快速定位 CORS 相关的问题根源。