CORS 跨域与 Options 预检请求
你写了一个前端页面,调用后端接口获取数据,代码看起来完全没问题,但浏览器控制台突然冒出一行红色错误:
Access to XMLHttpRequest at 'https://api.example.com/data' from origin 'https://www.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check.
更奇怪的是,打开 Network 面板一看,明明你发的是 POST 请求,却多出了一个 OPTIONS 请求——你根本没写过这行代码。这就是今天的主角:CORS 预检请求(Preflight Request)。
预检请求就像进入高级餐厅前的"着装检查"——浏览器先用 OPTIONS 请求问服务器:"我能不能穿这身衣服(带这些自定义头、用这种方法)进来?"只有服务器说"可以",真正的请求才会发出去。
一、什么是跨域?
在理解预检请求之前,我们得先搞清楚跨域的概念。
1.1 同源策略
浏览器有一个最基础的安全机制叫同源策略(Same-Origin Policy)。所谓"同源",指的是两个 URL 的协议(protocol)、域名(host) 和 端口(port) 完全一致。
| URL A | URL B | 是否同源 | 原因 |
|---|---|---|---|
https://example.com/a | https://example.com/b | 同源 | 协议、域名、端口都相同 |
https://example.com | http://example.com | 跨域 | 协议不同(https vs http) |
https://example.com | https://api.example.com | 跨域 | 域名不同(子域也算不同) |
https://example.com | https://example.com:8080 | 跨域 | 端口不同(443 vs 8080) |
假设没有同源策略:你登录了银行网站,银行的 Cookie 保存在浏览器中。此时你打开了一个恶意网站,这个网站的 JavaScript 可以直接向银行服务器发请求,浏览器会自动带上 Cookie——恶意网站就能以你的身份操作银行账户了。同源策略就是防止这种"偷偷摸摸"的跨站请求。
1.2 CORS 是什么
CORS(Cross-Origin Resource Sharing,跨域资源共享) 是一种标准化的机制,允许服务器显式声明哪些外部来源可以访问自己的资源。它通过一组 HTTP 头部字段来实现:
简单来说,CORS 的核心思想是:跨域请求能不能成功,由服务器说了算,浏览器负责执行。
二、简单请求 vs 预检请求
浏览器并不是对所有跨域请求都发预检。它会先判断这个请求是否属于简单请求——如果是,直接发;如果不是,先发预检。
2.1 简单请求的条件
一个请求要被浏览器判定为"简单请求",必须同时满足以下所有条件:
| 条件 | 允许的值 |
|---|---|
| 请求方法 | GET、HEAD、POST(仅这三种) |
| Content-Type | application/x-www-form-urlencoded、multipart/form-data、text/plain(仅这三种) |
| 请求头 | 只能使用 CORS 安全头部:Accept、Accept-Language、Content-Language、Content-Type(受限于上面三种值) |
| 其他 | 请求中没有使用 ReadableStream 对象;XMLHttpRequest.upload 没有注册事件监听器 |
2.2 简单请求的处理流程
对于简单请求,浏览器直接发出请求,并在请求中自动附加 Origin 头部:
GET /api/users HTTP/1.1
Host: api.example.com
Origin: https://www.example.com
Accept: application/json
服务器收到后,如果允许该来源访问,会在响应中添加 CORS 头部:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.example.com
Content-Type: application/json
[{"id": 1, "name": "张三"}, {"id": 2, "name": "李四"}]
浏览器检查 Access-Control-Allow-Origin 的值:
- 如果匹配当前页面的 Origin → 允许 JS 读取响应
- 如果不匹配或不存在 → 拦截响应,JS 拿不到数据
对于简单请求,即使 CORS 检查失败,请求已经到达了服务器并且服务器已经处理了。浏览器只是阻止了 JavaScript 读取响应。这意味着如果是 POST 请求写入数据,数据可能已经被写入了——这也是为什么"非简单请求"需要预检的原因之一。
2.3 什么时候触发预检
以下任何一种情况,浏览器就会判定为"需要预检的请求":
// 情况 1:使用了非简单方法
fetch('https://api.example.com/users/1', {
method: 'PUT', // PUT、DELETE、PATCH 等都会触发预检
});
// 情况 2:使用了自定义请求头
fetch('https://api.example.com/data', {
headers: {
'Authorization': 'Bearer token123', // 自定义头部
'X-Custom-Header': 'value', // 自定义头部
},
});
// 情况 3:Content-Type 不是简单类型
fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // 不是三种简单类型之一
},
body: JSON.stringify({ name: '张三' }),
});
在实际开发中,最容易触发预检请求的两个原因是:
- 使用
Content-Type: application/json发送 POST 请求(前后端分离项目几乎都用 JSON 通信) - 携带
Authorization头部(JWT Token 认证几乎是标配)
也就是说,现代前端开发中,绝大部分跨域接口请求都会触发预检。
三、Options 预检请求详解
3.1 预检的完整流程
当浏览器判定一个跨域请求为"非简单请求"时,会自动在真正的请求之前发送一个 OPTIONS 请求:
3.2 预检请求的内容
预检请求是一个 OPTIONS 方法的 HTTP 请求,由浏览器自动发出,开发者无法控制:
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://www.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
| 头部字段 | 说明 |
|---|---|
Origin | 当前页面的源(协议 + 域名 + 端口) |
Access-Control-Request-Method | 真正请求将使用的 HTTP 方法 |
Access-Control-Request-Headers | 真正请求将携带的自定义头部(逗号分隔) |
注意:预检请求没有请求体(body),它只是一个"询问"。
3.3 预检响应的内容
服务器收到 OPTIONS 请求后,需要返回相应的 CORS 响应头:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
Content-Length: 0
| 响应头部 | 说明 | 示例 |
|---|---|---|
Access-Control-Allow-Origin | 允许的来源(必须) | https://www.example.com 或 * |
Access-Control-Allow-Methods | 允许的 HTTP 方法 | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 允许的请求头 | Content-Type, Authorization |
Access-Control-Max-Age | 预检结果的缓存时间(秒) | 86400(24 小时) |
Access-Control-Allow-Credentials | 是否允许携带 Cookie | true |
Access-Control-Expose-Headers | 允许 JS 读取的额外响应头 | X-Total-Count, X-Request-Id |
3.4 预检失败的情况
如果预检不通过,浏览器会直接拦截,真正的请求不会发出:
常见的预检失败错误信息:
// 缺少 Allow-Origin
Access to XMLHttpRequest has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
// 方法不被允许
Access to XMLHttpRequest has been blocked by CORS policy:
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.
// 自定义头不被允许
Access to XMLHttpRequest has been blocked by CORS policy:
Request header field Authorization is not allowed by
Access-Control-Allow-Headers in preflight response.
四、预检缓存机制
每次发非简单请求都要先发一次 OPTIONS,这不是多了一次网络往返吗?确实是。所以浏览器提供了预检缓存机制。
4.1 Access-Control-Max-Age
服务器可以通过 Access-Control-Max-Age 告诉浏览器:这次预检的结果可以缓存多久。
Access-Control-Max-Age: 86400
在缓存有效期内,同一个来源对同一个 URL 的请求,只要请求方法和自定义头部在已批准的范围内,浏览器就不会再发预检请求。
4.2 各浏览器的最大缓存时间
不同浏览器对 Max-Age 有各自的上限,即使服务器设置再大也不会超过:
| 浏览器 | 最大缓存时间 |
|---|---|
| Chrome | 7200 秒(2 小时) |
| Firefox | 86400 秒(24 小时) |
| Safari | 604800 秒(7 天) |
如果服务器不返回 Access-Control-Max-Age,浏览器通常默认缓存时间为 5 秒(Chrome)或 不缓存,这意味着几乎每次请求都会触发预检。务必在服务端配置合理的缓存时间。
五、携带 Cookie 的跨域请求
默认情况下,跨域请求不会携带 Cookie。如果你需要在跨域请求中发送 Cookie(比如维持登录状态),需要前后端同时配置。
5.1 前端配置
// fetch API
fetch('https://api.example.com/user/profile', {
method: 'GET',
credentials: 'include', // 关键:携带跨域 Cookie
});
// XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/user/profile');
xhr.withCredentials = true; // 关键:携带跨域 Cookie
xhr.send();
// axios
axios.get('https://api.example.com/user/profile', {
withCredentials: true, // 关键:携带跨域 Cookie
});
5.2 后端配置
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true
当 Access-Control-Allow-Credentials: true 时,有以下严格限制:
Access-Control-Allow-Origin不能为*,必须指定确切的域名Access-Control-Allow-Headers不能为*,必须列出具体的头部Access-Control-Allow-Methods不能为*,必须列出具体的方法
违反这些规则,浏览器会直接拒绝响应。这是一个非常高频的线上 Bug。
5.3 完整示例
六、服务端配置实战
6.1 Node.js(Express)
const express = require('express');
const app = express();
// 方式一:手动配置 CORS 中间件
app.use((req, res, next) => {
// 允许的来源(不能用 * 如果需要携带 Cookie)
res.setHeader('Access-Control-Allow-Origin', 'https://www.example.com');
// 允许的方法
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
// 允许的请求头
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// 预检缓存时间
res.setHeader('Access-Control-Max-Age', '86400');
// 允许携带 Cookie
res.setHeader('Access-Control-Allow-Credentials', 'true');
// 处理预检请求:直接返回 204
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
// 方式二:使用 cors 库(推荐)
const cors = require('cors');
app.use(cors({
origin: 'https://www.example.com', // 允许的来源
methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的方法
allowedHeaders: ['Content-Type', 'Authorization'], // 允许的头
credentials: true, // 允许 Cookie
maxAge: 86400, // 预检缓存 24 小时
}));
6.2 Nginx
server {
listen 80;
server_name api.example.com;
# CORS 配置
add_header Access-Control-Allow-Origin "https://www.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 86400 always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
return 204;
}
location /api/ {
proxy_pass http://backend:3000/;
}
}
预检请求不需要响应体,使用 204 No Content 更语义化,还能节省少量带宽。不过用 200 OK 也完全没问题。
6.3 Spring Boot(Java)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://www.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.allowedHeaders("Content-Type", "Authorization")
.allowCredentials(true)
.maxAge(86400);
}
}
6.4 多来源动态配置
在实际项目中,你可能需要允许多个来源访问。但 Access-Control-Allow-Origin 只能设置一个值(或 *),不能写多个域名。常见做法是动态判断:
// Node.js 动态来源示例
const allowedOrigins = [
'https://www.example.com',
'https://admin.example.com',
'http://localhost:3000', // 开发环境
];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
// 动态 Origin 时必须设置 Vary 头,防止 CDN 缓存错误的响应
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Max-Age', '86400');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
当你根据请求的 Origin 动态设置 Access-Control-Allow-Origin 时,必须同时设置 Vary: Origin。否则 CDN 或浏览器可能缓存一个来源的 CORS 响应,错误地返回给另一个来源,导致跨域失败。
七、常见跨域解决方案对比
除了 CORS,还有其他一些绕过跨域限制的方式:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| CORS | 服务端设置 CORS 响应头 | 标准方案,功能完整 | 需要服务端配合 | 前后端分离的标准方案 |
| 代理服务器 | 前端请求同源的代理,代理转发到目标服务器 | 不需要目标服务器配合 | 多一层转发,增加延迟 | 开发环境、第三方 API |
| JSONP | 利用 <script> 标签不受同源限制 | 兼容老浏览器 | 只支持 GET,有安全风险 | 已基本淘汰 |
| postMessage | 窗口间消息通信 | 跨文档通信 | 只能在窗口/iframe 间使用 | iframe 通信 |
| WebSocket | WebSocket 协议不受同源限制 | 全双工通信 | 需要服务端支持 WebSocket | 实时通信场景 |
7.1 开发环境代理
在开发时最常用的方案是开发服务器代理,完全绕过浏览器跨域限制:
// Vite 配置(vite.config.ts)
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
// Webpack 配置(webpack.config.js / vue.config.js)
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
},
};
同源策略是浏览器的安全策略,只在浏览器环境中生效。服务器与服务器之间的 HTTP 请求不存在跨域问题。开发代理就是利用了这一点——浏览器以为自己请求的是同源的开发服务器,实际上开发服务器帮你把请求转发到了真正的 API 服务器。
八、调试跨域问题
8.1 Chrome DevTools 排查步骤
当遇到跨域问题时,可以按以下步骤排查:
8.2 使用 curl 模拟预检
你可以用 curl 手动发送 OPTIONS 请求来检查服务器的 CORS 配置:
curl -X OPTIONS https://api.example.com/api/users \
-H "Origin: https://www.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-v
关注响应中是否包含正确的 Access-Control-Allow-* 头部。
8.3 常见坑和解决方案
| 问题 | 原因 | 解决方案 |
|---|---|---|
| OPTIONS 返回 405 | 服务器未处理 OPTIONS 方法 | 在服务端路由/中间件中处理 OPTIONS |
| OPTIONS 返回 301/302 | 服务器对 OPTIONS 做了重定向 | 确保 OPTIONS 不被重定向,直接返回 |
| 携带 Cookie 失败 | Allow-Origin 设置为 * | 改为具体域名,设置 Allow-Credentials: true |
| 只有部分接口跨域失败 | 某些路由未添加 CORS 头 | 在全局中间件或网关层统一配置 |
| CDN 缓存导致跨域失败 | 不同来源拿到了同一份 CORS 头 | 添加 Vary: Origin 响应头 |
| Nginx 重复添加头部 | add_header 在多级 location 中重复 | 使用 always 参数,统一在一处配置 |
九、面试高频问题
Q1:什么是 CORS 预检请求?什么时候会触发?
参考回答:
CORS 预检请求是浏览器在发送"非简单请求"的跨域请求之前,自动发出的一个 OPTIONS 方法的 HTTP 请求。它的作用是先询问服务器:当前来源是否被允许、请求使用的方法和头部是否被支持。只有服务器"批准"后,浏览器才会发出真正的请求。
触发条件是请求不满足"简单请求"的要求,具体包括:
- 使用了 GET/HEAD/POST 之外的方法(如 PUT、DELETE、PATCH)
- POST 请求的 Content-Type 不是
application/x-www-form-urlencoded、multipart/form-data或text/plain - 请求中包含自定义头部(如 Authorization、X-Custom-Header)
Q2:简单请求和预检请求的区别是什么?
参考回答:
| 对比项 | 简单请求 | 预检请求 |
|---|---|---|
| 是否发 OPTIONS | 不发 | 先发 OPTIONS |
| 请求次数 | 1 次 | 2 次(OPTIONS + 真正请求) |
| 方法限制 | GET、HEAD、POST | 任意方法 |
| Content-Type 限制 | 仅三种简单类型 | 无限制 |
| 自定义头部 | 不能使用 | 可以使用 |
| 失败时机 | 请求已到达服务器,响应被拦截 | OPTIONS 阶段即被拦截,真正请求不发 |
| 安全性 | 较低(请求已执行) | 较高(先检查再执行) |
Q3:如何优化预检请求的性能?
参考回答:
-
设置 Access-Control-Max-Age:让浏览器缓存预检结果,避免重复发送 OPTIONS。建议设置为 86400(24 小时),注意 Chrome 最大只缓存 2 小时。
-
尽量使用简单请求:如果业务允许,可以将 POST 请求的 Content-Type 设为
application/x-www-form-urlencoded,避免触发预检(但不推荐,牺牲了代码可读性)。 -
使用同源代理:通过 Nginx 反向代理或开发服务器代理,让请求变成同源,从根本上消除预检。
-
合并请求:减少跨域请求的数量,间接减少预检次数。
Q4:为什么携带 Cookie 时 Access-Control-Allow-Origin 不能设为 *?
参考回答:
这是浏览器的安全策略。* 表示允许任意来源访问,如果同时允许携带 Cookie,就意味着任何网站都可以携带用户的 Cookie 访问你的 API,这等于完全绕过了同源策略的保护,造成严重的安全隐患(如 CSRF 攻击)。
因此规范规定:当 Access-Control-Allow-Credentials: true 时,Allow-Origin、Allow-Headers、Allow-Methods 都不能使用通配符 *,必须明确指定具体的值。
Q5:跨域请求失败,是请求没发出去还是响应被拦截了?
参考回答:
要分两种情况:
-
简单请求:请求已经发出并到达服务器,服务器也已经处理并返回响应。只是浏览器在检查响应头后发现不满足 CORS 要求,所以阻止 JavaScript 读取响应。也就是说,副作用(如数据写入)可能已经发生。
-
预检请求:如果 OPTIONS 预检失败,真正的请求根本不会发出。这是预检机制存在的核心价值——在有副作用的操作(PUT、DELETE 等)执行之前,先确认是否被允许。
Q6:如何在不修改服务端代码的情况下解决跨域问题?
参考回答:
- 开发环境:使用 Vite/Webpack 的 devServer proxy 代理
- 生产环境:在 Nginx 层配置反向代理,将 API 请求代理到后端,使前后端同源
- 浏览器插件:安装 CORS 插件(仅限调试,不能用于生产)
- BFF 层:搭建 Node.js 中间层(Backend for Frontend),由中间层转发 API 请求
十、总结
核心知识点回顾:
| 要点 | 说明 |
|---|---|
| 同源策略 | 浏览器安全基石,协议 + 域名 + 端口必须完全一致 |
| CORS | 服务器通过 HTTP 头部声明允许的跨域规则 |
| 简单请求 | GET/HEAD/POST + 简单 Content-Type + 无自定义头部 |
| 预检请求 | OPTIONS 方法,询问服务器是否允许即将发送的请求 |
| 预检缓存 | Access-Control-Max-Age 避免重复预检 |
| 携带 Cookie | 前端 credentials: include + 后端 Allow-Credentials: true + 不能用 * |
| 开发代理 | 最简单的跨域方案,利用服务端不受同源限制的特点 |
| Vary: Origin | 动态来源时必须设置,防止缓存错乱 |