跨域资源请求:简单请求与非简单请求

说到跨域,大家最先想到的估计就是那个烦人的 Access-Control-Allow-Origin。但你有没有发现,有时候浏览器报跨域错,有时候却悄无声息?
这里头其实分了两类——简单请求和非简单请求。

我当年刚接网关项目时,被这俩概念坑过好几次。
有一次前端死活调不通接口,我查了半天响应头都对,后来才发现是 Preflight 请求没通过。说白了,浏览器帮你做了个过滤器,但这个过滤器分两种模式。

什么是简单请求?

简单请求,指的是浏览器可以“直接”发送的跨域请求。
它不需要提前发个 OPTIONS 探路,一锤子买卖。

满足以下所有条件才算简单请求:

  • 请求方法只能是 GET、HEAD、POST
  • 请求头中只能出现浏览器允许的“安全首部集合”
  • Content-Type 只能取:text/plain、multipart/form-data、application/x-www-form-urlencoded
  • 不能使用 ReadableStream 或任何事件监听器(常见于 fetch)

用你们最常见的场景举个例子:

// 简单请求:GET + 默认头
fetch('https://api.example.com/user', {
  method: 'GET',
  headers: {
    'Content-Type': 'text/plain'  // 安全
  }
})

这种情况下,浏览器直接发请求,然后看响应里有没有 Access-Control-Allow-Origin。
有且匹配,数据拿到;没有或匹配失败,前端 CORS 错误。

避坑提示:Content-Type 写成 application/json 就直接变成非简单请求。
我曾经有个项目,后端说“我支持跨域”,但前端一直报错,最后发现就是 Content-Type 没改回 text/plain。所以网关在设计时,通常会要求前端统一用 JSON,那就老老实实处理 Preflight 吧。

非简单请求:没那么简单

非简单请求,浏览器认为自己拿不准,所以先派一个“探子”——也就是 Preflight 预检请求。
这个探子用 OPTIONS 方法发,问服务器:“我能发这个请求吗?”
服务器回一个“可以”,浏览器才继续发正式请求。

什么情况下会触发 Preflight?

  • 使用了非简单方法:PUT、DELETE、PATCH、CONNECT 等
  • 自定义了非安全首部:比如 Authorization、X-Requested-With 等
  • Content-Type 不是那三个简单值
  • 请求使用了 ReadableStream 或 Blob 等复杂体

看一个典型例子:

// 非简单请求:POST + application/json + 自定义头
fetch('https://api.example.com/order', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Custom-Auth': 'token123'
  },
  body: JSON.stringify({ product: 'book' })
})

浏览器先发 OPTIONS:

OPTIONS /order HTTP/1.1
Origin: https://frontend.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: x-custom-auth, content-type

服务器必须这样回应:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Custom-Auth
Access-Control-Max-Age: 86400

收到允许后,浏览器才发真正的 POST 请求。

注意:很多后端新手只给响应加 Access-Control-Allow-Origin,忘了处理 OPTIONS 请求。
结果就是 Preflight 直接 404 或 500,前端永远拿不到数据。
我有个线上事故就是这样发生的——网关层没有配置 OPTIONS 路由,整个前端瘫痪了半小时。所以,网关必须显式处理 OPTIONS 请求并返回必要首部。

两种请求对比

对比维度 简单请求 非简单请求
请求链 直接发请求 + 响应 OPTIONS(预检)+ 正式请求 + 响应
浏览器行为 实时检查响应头 先检查预检响应,再发实际请求
典型方法 GET, POST, HEAD PUT, DELETE, PATCH 等
Content-Type text/plain, multipart/form-data, x-www-form-urlencoded application/json 及其他
自定义头 不允许 允许,但必须显式声明
性能影响 0 额外开销 多一次 OPTIONS 往返(可缓存)

Preflight 的缓存机制

你可能会想:每次请求都多一次 OPTIONS,岂不是慢死了?
其实浏览器允许用 Access-Control-Max-Age 控制预检的缓存时间,单位秒。

  • 设置 Access-Control-Max-Age: 86400 可以缓存一天
  • 缓存期间,同域同方法同头的请求不会重复发 Preflight
  • 但注意,浏览器上限通常为 24 小时(各浏览器有差异)

我建议在网关层统一返回 7200(两小时) 作为默认值。既避免频繁探路,又不会让缓存过期后产生瞬间的延迟波动。

完整流程对比图

简单请求 vs. 非简单请求 CORS 流程 简单请求 浏览器发请求(GET/POST/HEAD) 服务器响应 + CORS 头 浏览器检查响应头 数据可用 ✅ 非简单请求 浏览器发 OPTIONS(Preflight) 服务器返回 204 + 允许头 浏览器检查预检结果 浏览器发正式请求 数据可用 ✅ 差异要点 • 预检开销 • 头声明要求 • 缓存控制 • Content-Type 限制 • 方法限制 • 自定义头限制 • 响应头匹配

网关层面的处理策略

了解这些后,我们再看网关怎么设计。

  1. 统一拦截 OPTIONS:网关层对所有跨域请求预检直接返回 204,并带上白名单的 Origin、Methods、Headers。
  2. 动态允许 Origin:不要写死 *,尤其是带凭据的请求。我一般会让网关从配置文件读取允许域名列表,运行时校验。
  3. 合理设置 Max-Age:建议 7200 秒,配合后端配置。
  4. 注意浏览器兼容性:Safari 对 Preflight 缓存时间有限制,我曾遇到 iOS 设备上 Preflight 频繁发送导致的延迟抖动,最后把 Max-Age 降到了 600 秒才稳定。
核心原则: 网关是 CORS 的第一道关卡。
不要把跨域逻辑散落到各个微服务里,统一在网关层处理,否则你会陷入每个服务都要加一堆头的噩梦。
我个人习惯是:网关层全局配置 CORS 规则,后端微服务只管自己的业务逻辑。

好了,简单请求和非简单请求的区别就这些。
记住一句话:凡是浏览器拿不准的,都会先探个路。而网关要做的,就是当好这个“路标”。