为什么 Network 中会出现两个同名请求?——CORS 预检请求学习笔记

为什么 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

CORSCross-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 请求并不是为了获取业务数据,而是在询问后端:

  1. 是否允许该前端来源访问;
  2. 是否允许使用 POST 方法;
  3. 是否允许携带这些请求头。

如果后端返回允许信息,浏览器才会继续发送正式的 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 一般不处理用户信息查询业务;
  • 它主要检查是否允许正式请求;
  • 返回值常见为 200204
  • 它通常没有业务响应体。

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

浏览器仍会拦截。

端口不同也属于不同来源。

前端可能配置:

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 错误

整个过程可以总结为:

  1. 前端代码调用一次接口;
  2. 浏览器发现请求跨域;
  3. 浏览器判断请求需要预检;
  4. 浏览器自动发送 OPTIONS
  5. 后端返回 CORS 许可信息;
  6. 浏览器确认允许;
  7. 浏览器发送正式 POST
  8. 后端执行业务逻辑;
  9. 浏览器将正式响应交给前端代码。

14. 排查清单

当 Network 中出现两个同名请求时,可以按以下顺序检查:

  1. 查看两条请求的 Request Method
  2. 判断是否为 OPTIONS + 正式方法
  3. 查看 Origin
  4. 查看 Access-Control-Request-Method
  5. 查看 Access-Control-Request-Headers
  6. 查看后端是否返回 Access-Control-Allow-*
  7. 查看 OPTIONS 状态码;
  8. 查看正式请求是否真正发出;
  9. 如果两条都是正式方法,检查 Initiator
  10. 检查重复调用、自动重试、重定向和代理。

15. 本次学习总结

通过这次学习,可以掌握以下知识:

  • 浏览器通过协议、主机和端口判断两个地址是否同源;
  • 只要其中一项不同,就可能构成跨域;
  • CORS 是服务器向浏览器声明跨域访问权限的机制;
  • 对于某些跨域请求,浏览器会先自动发送 OPTIONS 预检;
  • OPTIONS 用于权限检查,POSTGET 等正式请求才用于业务处理;
  • 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 开发知识点,与具体项目命名无关。

只要满足以下条件,就可能出现:

  1. 请求由浏览器中的网页发起;
  2. 前端和后端不是同源;
  3. 请求不满足简单请求条件;
  4. 后端通过 CORS 机制控制访问权限。

因此,在使用 Vue、React、Axios、Fetch 或其他浏览器前端技术时,都可能遇到预检请求。

它不是某个接口库专属的行为,也不是某个业务项目单独设计的功能。

一句话记忆:

CORS 预检是浏览器的通用安全机制,不是某个项目独有的逻辑。

问题 3:跨域请求都会出现两条记录吗?

我的困惑:

既然前端和后端地址不同属于跨域,是否所有跨域请求都会先发送 OPTIONS

解答:

不会。

浏览器会区分简单请求和需要预检的请求。

部分满足限制的 GETHEADPOST 请求可以直接发送,不需要预检。

而以下情况通常需要预检:

  • 使用 PUTDELETEPATCH 等方法;
  • 携带 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 中的信息很多,不清楚应该先看什么,容易仅凭请求名称进行猜测。

解答:

可以按下面的顺序排查:

  1. 先看 Request Method
  2. 如果是 OPTIONS + POST,检查 CORS;
  3. 如果是 POST + POST,检查重复调用或重试;
  4. 查看 Status Code
  5. 查看 Initiator
  6. 查看 OriginAccess-Control-* 请求头;
  7. 查看是否发生重定向;
  8. 查看开发代理、网关和 Service Worker。

其中最重要的第一步是:

text 复制代码
Headers → General → Request Method

不要只看左侧显示的请求名称,因为 Network 通常使用 URL 最后一段作为名称,两条不同方法的请求也会显示成同一个名字。

一句话记忆:

判断两个同名请求,先看方法,再看状态码和 Initiator。

相关推荐
菩提树下的打坐1 小时前
测试左移的更深一层:用 DDD 战术设计反推测试边界
学习
niaiheni2 小时前
红队实战:记一次Spring Cloud Gateway SpEL表达式注入到哥斯拉内存马(CVE-2022-22947)
web安全·spring cloud·网络安全
daad7772 小时前
记录matlab状态机demo
java·网络·matlab
casual~2 小时前
模逆元计算方法详解:扩展欧几里得算法与费马小定理
学习·算法·逆元
@Mike@2 小时前
06-数据库学习笔记(存储模型与数据压缩)
数据库·笔记·学习
paopaokaka_luck2 小时前
基于springboot3+vue3的智能文库平台(AI智能搜索、AI智能汇总、实时在线状态展示、多格式文档预览与富文本编辑、Echarts图形化分析)
前端·网络·spring boot·网络协议·echarts
Yang96113 小时前
告别“慢工出细活”:如何平衡光纤熔接的速度与精度?
网络
我的xiaodoujiao3 小时前
快速学习Python基础知识详细图文教程14--模块
开发语言·python·学习·测试工具
wdfk_prog3 小时前
嵌入式面试真题学习笔记系列
笔记·学习·面试