Fetch 发送两次请求的原因

理解 CORS 预检请求机制

问题

使用 fetch 发送请求时,为什么浏览器 Network 面板会显示发送了两次请求?

解答

fetch 发送两次请求是因为 CORS 预检请求(Preflight Request)。当发送跨域的”非简单请求”时,浏览器会先发送一个 OPTIONS 请求询问服务器是否允许该请求。

请求流程

第一次请求:OPTIONS(预检请求)

服务器返回允许的方法、头部等信息

第二次请求:实际的 GET/POST/PUT 等请求

触发预检请求的条件

只要满足以下任一条件,就会触发预检请求:

// 1. 使用了非简单方法
fetch('https://api.example.com/data', {
  method: 'PUT'  // PUT、DELETE、PATCH 等
})

// 2. 设置了自定义请求头
fetch('https://api.example.com/data', {
  headers: {
    'X-Custom-Header': 'value',  // 自定义头部
    'Content-Type': 'application/json'  // 非简单 Content-Type
  }
})

// 3. Content-Type 不是简单类型
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'  // 触发预检
  },
  body: JSON.stringify({ name: 'test' })
})

简单请求(不触发预检)

// 满足以下所有条件的请求不会触发预检:
// 1. 方法是 GET、HEAD、POST 之一
// 2. 只使用了简单头部
// 3. Content-Type 是以下之一:
//    - text/plain
//    - multipart/form-data
//    - application/x-www-form-urlencoded

// 这个请求不会触发预检
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  body: 'name=test&age=18'
})

预检请求示例

// 客户端代码
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123'
  },
  body: JSON.stringify({ name: 'John' })
})
# 第一次请求(预检)
OPTIONS /users HTTP/1.1
Origin: https://mysite.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

# 服务器响应
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

# 第二次请求(实际请求)
POST /users HTTP/1.1
Content-Type: application/json
Authorization: Bearer token123

服务端配置(Node.js 示例)

// Express 处理 CORS
const express = require('express')
const app = express()

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'https://mysite.com')
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
  res.header('Access-Control-Max-Age', '86400')  // 缓存预检结果 24 小时
  
  // 直接响应预检请求
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204)
  }
  next()
})

关键点

  • 两次请求是 CORS 预检机制导致的,第一次是 OPTIONS 请求
  • 非简单请求才会触发预检:非简单方法、自定义头部、application/json
  • 简单请求条件:GET/HEAD/POST + 简单头部 + 简单 Content-Type
  • Access-Control-Max-Age 可以缓存预检结果,减少 OPTIONS 请求次数
  • 同源请求不会触发预检,这是跨域特有的机制