做前端开发,几乎人人都遇到过浏览器控制台的红色报错:跨域 CORS 被拦截。它本质是浏览器同源策略(Same-Origin Policy)的安全护栏,而不是服务器拒绝响应。本文从原理到落地,把简单请求、预检请求(preflight)和带凭证的请求一次讲透,并给出可直接抄进项目的 Nginx、Node、Spring Boot 配置。
一、什么是跨域:同源策略的边界
同源策略规定:只有当协议、域名、端口三者完全一致时,两个 URL 才算“同源”。只要有一个不同,浏览器就会把它判定为跨域。例如页面在 https://fsdata.site,去请求 https://api.fsdata.site 或 https://fsdata.site:8080,都会触发跨域。注意:跨域是浏览器的限制,用 Postman 或 curl 直接请求并不会报 CORS 错误——这也正是调试时最容易让人困惑的地方。
二、CORS 响应头全景:一张表记牢
服务端通过在响应里带上 Access-Control-* 头,告诉浏览器“我允许这个来源访问”。下面是最常用的一组头:
| 响应头 | 作用 | 常见取值 |
|---|---|---|
| Access-Control-Allow-Origin | 允许的来源 | https://fsdata.site 或 * |
| Access-Control-Allow-Methods | 允许的 HTTP 方法 | GET, POST, PUT, DELETE, OPTIONS |
| Access-Control-Allow-Headers | 允许的自定义请求头 | Authorization, Content-Type |
| Access-Control-Allow-Credentials | 是否允许带 Cookie | true / false |
| Access-Control-Max-Age | 预检结果缓存秒数 | 86400 |
三、简单请求 vs 预检请求:决定成败的分水岭
浏览器把跨域请求分成两类。满足以下全部条件的是“简单请求”,直接发,不带预检:方法是 GET/POST/HEAD;仅含安全头(Accept、Content-Type 等);且 Content-Type 只能是 application/x-www-form-urlencoded、multipart/form-data、text/plain 三者之一。
| 维度 | 简单请求 | 预检请求 |
|---|---|---|
| 触发条件 | 方法/头/类型均受限 | JSON body、自定义头、PUT/DELETE |
| 实际请求前 | 不发 OPTIONS | 先发 OPTIONS 探路 |
| 服务端额外要求 | 仅需 Allow-Origin | 需 Allow-Methods / Allow-Headers |
下面这种带 JSON body 的写法,必然触发预检,因为 Content-Type: application/json 不在简单请求白名单内:
fetch('https://api.fsdata.site/v1/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'hello' })
});
浏览器会先悄悄发一个 OPTIONS 请求,带上 Access-Control-Request-Method 和 Access-Control-Request-Headers,只有服务端返回 2xx 且头正确,真正的 POST 才会发出。
四、服务端怎么配:三套可抄配置
4.1 Nginx 反向代理层放行
最常见也最省心的方式是在网关层统一加头。下面这段可直接放进 Nginx 的 location 块(和本站 Nginx 基础配置、Nginx 反向代理实战 的写法一脉相承):
location /api/ {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Type' 'text/plain; charset=utf-8';
return 204;
}
}
4.2 Node / Express 中间件
Node 服务用官方 cors 包最稳妥,一行即可;需要精细控制时传配置对象:
const cors = require('cors');
app.use(cors({
origin: 'https://fsdata.site',
credentials: true
}));
4.3 Spring Boot 全局配置
Spring Boot 推荐实现 WebMvcConfigurer 做全局映射,避免在每个 Controller 上散落 @CrossOrigin:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://fsdata.site")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true)
.maxAge(3600);
}
}
五、带凭证的跨域:withCredentials 的坑
前端如果要携带 Cookie(登录态),必须两端同时配合:请求侧加 credentials: 'include',且服务端返回 Access-Control-Allow-Credentials: true。关键约束:一旦允许凭证,Access-Control-Allow-Origin 就不能写 *,必须写明具体来源,否则浏览器直接拒绝。
fetch('https://api.fsdata.site/v1/me', {
credentials: 'include' // 必须与服务端 Allow-Credentials: true 配对
});
六、生产环境 Checklist 与常见报错
上线前对照这张表排查,能解决 90% 的 CORS 工单:
| 现象 | 根因 | 修复 |
|---|---|---|
| OPTIONS 返回 403/404 | 网关未放行 OPTIONS 方法 | Nginx 对 OPTIONS 返回 204 |
| Allow-Origin 不匹配 | 写了 * 但带了凭证 | 改为具体域名 |
| 自定义头仍被拦 | Allow-Headers 没列全 | 补全 Authorization 等 |
| 预检频繁耗时 | 未设 Max-Age | 设 86400 缓存 |
七、前端兜底:代理与 JSONP 的取舍
开发阶段最干净的方案是本地代理:在 Vue3 项目 的 vite.config.ts 里配 server.proxy,把 /api 转发到后端,浏览器侧完全无跨域。至于 JSONP,它只能发 GET 且依赖后端改造,如今已被 CORS 全面取代,新项目不建议再引入。
结语
跨域 CORS 不是玄学,而是一套“浏览器问、服务端答”的协商协议。记住三件事:简单请求看类型、复杂请求必预检、带凭证不能写星号。把本文的 Nginx / Node / Spring Boot 配置按自己域名改一改,就能从报错走向上线。




