最近在一次前端面试中被问到一个高频问题:“一个POST请求为什么会发送两次?” 这个问题看似简单,却牵扯出前端安全、浏览器策略以及网络协议等诸多核心知识点,值得深入探讨。
同源策略
在浏览器环境中,内容接入是高度开放的,JavaScript文件、图片、音频、视频乃至其他站点的可执行文件都可以被引入。然而,这种开放性若不加约束,便会引发严重的安全问题,例如:
- 跨站脚本攻击(XSS)
- SQL注入攻击
- OS命令注入攻击
- HTTP首部注入攻击
- 跨站点请求伪造(CSRF)
因此,为了保障用户隐私与数据安全,浏览器实施了一套基础且核心的安全策略:同源策略。
什么是同源策略?
同源策略是一个关键的安全机制,它限制了一个“源”的文档或脚本如何与另一个“源”的资源进行交互。如果两个URL的协议、主机和端口完全相同,则它们被视为同源。
以下是与 http://test.home.com:8080/dir/page.html 进行对比的示例:
| URL |
结果 |
原因 |
http://test.home.com:8080/dir/page.html |
同源 |
只有路径不同 |
http://test.home.com:8080/dir/inner/another.html |
同源 |
只有路径不同 |
https://test.home.com:8080/secure.html |
不同源 |
协议不同(HTTP vs HTTPS) |
http://test.home.com:8081/dir/etc.html |
不同源 |
端口不同(8080 vs 8081) |
http://online.home.com:8080/dir/other.html |
不同源 |
主机不同(test.home.com vs online.home.com) |
同源策略主要体现在三个方面:
- DOM访问限制:防止恶意脚本跨域窃取或篡改其他页面的DOM内容。
- Web数据限制:限制
XMLHttpRequest或Fetch API仅能向同源地址发起请求,防范CSRF等攻击。
- 网络通信限制:浏览器会拦截跨域请求的响应,确保只有受信任的源能与服务器通信。
出于安全考虑,默认情况下,浏览器会限制脚本发起的跨源HTTP请求。这意味着,你的前端应用通常只能从加载它的同一个域请求资源——除非服务器明确告知浏览器允许跨域,而这正是CORS机制发挥作用的地方。
CORS
这里需要澄清一个常见误解:“浏览器限制了发起跨站请求”。更准确的说法是:跨站请求通常可以正常发起,但返回的响应结果可能会被浏览器拦截。
浏览器采用多进程架构,网络进程负责下载资源。当它获取到一个跨域响应时,浏览器会根据安全策略(如CORB - Cross-Origin Read Blocking)决定是否将数据送达渲染进程。即使网络进程拿到了数据,如果浏览器判断该响应可能包含敏感信息或恶意代码,仍会阻止前端脚本访问它。
跨源资源共享(CORS)正是为了解决这一问题而生的机制。它允许服务器通过设置特定的HTTP响应头,来声明哪些外部源有权访问其资源。
简单请求
某些请求不会触发额外的预检检查,这类请求被称为简单请求。一个请求若想被视作简单请求,必须同时满足以下所有条件:
- HTTP方法限制:仅使用
GET、HEAD、POST 之一。
- 自定义标头限制:请求头仅包含以下字段:
Accept, Accept-Language, Content-Language, Last-Event-ID, Content-Type(且其值仅限于 application/x-www-form-urlencoded、multipart/form-data、text/plain)。此外,还包括一些特定的HTML头部字段如DPR, Download, Save-Data, Viewport-Width, Width。
- 未使用ReadableStream对象。
- 未使用自定义请求头。
- XMLHttpRequestUpload对象未注册任何事件监听器。
对于简单请求,浏览器会直接发送实际请求,并在请求头中带上Origin字段。服务器通过响应头Access-Control-Allow-Origin来决定是否允许此次跨域访问。
预检请求
当请求不满足“简单请求”的所有条件时(例如,使用了PUT、DELETE方法,或Content-Type为application/json,或包含了自定义头),浏览器会先发起一次预检请求。
预检请求使用OPTIONS方法,旨在正式通信前,征询服务器是否允许接下来的实际请求。这正是POST请求可能发送两次的根本原因:第一次是OPTIONS预检请求,第二次才是真正的POST请求。
例如,在一个网站上发起一个删除操作的POST请求,该请求携带了自定义头部,浏览器会首先发起一个预检请求:

预检请求的关键头信息包括:
Access-Control-Request-Method:告知服务器,实际请求将使用的HTTP方法(例如POST)。
Access-Control-Request-Headers:告知服务器,实际请求将携带的自定义头信息列表(例如content-type, x-secsdk-csrf-token)。
服务器收到预检请求后,会通过响应头进行回应,关键的CORS头信息包括:
Access-Control-Allow-Origin:允许发起请求的源,可以是具体的域名(如https://xxx.cn),或通配符*(表示允许任何源)。
Access-Control-Allow-Methods:允许的实际请求方法。
Access-Control-Allow-Headers:允许的实际请求头。
Access-Control-Max-Age:本次预检响应的有效时间(秒),在此期间不再发送预检请求。
一旦服务器通过了预检请求,浏览器便会发送真正的请求:

附带身份凭证的请求与通配符
当请求需要携带Cookie等身份凭证时,CORS的设置需要更加严格:
Access-Control-Allow-Origin 不能设置为通配符 *,而必须指定确切的源(如 https://xxx.cn),否则请求将失败。
- 同样,
Access-Control-Allow-Headers 和 Access-Control-Allow-Methods 也应尽量避免使用 *,而应列出明确允许的列表,以减少潜在的安全风险。
- 服务器还需要设置
Access-Control-Allow-Credentials: true 来声明允许携带凭证。
完整的请求流程图
以下流程图清晰地展示了浏览器在处理跨域请求时的完整逻辑判断过程:

总结
预检请求是CORS机制中一项关键的安全措施。 它的存在并非多余,而是为了在真正影响服务器数据或状态之前,进行一次“安全握手”。通过这次握手,服务器可以明确告知浏览器:“我允许来自这个源的、使用这些方法和这些头部的请求”。
对于前端开发者而言,理解预检请求至关重要。当你发现一个POST请求在开发者工具中出现了两次(一次OPTIONS,一次POST),你就应该立刻意识到,这个请求包含了非简单请求的元素,触发了CORS预检机制。解决由此引发的跨域问题,通常需要后端同事协作,正确配置CORS响应头。
希望这篇解析能帮助你彻底理解POST请求“发送两次”背后的原理。在实际开发中遇到类似问题时,不妨多看看网络面板,答案往往就在那些请求头与响应头之中。如果你想与其他开发者深入交流这类网络与安全话题,欢迎来云栈社区一起探讨。