POST请求发两次?一次讲透CORS预检机制,面试不再翻车

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 传输、自定义头)

什么是简单请求?

必须同时满足以下所有条件:

  1. 请求方法GETHEADPOST 之一
  2. Content-Type :仅限于
    • text/plain
    • application/x-www-form-urlencoded
    • multipart/form-data
  3. 请求头 :只能包含 CORS 安全头,比如 AcceptAccept-LanguageContent-Language,以及值为上述三种之一的 Content-Type

简单请求不会触发 OPTIONS,浏览器直接发送真实请求。

什么是非简单请求?

只要不满足上述任一条件,就是非简单请求。最常见的触发场景:

  • 使用 PUTDELETEPATCH 等方法
  • Content-Type: application/json ------ 这是现代前后端分离项目的标配
  • 添加自定义头,如 X-TokenAuthorizationX-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 OK201 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),或者方法变了(POSTPUT),或者多带了一个自定义头,浏览器依然会重新发起预检。

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 为什么要发两次",你可以这样组织语言:

  1. 定性 :这是浏览器对 跨域非简单请求 执行的 CORS 预检(Preflight) 机制。
  2. 原因 :当请求的 Content-Type 不是三种表单格式之一(如 application/json),或带有自定义头时,浏览器会先发 OPTIONS 询问服务器是否允许该跨域访问。
  3. 过程 :第一次 OPTIONS 无请求体,用于权限确认;第二次才是带业务数据的真实请求。
  4. 优化 :通过设置 Access-Control-Max-Age 缓存预检结果,或在开发/生产环境使用代理/同域部署来规避。

最后,别忘了补一句:"这和业务上的防重复提交完全是两码事,防重是前端或后端在用户行为层面的控制,而预检是浏览器的自动安全行为。"


现在,你可以在面试中自信地给出满分回答,也可以在日常开发中快速定位 CORS 相关的问题根源。

相关推荐
乘风gg1 小时前
手把手带你实践历时一年总结的 AI Code Review 最佳工作流!
前端·ai编程·cursor
IT_陈寒1 小时前
SpringBoot自动配置这么智能,为啥我写的Bean注入不了?
前端·人工智能·后端
LT10157974441 小时前
2026年Web自动化测试工具选型指南:多浏览器兼容解决方案
前端·测试工具·自动化
HYCS1 小时前
用pixi.js实现fabric.js(七):框选、ActiveObject和控制点
前端·javascript·canvas
云浪1 小时前
手把手教你用 fetch 读取 SSE 流,给 AI 聊天加上打字机效果
前端·javascript·vue.js
DO_Community2 小时前
AI 创新先锋 Probably 携手 DigitalOcean 打造“本地优先”可验证智能体架构
人工智能·架构
Csvn2 小时前
Tailwind 动态拼接类名失效?JIT 引擎正在"静态分析"你
前端
柳杉2 小时前
我用Threejs 搓了一个 3D 中国地图设计器,开箱即用
前端·three.js·数据可视化