前端面试官:Post为什么要发送两次请求?
核心思路浏览器出于安全考虑对“非简单请求”自动发起 OPTIONS 预检以获取服务器授权性能优化的本质是“将非简单请求降级为简单请求”或“缓存预检结果”。️ 解决方案架构图文本版[客户端 Browser] [网络层] [服务端 Server] | | | | 1. 判断是否为非简单请求? | | |--(是)-----------------------| | | | | | 2. 发送 OPTIONS 预检请求 | | | (Origin, Method, Headers) | -------------------------| | | | 3. 校验CORS策略 | | | | 4. 返回预检响应 | -------------------------| | (Access-Control-Allow-*) | | | | | | 5. 检查响应头是否包含允许信息 | | |--(通过)---------------------| | | | | | 6. 发送真正的业务请求(POST等) | | | ----------------------------| | | | | | 7. 返回业务数据 | -------------------------| | | | ⚠️ 若步骤5校验失败 - 浏览器直接拦截抛出CORS Error真实请求永不发出 答案核心干货1. 纠正概念不是“两次POST”而是“1次OPTIONS 1次POST”错误认知浏览器发了两次一样的请求。正确原理这是 CORS 机制中的Preflight预检。浏览器在发送跨域的非简单请求前必须先询问服务器“我即将用 X 方法、带 Y 头访问你你允许吗”主要矛盾安全性 vs 性能。预检保障了跨域安全但增加了一次 RTT往返时延导致接口耗时翻倍。次要矛盾开发者对“简单请求”定义模糊导致无意中触发了不必要的预检。2. 触发预检的条件非简单请求判定只要满足以下任意一条即触发 OPTIONSMethod 非简单不是GET、HEAD、POST注意即使是POST也可能触发见下条。Content-Type 非简单不是application/x-www-form-urlencoded、multipart/form-data、text/plain。⚠️高频坑点application/json会触发预检。自定义请求头携带了除 CORS 安全列表以外的 Header如Authorization、X-Token、X-Requested-With。其他使用了ReadableStream、XMLHttpRequestUpload对象注册了事件监听器等。❓ 为什么 GET 很少发预检POST 却经常发GET 通常是简单请求无Body、无自定义头。POST 在现代开发中常配合application/json或Authorization头使用这两者都会将其变为“非简单请求”。3. 优化策略从根源减少/消除预检优化手段原理适用场景局限性设置 Max-Age服务端返回Access-Control-Max-Age: 86400浏览器缓存预检结果所有跨域接口受浏览器上限限制Chrome 2h/Firefox 24h仅同域名路径有效降级 Content-Type改用text/plain或form-urlencoded内部服务、表单提交后端需适配解析逻辑不适合复杂嵌套JSON避免自定义头Token 放 CookieSameSiteNone或 URL 参数对SEO或旧系统兼容要求高安全性降低Cookie 有大小限制同域部署Nginx 反向代理/api到后端前端只访问同域生产环境首选需运维配置SSR/BFF架构天然支持WebSocketWS 不受 CORS 预检限制实时通信、长连接协议不同不适合常规CRUD⚠️ 边界场景与避坑指南Max-Age 的浏览器差异Chrome: 最大缓存2小时7200s超过按2h算。Firefox: 最大缓存24小时86400s。Safari: 历史上曾限制为5分钟新版本已改善但仍需注意。建议设置86400即可无需更大。预检请求不带 CookieOPTIONS 请求默认不携带凭证服务端校验时不能依赖 Cookie 中的 Session应基于 Origin/Header 做白名单校验。Vary如果服务端根据 Origin 动态返回 CORS 头必须在响应头加Vary: Origin否则 CDN/浏览器可能缓存错误的 CORS 响应给其他源。本地调试陷阱localhost和127.0.0.1被视为不同源开发时也会触发预检不要误以为是线上问题。 示例代码服务端Node.js/Koa正确配置app.use(async(ctx,next){constoriginctx.get(Origin);// ✅ 动态白名单校验constallowedOrigins[https://app.example.com,https://admin.example.com];if(allowedOrigins.includes(origin)){ctx.set(Access-Control-Allow-Origin,origin);ctx.set(Access-Control-Allow-Credentials,true);ctx.set(Access-Control-Allow-Methods,GET,POST,PUT,DELETE,OPTIONS);ctx.set(Access-Control-Allow-Headers,Content-Type,Authorization,X-Token);// ✅ 关键优化缓存预检结果24小时ctx.set(Access-Control-Max-Age,86400);// ✅ 关键安全防止CDN缓存污染ctx.set(Vary,Origin);}// ✅ OPTIONS 直接返回204不走后续业务逻辑if(ctx.methodOPTIONS){ctx.status204;return;}awaitnext();});前端规避预检的写法对比// ❌ 触发预检application/json Authorizationfetch(/api/data,{method:POST,headers:{Content-Type:application/json,Authorization:Bearer xxx},body:JSON.stringify({name:test})});// ✅ 不触发预检需后端支持解析fetch(/api/data,{method:POST,headers:{Content-Type:text/plain},body:JSON.stringify({name:test})// 字符串形式发送}); 满分答案面试口述版“这个问题考察的是对 CORS 预检机制的理解。首先澄清浏览器发的不是两次 POST而是一次 OPTIONS 预检 一次真实请求。这是浏览器对‘非简单请求’的安全握手确认服务器允许该跨域操作后才会发送真实请求。触发条件有三类一是方法不是 GET/HEAD/POST二是 Content-Type 不是表单/纯文本比如常用的 application/json三是带了 Authorization 等自定义头。这就是为什么 POST 比 GET 更容易触发预检——因为现代 API 普遍用 JSON 和 Token。优化方案分三层最有效生产环境通过 Nginx 反代实现同域彻底消除跨域最通用服务端设置Access-Control-Max-Age: 86400缓存预检结果注意 Chrome 上限是 2 小时兜底方案将 Content-Type 降级为 text/plain 或用 Cookie 替代自定义头把请求变回简单请求。额外注意服务端动态返回 CORS 头时必须加Vary: Origin否则 CDN 缓存会导致跨域混乱。这道题表面问网络现象实际考的是安全模型、HTTP 协议细节和工程化优化能力的综合理解。”