为什么 Network 中会出现两个同名请求?------CORS 预检请求学习笔记
📘 本文简介
本文从浏览器 Network 面板中出现两个同名接口请求的现象出发,系统讲解同源策略、跨域、CORS 和 OPTIONS 预检请求,并说明前端、浏览器与后端分别承担什么职责。
本文中的域名、IP 地址、接口路径、请求头和端口均为虚拟示例,不对应任何真实项目或服务。
1. 学习背景
在调试前端接口时,Network 面板中可能会出现两条名称相同的请求,例如:
text
OPTIONS /api/demo/user/profile
POST /api/demo/user/profile
初学者很容易产生以下疑问:
- 前端代码是不是执行了两次?
- Axios 或 Fetch 是否重复发送了请求?
- 为什么登录接口、用户信息接口都出现两条记录?
- 这是前端问题、后端问题,还是网络问题?
很多情况下,答案并不是"接口调用了两次",而是浏览器为了保证跨域请求安全,自动发送了一次 CORS 预检请求。
不过需要注意:
**⚠️ 注意:**看到两个同名请求时,不能直接认定它们一定是预检请求和正式请求。必须查看两条记录各自的 Request Method。
判断方式如下:
| 两条请求的方法 | 可能原因 |
|---|---|
OPTIONS + POST |
通常是 CORS 预检请求和正式请求 |
OPTIONS + GET |
通常是 CORS 预检请求和正式请求 |
POST + POST |
前端可能真的调用了两次,或存在自动重试 |
GET + GET |
可能是重复渲染、重复监听、缓存验证或重试 |
| 一个失败、一个成功 | 可能存在重试、代理转发或降级逻辑 |
2. 核心概念
2.1 什么是同源
浏览器判断两个地址是否"同源",主要比较以下三个部分:
text
协议 + 主机 + 端口
例如,假设前端页面运行在:
text
http://localhost:3030
后端接口运行在:
text
http://203.0.113.10:8081
对比结果如下:
| 对比项 | 前端 | 后端 | 是否相同 |
|---|---|---|---|
| 协议 | http |
http |
相同 |
| 主机 | localhost |
203.0.113.10 |
不同 |
| 端口 | 3030 |
8081 |
不同 |
只要协议、主机或端口中有任意一项不同,浏览器就认为它们不是同源。
因此,上面的请求属于 跨域请求。
常见的跨域情况
text
http://example.test
https://example.test
协议不同,属于跨域。
text
http://www.example.test
http://api.example.test
主机不同,属于跨域。
text
http://example.test:3000
http://example.test:8080
端口不同,属于跨域。
💡 核心结论
同源要求协议、主机和端口全部相同。任意一项不同,都可能触发浏览器的跨域限制。
2.2 什么是同源策略
同源策略(Same-Origin Policy) 是浏览器的一种安全机制。
它的作用是限制一个来源的网页随意读取另一个来源的数据。
例如,用户同时打开了:
text
https://bank.example.test
https://unknown.example.test
如果浏览器完全不做限制,那么恶意网页可能尝试读取用户在银行网站中的敏感数据。
因此,浏览器默认不允许网页任意访问其他来源的资源。
需要特别注意:
同源策略主要由浏览器执行,并不是 HTTP 协议本身禁止跨域。
服务器、命令行工具或桌面客户端通常不受浏览器同源策略的直接限制。
2.3 什么是 CORS
CORS 是 Cross-Origin Resource Sharing 的缩写,中文通常称为:
跨域资源共享
它是一套基于 HTTP 响应头的机制,用来告诉浏览器:
虽然这个前端页面和后端接口不是同源,但后端允许它访问。
后端可能返回类似下面的响应头:
http
Access-Control-Allow-Origin: http://localhost:3030
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Demo-Signature
这些响应头分别表示:
| 响应头 | 含义 |
|---|---|
Access-Control-Allow-Origin |
允许哪些来源访问 |
Access-Control-Allow-Methods |
允许使用哪些请求方法 |
Access-Control-Allow-Headers |
允许携带哪些请求头 |
Access-Control-Allow-Credentials |
是否允许携带 Cookie 等凭证 |
Access-Control-Max-Age |
预检结果可以缓存多久 |
CORS 并不是让浏览器"关闭安全限制",而是让服务器明确声明允许哪些跨域访问。
2.4 什么是预检请求
预检请求的英文是:
text
Preflight Request
浏览器在发送某些跨域请求之前,会先发送一个 OPTIONS 请求,询问服务器是否允许后续的正式请求。
例如,前端原本想发送:
http
POST /api/demo/user/profile
Authorization: Bearer demo-token
Content-Type: application/json
X-Demo-Signature: demo-signature
浏览器可能先发送:
http
OPTIONS /api/demo/user/profile
Origin: http://localhost:3030
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type, x-demo-signature
这次 OPTIONS 请求并不是为了获取业务数据,而是在询问后端:
- 是否允许该前端来源访问;
- 是否允许使用
POST方法; - 是否允许携带这些请求头。
如果后端返回允许信息,浏览器才会继续发送正式的 POST 请求。
3. 为什么会看到两个同名请求
假设前端代码只调用了一次接口:
javascript
fetch('http://203.0.113.10:8081/api/demo/user/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer demo-token',
'X-Demo-Signature': 'demo-signature'
},
body: JSON.stringify({
userId: 1001
})
});
从前端代码的角度看,只执行了一次 fetch。
但浏览器实际可能执行以下流程:
#mermaid-svg-bgjtw2SbSCnjN89B{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bgjtw2SbSCnjN89B .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bgjtw2SbSCnjN89B .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bgjtw2SbSCnjN89B .error-icon{fill:#552222;}#mermaid-svg-bgjtw2SbSCnjN89B .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bgjtw2SbSCnjN89B .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bgjtw2SbSCnjN89B .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bgjtw2SbSCnjN89B .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bgjtw2SbSCnjN89B .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bgjtw2SbSCnjN89B .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bgjtw2SbSCnjN89B .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bgjtw2SbSCnjN89B .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bgjtw2SbSCnjN89B .marker.cross{stroke:#333333;}#mermaid-svg-bgjtw2SbSCnjN89B svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bgjtw2SbSCnjN89B p{margin:0;}#mermaid-svg-bgjtw2SbSCnjN89B .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bgjtw2SbSCnjN89B .cluster-label text{fill:#333;}#mermaid-svg-bgjtw2SbSCnjN89B .cluster-label span{color:#333;}#mermaid-svg-bgjtw2SbSCnjN89B .cluster-label span p{background-color:transparent;}#mermaid-svg-bgjtw2SbSCnjN89B .label text,#mermaid-svg-bgjtw2SbSCnjN89B span{fill:#333;color:#333;}#mermaid-svg-bgjtw2SbSCnjN89B .node rect,#mermaid-svg-bgjtw2SbSCnjN89B .node circle,#mermaid-svg-bgjtw2SbSCnjN89B .node ellipse,#mermaid-svg-bgjtw2SbSCnjN89B .node polygon,#mermaid-svg-bgjtw2SbSCnjN89B .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bgjtw2SbSCnjN89B .rough-node .label text,#mermaid-svg-bgjtw2SbSCnjN89B .node .label text,#mermaid-svg-bgjtw2SbSCnjN89B .image-shape .label,#mermaid-svg-bgjtw2SbSCnjN89B .icon-shape .label{text-anchor:middle;}#mermaid-svg-bgjtw2SbSCnjN89B .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bgjtw2SbSCnjN89B .rough-node .label,#mermaid-svg-bgjtw2SbSCnjN89B .node .label,#mermaid-svg-bgjtw2SbSCnjN89B .image-shape .label,#mermaid-svg-bgjtw2SbSCnjN89B .icon-shape .label{text-align:center;}#mermaid-svg-bgjtw2SbSCnjN89B .node.clickable{cursor:pointer;}#mermaid-svg-bgjtw2SbSCnjN89B .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bgjtw2SbSCnjN89B .arrowheadPath{fill:#333333;}#mermaid-svg-bgjtw2SbSCnjN89B .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bgjtw2SbSCnjN89B .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bgjtw2SbSCnjN89B .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bgjtw2SbSCnjN89B .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bgjtw2SbSCnjN89B .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bgjtw2SbSCnjN89B .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bgjtw2SbSCnjN89B .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bgjtw2SbSCnjN89B .cluster text{fill:#333;}#mermaid-svg-bgjtw2SbSCnjN89B .cluster span{color:#333;}#mermaid-svg-bgjtw2SbSCnjN89B div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bgjtw2SbSCnjN89B .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bgjtw2SbSCnjN89B rect.text{fill:none;stroke-width:0;}#mermaid-svg-bgjtw2SbSCnjN89B .icon-shape,#mermaid-svg-bgjtw2SbSCnjN89B .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bgjtw2SbSCnjN89B .icon-shape p,#mermaid-svg-bgjtw2SbSCnjN89B .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bgjtw2SbSCnjN89B .icon-shape .label rect,#mermaid-svg-bgjtw2SbSCnjN89B .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bgjtw2SbSCnjN89B .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bgjtw2SbSCnjN89B .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bgjtw2SbSCnjN89B :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
允许
不允许
前端调用一次 fetch
是否跨域
发送正式 POST
是否需要预检
浏览器发送 OPTIONS
后端是否允许
拦截正式请求
前端获得响应
因此,Network 面板中可能出现:
text
OPTIONS /api/demo/user/profile
POST /api/demo/user/profile
这两条记录的接口路径相同,但用途不同:
| 请求 | 谁发起 | 作用 |
|---|---|---|
OPTIONS |
浏览器自动发送 | 检查跨域权限 |
POST |
前端业务代码要求发送 | 真正提交或获取业务数据 |
💡 核心结论
前端代码调用一次,不代表 Network 中只能出现一条记录。对于需要预检的跨域请求,浏览器会自动增加一条 OPTIONS 请求。
4. 哪些请求会触发预检
并不是所有跨域请求都会发送 OPTIONS。
浏览器会区分:
- 简单请求;
- 需要预检的请求。
4.1 简单请求
满足特定条件的跨域请求,可以直接发送正式请求,不需要先发 OPTIONS。
常见的简单请求方法包括:
text
GET
HEAD
POST
但只满足请求方法还不够,请求头和 Content-Type 也必须符合限制。
常见的简单 Content-Type 包括:
text
application/x-www-form-urlencoded
multipart/form-data
text/plain
4.2 常见的预检触发条件
以下情况通常会触发预检:
1. 使用非简单请求方法
例如:
text
PUT
DELETE
PATCH
2. 携带非简单请求头
例如:
http
Authorization: Bearer demo-token
X-Demo-Signature: demo-signature
X-Request-Id: demo-id
3. 使用 application/json
例如:
http
Content-Type: application/json
这也是前后端项目中非常常见的预检触发原因。
4. 请求中包含浏览器需要额外确认的跨域配置
例如某些凭证、私有网络访问或特殊请求头场景。
5. OPTIONS 和 POST 分别做什么
以一个虚拟的用户信息接口为例。
5.1 OPTIONS:权限检查
浏览器先发送:
http
OPTIONS /api/demo/user/profile HTTP/1.1
Origin: http://localhost:3030
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type, x-demo-signature
后端可能返回:
http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3030
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Demo-Signature
Access-Control-Max-Age: 600
这里的重点是:
OPTIONS一般不处理用户信息查询业务;- 它主要检查是否允许正式请求;
- 返回值常见为
200或204; - 它通常没有业务响应体。
5.2 POST:正式业务请求
预检通过后,浏览器再发送:
http
POST /api/demo/user/profile HTTP/1.1
Origin: http://localhost:3030
Authorization: Bearer demo-token
Content-Type: application/json
X-Demo-Signature: demo-signature
{
"userId": 1001
}
后端处理业务后返回:
json
{
"code": 0,
"message": "success",
"data": {
"nickname": "Demo User"
}
}
正式请求才会真正执行:
- 身份校验;
- 参数校验;
- 数据库查询;
- 业务逻辑;
- 返回业务数据。
6. 如何在浏览器中确认
当 Network 中出现两个同名请求时,可以按照下面的步骤检查。
第一步:打开开发者工具
在浏览器中打开:
text
开发者工具 → Network
第二步:点击两条同名请求
分别查看每一条请求。
第三步:检查请求方法
进入:
text
Headers → General → Request Method
可能会看到:
text
Request Method: OPTIONS
另一条为:
text
Request Method: POST
如果确实是 OPTIONS + POST,通常说明:
一条是浏览器自动发送的预检请求,一条是正式业务请求。
第四步:检查 CORS 相关请求头
在 OPTIONS 请求中,可以关注:
http
Origin
Access-Control-Request-Method
Access-Control-Request-Headers
在响应中,可以关注:
http
Access-Control-Allow-Origin
Access-Control-Allow-Methods
Access-Control-Allow-Headers
第五步:检查 Initiator
Network 面板中的 Initiator 可以帮助判断请求由哪里触发:
- JavaScript 文件和代码行;
- 浏览器预检;
- 页面跳转;
- 重定向;
- Service Worker。
如果两个请求都是 POST,应继续检查 Initiator,看看它们是否来自同一段代码。
7. 前端、浏览器和后端分别负责什么
CORS 预检涉及三个参与者:
#mermaid-svg-6632FdyEQKVJvANB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6632FdyEQKVJvANB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6632FdyEQKVJvANB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6632FdyEQKVJvANB .error-icon{fill:#552222;}#mermaid-svg-6632FdyEQKVJvANB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6632FdyEQKVJvANB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6632FdyEQKVJvANB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6632FdyEQKVJvANB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6632FdyEQKVJvANB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6632FdyEQKVJvANB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6632FdyEQKVJvANB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6632FdyEQKVJvANB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6632FdyEQKVJvANB .marker.cross{stroke:#333333;}#mermaid-svg-6632FdyEQKVJvANB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6632FdyEQKVJvANB p{margin:0;}#mermaid-svg-6632FdyEQKVJvANB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6632FdyEQKVJvANB .cluster-label text{fill:#333;}#mermaid-svg-6632FdyEQKVJvANB .cluster-label span{color:#333;}#mermaid-svg-6632FdyEQKVJvANB .cluster-label span p{background-color:transparent;}#mermaid-svg-6632FdyEQKVJvANB .label text,#mermaid-svg-6632FdyEQKVJvANB span{fill:#333;color:#333;}#mermaid-svg-6632FdyEQKVJvANB .node rect,#mermaid-svg-6632FdyEQKVJvANB .node circle,#mermaid-svg-6632FdyEQKVJvANB .node ellipse,#mermaid-svg-6632FdyEQKVJvANB .node polygon,#mermaid-svg-6632FdyEQKVJvANB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6632FdyEQKVJvANB .rough-node .label text,#mermaid-svg-6632FdyEQKVJvANB .node .label text,#mermaid-svg-6632FdyEQKVJvANB .image-shape .label,#mermaid-svg-6632FdyEQKVJvANB .icon-shape .label{text-anchor:middle;}#mermaid-svg-6632FdyEQKVJvANB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6632FdyEQKVJvANB .rough-node .label,#mermaid-svg-6632FdyEQKVJvANB .node .label,#mermaid-svg-6632FdyEQKVJvANB .image-shape .label,#mermaid-svg-6632FdyEQKVJvANB .icon-shape .label{text-align:center;}#mermaid-svg-6632FdyEQKVJvANB .node.clickable{cursor:pointer;}#mermaid-svg-6632FdyEQKVJvANB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6632FdyEQKVJvANB .arrowheadPath{fill:#333333;}#mermaid-svg-6632FdyEQKVJvANB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6632FdyEQKVJvANB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6632FdyEQKVJvANB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6632FdyEQKVJvANB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6632FdyEQKVJvANB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6632FdyEQKVJvANB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6632FdyEQKVJvANB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6632FdyEQKVJvANB .cluster text{fill:#333;}#mermaid-svg-6632FdyEQKVJvANB .cluster span{color:#333;}#mermaid-svg-6632FdyEQKVJvANB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6632FdyEQKVJvANB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6632FdyEQKVJvANB rect.text{fill:none;stroke-width:0;}#mermaid-svg-6632FdyEQKVJvANB .icon-shape,#mermaid-svg-6632FdyEQKVJvANB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6632FdyEQKVJvANB .icon-shape p,#mermaid-svg-6632FdyEQKVJvANB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6632FdyEQKVJvANB .icon-shape .label rect,#mermaid-svg-6632FdyEQKVJvANB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6632FdyEQKVJvANB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6632FdyEQKVJvANB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6632FdyEQKVJvANB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 前端代码
浏览器
后端服务
7.1 前端的职责
前端负责定义正式请求,例如:
- 请求地址;
- 请求方法;
- 请求参数;
- 请求头;
- 是否携带 Cookie;
- 如何处理成功和失败。
示例:
javascript
const response = await fetch(
'http://203.0.113.10:8081/api/demo/user/profile',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer demo-token'
},
body: JSON.stringify({
userId: 1001
})
}
);
前端通常不会主动编写:
javascript
fetch(url, {
method: 'OPTIONS'
});
预检请求一般由浏览器自动生成。
7.2 浏览器的职责
浏览器负责:
- 判断是否跨域;
- 判断是否需要预检;
- 自动发送
OPTIONS; - 检查后端返回的 CORS 响应头;
- 决定是否继续发送正式请求;
- 在不符合规则时拦截前端读取响应。
因此,Network 中的 OPTIONS 并不一定来自项目源码。
7.3 后端的职责
后端负责:
- 正确处理
OPTIONS请求; - 返回允许访问的来源;
- 返回允许的方法;
- 返回允许的请求头;
- 必要时允许携带凭证;
- 在正式响应中继续返回正确的 CORS 响应头。
如果后端没有正确配置,浏览器可能报错:
text
Blocked by CORS policy
或者:
text
Response to preflight request doesn't pass access control check
此时常见现象是:
text
OPTIONS 请求失败
POST 请求没有发送
8. 这个知识属于前端、后端还是网络
从知识分类看,它主要属于:
text
HTTP
浏览器安全机制
同源策略
CORS
前后端通信
因此更准确的分类是:
Web 网络与浏览器安全知识。
但在实际项目中,前端、浏览器和后端都会参与。
| 参与方 | 主要职责 |
|---|---|
| 前端 | 定义正式请求 |
| 浏览器 | 判断跨域、发送预检、执行安全限制 |
| 后端 | 返回 CORS 响应头并决定是否放行 |
| 网关或代理 | 可能统一处理跨域、转发和响应头 |
所以不能简单地说它只属于前端或只属于后端。
更准确的理解是:
浏览器执行 CORS 检查,后端或网关负责配置允许规则,前端负责正确发起请求。
9. 为什么 Apifox、Postman 或 curl 可能不报错
CORS 主要是浏览器针对网页执行的安全机制。
以下工具不是运行在普通网页环境中:
text
Apifox
Postman
curl
服务器端脚本
桌面客户端
它们通常不会受到浏览器同源策略的限制。
例如,使用 curl 可以直接发送:
bash
curl -X POST 'http://203.0.113.10:8081/api/demo/user/profile' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer demo-token' \
-d '{"userId":1001}'
它一般不会因为"前端页面来源不同"而自动阻止请求。
因此,项目中经常出现:
text
Apifox 请求成功
浏览器请求失败
这并不一定说明:
- 前端参数写错了;
- 后端接口业务逻辑有问题;
- 接口无法访问。
它可能只是说明:
后端接口本身可以工作,但没有向浏览器返回正确的 CORS 许可信息。
**⚠️ 注意:**不要使用"Postman 能请求成功"来证明浏览器中的跨域配置一定正确。两者所处的安全环境不同。
10. 两个同名请求不一定都是 CORS
虽然 OPTIONS + POST 是常见情况,但两个同名请求也可能由其他原因造成。
10.1 前端代码重复调用
例如:
javascript
loadUserProfile();
loadUserProfile();
或者同一个函数被多个生命周期或事件同时触发。
10.2 组件重复挂载
在某些开发环境或框架配置下,组件可能被重复创建,导致初始化接口再次调用。
10.3 事件绑定重复
例如按钮同时触发:
- 表单提交;
- 点击事件;
- 键盘回车事件。
如果没有阻止默认行为,可能发送两次请求。
10.4 请求库自动重试
项目可能配置了:
- 网络失败重试;
- Token 刷新后重试;
- 超时重试;
- 网关重试。
10.5 HTTP 重定向
请求可能先访问一个地址,再被服务器重定向到另一个地址。
例如:
text
HTTP → HTTPS
旧路径 → 新路径
10.6 开发代理和 Service Worker
开发服务器代理、浏览器扩展或 Service Worker 也可能参与请求流程。
因此,排查时应该先看请求方法,再看状态码、Initiator 和响应头。
11. 常见错误排查
11.1 OPTIONS 返回 404
现象:
text
OPTIONS /api/demo/user/profile 404
可能原因:
- 后端没有处理
OPTIONS; - 网关没有放行;
- 路由只接受
POST; - CORS 中间件配置位置不正确。
处理方向:
- 在后端框架中启用 CORS;
- 让网关统一响应预检;
- 确保
OPTIONS不被认证逻辑提前拒绝。
11.2 OPTIONS 返回 401 或 403
预检请求通常不会携带正式业务认证信息。
如果后端要求所有请求都必须登录,可能会把 OPTIONS 拦截为未授权。
处理方向:
- 对
OPTIONS预检请求放行; - 先处理 CORS,再进入认证逻辑;
- 检查网关和安全中间件执行顺序。
11.3 请求头不被允许
浏览器可能报错:
text
Request header field authorization is not allowed
说明前端想发送:
http
Authorization
但后端的响应中没有允许它。
需要检查:
http
Access-Control-Allow-Headers
11.4 Origin 不被允许
如果前端来源是:
text
http://localhost:3030
但后端只允许:
text
http://localhost:3000
浏览器仍会拦截。
端口不同也属于不同来源。
11.5 携带 Cookie 时配置不匹配
前端可能配置:
javascript
fetch(url, {
credentials: 'include'
});
此时后端通常还需要返回:
http
Access-Control-Allow-Credentials: true
并且 Access-Control-Allow-Origin 不能随意使用通配符 *。
12. 开发环境中常见的解决方式
12.1 由后端正确配置 CORS
这是最直接的方式。
后端根据需要配置:
- 允许的前端来源;
- 允许的方法;
- 允许的请求头;
- 是否允许 Cookie;
- 预检缓存时间。
12.2 使用前端开发服务器代理
在本地开发时,可以让浏览器只请求当前前端服务,再由开发服务器转发到后端。
浏览器看到的请求可能是:
text
http://localhost:3030/api/demo/user/profile
然后开发服务器代理到:
text
http://203.0.113.10:8081/api/demo/user/profile
对于浏览器来说,请求目标仍然是:
text
http://localhost:3030
因此可以避免浏览器层面的跨域问题。
不过需要理解:
代理不是让后端接口变成了同源,而是由开发服务器替浏览器完成了跨服务器转发。
12.3 由网关统一处理
在生产环境中,常见做法是通过 Nginx、API Gateway 或统一后端网关:
- 处理
OPTIONS; - 添加 CORS 响应头;
- 转发正式请求;
- 统一认证、限流和日志。
13. 完整执行流程
下面以一个需要预检的跨域 POST 请求为例。
后端服务 浏览器 前端代码 后端服务 浏览器 前端代码 #mermaid-svg-r5c4orqBLRADzQLL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r5c4orqBLRADzQLL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r5c4orqBLRADzQLL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r5c4orqBLRADzQLL .error-icon{fill:#552222;}#mermaid-svg-r5c4orqBLRADzQLL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r5c4orqBLRADzQLL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r5c4orqBLRADzQLL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r5c4orqBLRADzQLL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r5c4orqBLRADzQLL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r5c4orqBLRADzQLL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r5c4orqBLRADzQLL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r5c4orqBLRADzQLL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r5c4orqBLRADzQLL .marker.cross{stroke:#333333;}#mermaid-svg-r5c4orqBLRADzQLL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r5c4orqBLRADzQLL p{margin:0;}#mermaid-svg-r5c4orqBLRADzQLL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-r5c4orqBLRADzQLL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-r5c4orqBLRADzQLL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-r5c4orqBLRADzQLL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-r5c4orqBLRADzQLL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-r5c4orqBLRADzQLL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-r5c4orqBLRADzQLL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-r5c4orqBLRADzQLL .sequenceNumber{fill:white;}#mermaid-svg-r5c4orqBLRADzQLL #sequencenumber{fill:#333;}#mermaid-svg-r5c4orqBLRADzQLL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-r5c4orqBLRADzQLL .messageText{fill:#333;stroke:none;}#mermaid-svg-r5c4orqBLRADzQLL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-r5c4orqBLRADzQLL .labelText,#mermaid-svg-r5c4orqBLRADzQLL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-r5c4orqBLRADzQLL .loopText,#mermaid-svg-r5c4orqBLRADzQLL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-r5c4orqBLRADzQLL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-r5c4orqBLRADzQLL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-r5c4orqBLRADzQLL .noteText,#mermaid-svg-r5c4orqBLRADzQLL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-r5c4orqBLRADzQLL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-r5c4orqBLRADzQLL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-r5c4orqBLRADzQLL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-r5c4orqBLRADzQLL .actorPopupMenu{position:absolute;}#mermaid-svg-r5c4orqBLRADzQLL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-r5c4orqBLRADzQLL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-r5c4orqBLRADzQLL .actor-man circle,#mermaid-svg-r5c4orqBLRADzQLL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-r5c4orqBLRADzQLL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt预检通过预检失败 调用一次 fetch 或 Axios判断是否跨域判断是否需要预检OPTIONS 预检请求返回允许的来源、方法和请求头POST 正式请求返回业务响应将响应交给前端代码抛出 CORS 错误
整个过程可以总结为:
- 前端代码调用一次接口;
- 浏览器发现请求跨域;
- 浏览器判断请求需要预检;
- 浏览器自动发送
OPTIONS; - 后端返回 CORS 许可信息;
- 浏览器确认允许;
- 浏览器发送正式
POST; - 后端执行业务逻辑;
- 浏览器将正式响应交给前端代码。
14. 排查清单
当 Network 中出现两个同名请求时,可以按以下顺序检查:
- 查看两条请求的
Request Method; - 判断是否为
OPTIONS + 正式方法; - 查看
Origin; - 查看
Access-Control-Request-Method; - 查看
Access-Control-Request-Headers; - 查看后端是否返回
Access-Control-Allow-*; - 查看
OPTIONS状态码; - 查看正式请求是否真正发出;
- 如果两条都是正式方法,检查
Initiator; - 检查重复调用、自动重试、重定向和代理。
15. 本次学习总结
通过这次学习,可以掌握以下知识:
- 浏览器通过协议、主机和端口判断两个地址是否同源;
- 只要其中一项不同,就可能构成跨域;
- CORS 是服务器向浏览器声明跨域访问权限的机制;
- 对于某些跨域请求,浏览器会先自动发送
OPTIONS预检; OPTIONS用于权限检查,POST、GET等正式请求才用于业务处理;- Network 中出现两条同名记录,不一定代表前端代码调用了两次;
- 必须通过
Request Method、状态码、请求头和Initiator进行判断; - 前端负责定义正式请求,浏览器负责执行安全检查,后端或网关负责放行;
- Apifox、Postman 和 curl 不受普通浏览器同源策略的直接限制;
- 如果两条请求都是
POST,需要排查重复调用、重试、重定向等其他原因。
一句话总结:
CORS 预检是浏览器在发送部分跨域请求前执行的安全检查,常表现为先发送 OPTIONS,再发送正式请求。
问题与解答
问题 1:为什么 Network 中会出现两个同名接口请求?
我的困惑:
前端代码看起来只调用了一次接口,但浏览器的 Network 面板中却出现了两条名称相同的记录,因此怀疑代码是否重复执行。
解答:
如果两条请求的方法分别是:
text
OPTIONS
POST
那么大概率不是业务代码调用了两次。
前端只要求浏览器发送一次 POST,但浏览器发现这是一个需要预检的跨域请求,于是先自动发送 OPTIONS,确认后端是否允许来源、方法和请求头。
预检通过后,浏览器才发送真正的 POST。
但不能只根据"名称相同"下结论。因为如果两条都是 POST,就可能是代码重复调用、自动重试或组件重复执行。
一句话记忆:
OPTIONS + POST通常是一次前端调用产生的预检请求和正式请求。
问题 2:这是一个普遍的 Web 开发知识点吗?
我的困惑:
不确定两个同名请求是否只是某个项目的特殊实现,还是所有前后端项目中都可能遇到的现象。
解答:
这是一个通用的 Web 开发知识点,与具体项目命名无关。
只要满足以下条件,就可能出现:
- 请求由浏览器中的网页发起;
- 前端和后端不是同源;
- 请求不满足简单请求条件;
- 后端通过 CORS 机制控制访问权限。
因此,在使用 Vue、React、Axios、Fetch 或其他浏览器前端技术时,都可能遇到预检请求。
它不是某个接口库专属的行为,也不是某个业务项目单独设计的功能。
一句话记忆:
CORS 预检是浏览器的通用安全机制,不是某个项目独有的逻辑。
问题 3:跨域请求都会出现两条记录吗?
我的困惑:
既然前端和后端地址不同属于跨域,是否所有跨域请求都会先发送 OPTIONS?
解答:
不会。
浏览器会区分简单请求和需要预检的请求。
部分满足限制的 GET、HEAD 或 POST 请求可以直接发送,不需要预检。
而以下情况通常需要预检:
- 使用
PUT、DELETE、PATCH等方法; - 携带
Authorization; - 携带自定义请求头;
- 使用
Content-Type: application/json; - 使用其他不符合简单请求条件的配置。
所以:
text
跨域 ≠ 一定发送 OPTIONS
更准确的关系是:
text
跨域 + 非简单请求 → 通常需要预检
一句话记忆:
只有需要预检的跨域请求才会先发送 OPTIONS,不是所有跨域请求都有两条记录。
问题 4:这是前端问题、后端问题,还是网络问题?
我的困惑:
预检请求出现在前端浏览器中,但又需要后端配置,因此不清楚应该把它归到哪一类知识。
解答:
从知识分类看,它属于:
- HTTP;
- 浏览器安全;
- 同源策略;
- CORS;
- 前后端通信。
因此,它本质上是一个 Web 网络与浏览器安全知识点。
从开发职责看:
- 前端负责定义正式请求;
- 浏览器负责判断是否跨域并发送预检;
- 后端或网关负责返回允许规则;
- 浏览器根据后端响应决定是否继续。
所以,出现 CORS 报错时不能简单地说"都是前端问题"或"都是后端问题"。
需要根据具体情况判断:
- 前端请求地址或请求头是否正确;
- 浏览器发出的预检包含什么;
- 后端是否正确返回 CORS 响应头;
- 网关是否拦截了
OPTIONS。
一句话记忆:
前端发请求,浏览器做检查,后端负责放行;它是三者交界处的 Web 网络知识。
问题 5:为什么浏览器报跨域错误,但 Apifox 或 Postman 可以正常请求?
我的困惑:
同一个接口在接口测试工具中可以成功,但在浏览器页面中却被拦截,看起来像是接口表现不一致。
解答:
CORS 主要是浏览器针对网页执行的安全机制。
Apifox、Postman 和 curl 并不是普通网页,因此通常不会受到浏览器同源策略的直接限制。
所以,接口测试工具请求成功,只能说明:
- 网络可以访问该接口;
- 接口可能能够正确处理参数;
- 后端业务逻辑可能正常。
它不能证明:
- 后端已经正确允许浏览器跨域;
OPTIONS预检一定能通过;- CORS 响应头配置正确。
浏览器失败而测试工具成功时,应该重点检查 CORS 配置。
一句话记忆:
Postman 能访问说明接口可用,不代表浏览器的 CORS 检查也能通过。
问题 6:看到两个同名请求时,应该如何快速判断原因?
我的困惑:
Network 中的信息很多,不清楚应该先看什么,容易仅凭请求名称进行猜测。
解答:
可以按下面的顺序排查:
- 先看
Request Method; - 如果是
OPTIONS + POST,检查 CORS; - 如果是
POST + POST,检查重复调用或重试; - 查看
Status Code; - 查看
Initiator; - 查看
Origin和Access-Control-*请求头; - 查看是否发生重定向;
- 查看开发代理、网关和 Service Worker。
其中最重要的第一步是:
text
Headers → General → Request Method
不要只看左侧显示的请求名称,因为 Network 通常使用 URL 最后一段作为名称,两条不同方法的请求也会显示成同一个名字。
一句话记忆:
判断两个同名请求,先看方法,再看状态码和 Initiator。