Appearance
CORS 预检:为什么发了两次请求
浏览器 Network 面板中同一个 API 端点先发 OPTIONS,再发 GET/POST,通常对应的是浏览器的 CORS 预检(Preflight)机制。
同源策略与 CORS 的关系
同源策略(Same-Origin Policy)是浏览器的安全基石——阻止一个源的脚本访问另一个源的资源。判断"同源"只看三点:协议 + 域名 + 端口,三者必须完全一致。
同源策略阻止的行为:
- 读取跨域 iframe 的 DOM
- 读取跨域请求的响应体(请求可以发出去,但响应被浏览器拦截)
- 访问跨域的 Cookie、LocalStorage、IndexedDB
CORS(Cross-Origin Resource Sharing)是"受控的放松"——允许服务器通过特定的响应头声明"我允许哪些源访问我"。预检是 CORS 的安全阀门——在实际请求之前,先发一个 OPTIONS 请求确认服务器是否允许。
简单请求 vs 非简单请求:精确的判定条件
Fetch 规范 §4.8 定义了"简单请求"的三个同时条件:
条件 1:请求方法是 GET、HEAD 或 POST。
条件 2:请求头(除了浏览器自动设置的 CORS 安全列表头外)只包含以下字段:
AcceptAccept-LanguageContent-LanguageContent-Type(仅限以下三个值):application/x-www-form-urlencodedmultipart/form-datatext/plain
Range(CORS 安全列表头)- 以及少数其他 CORS 安全列表头(
DPR、Downlink、Save-Data、Viewport-Width、Width已从规范中移除)
条件 3:请求不携带 ReadableStream 对象,且如果使用 XMLHttpRequest,没有注册 upload 事件监听器。
任何不满足这三个条件的请求都是"非简单请求",浏览器会在实际请求之前自动发送一个 OPTIONS 请求。
高频触发组合是:POST + Content-Type: application/json + Authorization: Bearer xxx。这三个条件叠加后会稳定触发预检,因为 application/json 不在允许的 Content-Type 列表中,Authorization 也属于自定义头。
OPTIONS 预检的请求/响应
浏览器自动生成的 OPTIONS 请求包含以下请求头:
http
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-typeOrigin:告诉服务器"请求来自哪个源"Access-Control-Request-Method:告诉服务器"我实际要发的是 POST"Access-Control-Request-Headers:告诉服务器"实际请求会带这些自定义头"
服务器通过预检后返回:
http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400各响应头的含义:
| 响应头 | 含义 |
|---|---|
Access-Control-Allow-Origin | 允许的源(不能是 * 如果请求携带 credentials) |
Access-Control-Allow-Methods | 允许的 HTTP 方法列表 |
Access-Control-Allow-Headers | 允许的自定义请求头列表 |
Access-Control-Max-Age | 浏览器缓存这个预检结果的时长(秒) |
预检的缓存(Access-Control-Max-Age)
Access-Control-Max-Age 让浏览器在指定时间内不再对同一 URL 重复发送 OPTIONS。但浏览器的缓存上限不同:
| 浏览器 | 最大缓存时间 | 备注 |
|---|---|---|
| Chromium(Chrome/Edge) | 600 秒(10 分钟) | 硬编码在 network::kMaxPreflightCacheTimeout,即使服务器返回更大的值也会被截断 |
| Firefox | 86400 秒(24 小时) |
这意味着 Chrome 用户每 10 分钟至少触发一次预检。对于 SPA 应用,10 分钟内频繁 API 调用的场景下,预检开销在首请求之后基本可忽略。
注意:预检缓存是按 (URL, Origin) 维度存储的。https://api.example.com/v1/users 和 https://api.example.com/v1/posts 的预检缓存是独立的。
工程中的优化策略
1. 消除不必要的预检。
如果 API 只涉及 GET 请求且不需要自定义头,保持在简单请求范围内就不会触发 OPTIONS 往返。但这类前提在实际接口里并不常见,因为 application/json 和 Authorization 头非常普遍。
2. 合理设置 Max-Age。
服务端配置,权衡安全性和性能:
nginx
# Nginx:添加 CORS 预检缓存头
add_header Access-Control-Max-Age 600;600 秒是 Chromium 的上限——超过这个值的设置只在 Firefox 中对后续请求有额外收益。
3. 使用同源代理(开发/生产)。
在开发环境用 Vite/Webpack proxy 转发 API 请求,在 Vite 中配置:
ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});生产环境用 Nginx 反向代理统一域名:
nginx
location /api/ {
proxy_pass https://api.example.com/;
proxy_set_header Host api.example.com;
}同源代理从根本上消除跨域——对于前端,所有请求的源都是当前页面源,不存在跨域。这是最优方案。
4. 避免不必要的自定义头。
如果 API 不需要区分源,且不需要发送 cookie,可以:
- 用查询参数代替自定义头(如
?token=xxx代替Authorization: Bearer xxx)——但这有安全风险(token 出现在 URL 中,被浏览器历史、服务器日志记录) - 把认证信息放在 Cookie 中配合
SameSite=None; Secure——但需要 CORS 返回Access-Control-Allow-Credentials: true
通常不值得为了省一次预检而牺牲安全性。OPTIONS 请求本身是一次轻量的 HTTP 往返(无请求体,无响应体),从性能角度,它的开销比 application/json 的便利性和 Authorization 头安全性小得多。
5. CDN 层处理 CORS。
在 CDN 层面统一配置 CORS 响应头,而不是在每个后端服务中单独处理。确保 CDN 正确转发 OPTIONS 请求(很多 CDN 默认不缓存 OPTIONS)。
