主题
CORS 跨域资源共享 · 从 0 到 1 完全指南
一句话开篇:本章用"小区门禁、跨城办事、海关报关"等生活类比,把困扰每个前端的"跨域报错"彻底讲清——你将看懂为什么会跨域、CORS 协议每个响应头到底在做什么、前后端各要配什么、以及面试官最爱追问的细节。
📖 目录
- 0. 你大概率遇过这个报错
- 1. 生活类比(先建立直觉)
- 2. 同源策略:浏览器的"门禁规则"
- 3. 为什么需要 CORS
- 4. CORS 的两类请求:简单请求 vs 预检请求
- 5. CORS 完整响应头详解
- 6. 携带凭证(Cookie / Auth)的跨域
- 7. 服务端配置实战
- 8. 前端配置实战
- 9. 开发期最常用:本地代理
- 10. 其他跨域方案大盘点
- 11. ⚠️ 易踩的坑(Top 15)
- 12. 安全相关:CORS 不等于安全
- 13. 完整请求生命周期图
- 14. 一句话总结
- 15. 延伸阅读
0. 你大概率遇过这个报错
Access to fetch at 'http://api.example.com/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.99% 的前端在第一次连后端接口时都被这条红字劝退过。这条报错的关键信息有 3 个:
- 谁挡住了? —— 浏览器(不是后端、不是网关)
- 挡住的原因? —— 没看到一个叫
Access-Control-Allow-Origin的响应头 - 从哪来到哪去? —— 从
http://localhost:3000想去http://api.example.com
把这 3 个问题搞透,你就掌握了 CORS 的 80%。本章正是围绕它们展开。
1. 生活类比(先建立直觉)
| 概念 | 生活类比 | 一句话解释 |
|---|---|---|
| 同源策略 | 你住 A 小区,不能随意进 B 小区拿东西 | 浏览器对"不同源"做隔离 |
| 源 (Origin) | 你的小区地址 = 城市 + 街道 + 门牌号 | 协议 + 域名 + 端口 |
| 跨域请求 | 你想去 B 小区拿快递 | JS 想访问别的源的资源 |
| CORS | B 小区物业贴了一张白名单:"欢迎 A 小区业主" | 服务端通过响应头明示"我允许谁" |
| 简单请求 | 直接去敲门、问问就走(GET 一下) | 浏览器直接发,看响应头放不放行 |
| 预检请求 (Preflight) | 先打个电话问"我能带行李进来吗?" | 浏览器先发 OPTIONS 探路 |
| 凭证 (Credentials) | 你想带身份证(Cookie)进去 | 必须双方都"额外签字" |
| Allow-Origin: * | "本店欢迎所有人参观" | 任何源都能访问(不能配 Cookie) |
| Vary: Origin | 物业按访客小区不同发不同通行证 | CDN 缓存按 Origin 区分 |
| JSONP | 用快递包裹(<script>)夹带回信 | 老办法,仅 GET,已淘汰 |
| 代理 (Proxy) | 你让自家保安去 B 小区取件,再交给你 | 同源前端 → 自家后端 → 转发 |
💡 先记住一个关键直觉:跨域是浏览器单方面挡的,请求其实已经发出去了,响应也回来了,只是浏览器不让你的 JS 读到结果而已(这点很多人不知道,会引出很多面试题)。
2. 同源策略:浏览器的"门禁规则"
2.1 什么叫"同源"
同源 = 协议 + 域名 + 端口 三者完全相同。
任何一个不一样,浏览器就把你判定为"跨源"。
| URL A | URL B | 同源吗? | 原因 |
|---|---|---|---|
http://a.com/page1 | http://a.com/page2 | ✅ | 完全相同 |
http://a.com:80 | http://a.com | ✅ | http 默认 80 |
http://a.com | https://a.com | ❌ | 协议不同 |
http://a.com | http://a.com:8080 | ❌ | 端口不同 |
http://a.com | http://www.a.com | ❌ | 子域不同 |
http://a.com | http://b.com | ❌ | 域名不同 |
http://a.com | http://A.COM | ✅ | 域名大小写不敏感 |
http://192.168.1.1 | http://a.com | ❌ | IP 与域名不等价(即使解析一致) |
2.2 同源策略到底限制了什么
不是"任何跨域都被拦",它的边界很精细:
| 行为 | 是否受限 |
|---|---|
fetch / XMLHttpRequest 跨源 | ❌ 受限(响应内容不可读) |
| 读取跨源 iframe 的 DOM / contentWindow.document | ❌ 受限 |
| 读取跨源 Cookie / LocalStorage / IndexedDB | ❌ 受限 |
跨源 <img src> 加载并显示 | ✅ 不受限(但 canvas 取像素受限) |
跨源 <script src> 加载并执行 | ✅ 不受限(错误信息脱敏) |
跨源 <link rel="stylesheet"> | ✅ 不受限 |
<a href> 跳转 | ✅ 不受限 |
<form> 提交跨域 | ✅ 不受限(但拿不到响应) |
window.postMessage 跨源通信 | ✅ 专门用于跨源 |
🤯 看到这里你可能很迷惑:"既然
<form>能跨域提交,那 fetch 为什么不行?"答案:因为
<form>提交后页面会跳走,发起方拿不到响应;而fetch是 JS 在原页面拿响应,才有"偷数据"的风险。同源策略要拦的就是这个"读"的能力。
2.3 没有同源策略会怎么样?
举个真实的攻击场景:
1. 你刚刚在 https://bank.com 登录,浏览器存了 cookie
2. 你又打开了恶意网站 https://evil.com 看新闻
3. evil.com 的 JS 代码偷偷写了:
fetch('https://bank.com/api/transfer?to=hacker&amount=10000',
{ credentials: 'include' })
4. 浏览器自动带上 bank.com 的 cookie
5. 钱被转走,你还啥都不知道正是同源策略让上面的 fetch 拿不到响应(甚至请求都能被预检拦下来),才让浏览器成为相对安全的执行环境。
3. 为什么需要 CORS
同源策略很安全,但太"一刀切"了。现实业务中:
- 公司有多个域名:
www.company.com+api.company.com+cdn.company.com - 前后端分离开发:本地
localhost:5173调用localhost:8080 - 第三方服务:要调用地图、天气、支付等公开 API
- 微前端:主应用
app.com加载子应用subapp.com
这些都是正当的跨源需求,只能简单粗暴地禁掉吗?
CORS(Cross-Origin Resource Sharing,跨域资源共享) 就是 W3C 给出的标准答案:
服务器在响应头里明示"我允许哪些源访问我",浏览器看到允许就放行,没看到就拦截。
形象地说:
旧规则: 小区门禁 → 一律不让外来车辆进
新规则(CORS): 小区门禁 → 看车上有没有"白名单标牌"
↓
没标牌 → 拦
有标牌 → 放行(标牌由小区物业 = 服务端 颁发)关键认知:CORS 是服务端说了算,前端只是"被动遵守"。前端没法靠改自己的代码绕开 CORS(除非走代理)。
4. CORS 的两类请求:简单请求 vs 预检请求
CORS 把跨域请求分成两类,处理方式截然不同。
4.1 简单请求(Simple Request)
定义:同时满足以下三个条件 → 简单请求
| 条件 | 允许范围 |
|---|---|
| 方法 | GET / HEAD / POST |
| Content-Type | text/plain / multipart/form-data / application/x-www-form-urlencoded |
| 请求头 | 只能是 CORS 安全头:Accept / Accept-Language / Content-Language / Content-Type(前面 3 种之一) |
流程:浏览器直接发请求,由响应头决定能否读响应。
[浏览器] ─── GET /api/users ───────────────► [服务器]
Origin: http://a.com
[浏览器] ◄── 200 OK ───────────────────── [服务器]
Access-Control-Allow-Origin: http://a.com
Body: {...}
✅ 浏览器看到 Allow-Origin 匹配 → JS 拿到响应
❌ 没有或不匹配 → 控制台报错,JS 拿不到⚠️ 请注意:即使浏览器最终拦了你的 JS,请求已经实际打到了服务器。如果是
POST一笔订单,服务器可能已经写库了!这就是为什么"删除/修改"类操作要做 CSRF Token 的原因。
4.2 预检请求(Preflight)
不是简单请求 → 浏览器先发一次 OPTIONS 询问,得到许可后才发真请求。
触发预检的常见场景:
- 用
PUT/DELETE/PATCH等方法 - 用
Content-Type: application/json(这是 99% 的 REST API!) - 加了自定义头部,如
Authorization、X-Token
完整流程:
(第 1 步:预检)
[浏览器] ─── OPTIONS /api/users ──────────► [服务器]
Origin: http://a.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, X-Token
[浏览器] ◄── 204 No Content ────────────── [服务器]
Access-Control-Allow-Origin: http://a.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, X-Token
Access-Control-Max-Age: 86400
(第 2 步:真请求)
[浏览器] ─── PUT /api/users ──────────────► [服务器]
Origin: http://a.com
Content-Type: application/json
X-Token: xxx
Body: {...}
[浏览器] ◄── 200 OK ────────────────────── [服务器]
Access-Control-Allow-Origin: http://a.com💡 生活类比:预检就像你带行李进高档酒店,门口保安先打电话问前台"这位客人能带 3 个箱子上楼吗?",前台说可以你才能进去。
第一次问完,保安会记一段时间(
Max-Age),下次同一位客人带同样行李就不用再问了。
4.3 简单 vs 预检 一图速记
┌──────── 跨源 fetch ────────┐
│ │
▼ ▼
是简单请求? 不是简单请求
(GET/POST/HEAD 且 (PUT/DELETE 或
Content-Type 受限 application/json
且无自定义头) 或自定义头)
│ │
▼ ▼
┌──────────────┐ ┌────────────────┐
│ 直接发真请求 │ │ 先发 OPTIONS │
│ │ │ 预检通过 → │
│ 看响应头放行 │ │ 再发真请求 │
└──────────────┘ └────────────────┘5. CORS 完整响应头详解
CORS 一共有 6 个响应头,背下来你就理解整个协议了。
5.1 Access-Control-Allow-Origin(必备)
作用:告诉浏览器"哪个源可以读响应"。
http
# 写法 1:通配符(任何源都可以)
Access-Control-Allow-Origin: *
# 写法 2:明确指定(推荐)
Access-Control-Allow-Origin: https://app.example.com
# 写法 3:动态返回(最实用)
# 后端读取请求的 Origin,校验后原样返回⚠️ 致命坑:
Allow-Origin只能是单个源或*,不支持多个源用逗号分隔!要多个白名单的话,必须后端动态判断后返回。
5.2 Access-Control-Allow-Methods(预检必备)
作用:告诉浏览器"我允许哪些 HTTP 方法"。
http
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS5.3 Access-Control-Allow-Headers(预检必备)
作用:告诉浏览器"我允许请求里带哪些自定义头部"。
http
Access-Control-Allow-Headers: Content-Type, Authorization, X-Token💡 注意是响应头允许的请求头,别绕晕。也支持
*(但和 Cookie 一样有限制)。
5.4 Access-Control-Allow-Credentials(带 Cookie 才需要)
作用:告诉浏览器"是否允许带凭证(Cookie / HTTP Auth)"。
http
Access-Control-Allow-Credentials: true⚠️ 一旦设为
true,Allow-Origin不能为*,必须是明确的源;同时Allow-Headers和Allow-Methods也不能为*。
5.5 Access-Control-Max-Age(性能优化)
作用:告诉浏览器"这次预检结果可以缓存多少秒"。
http
Access-Control-Max-Age: 86400 # 缓存 1 天不设的话,每个跨域请求都会预检一次,性能会很差。
5.6 Access-Control-Expose-Headers(前端要读自定义响应头时必备)
作用:默认前端只能读 6 个"安全响应头"(Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma)。要读其他响应头(如分页、自定义错误码),必须暴露。
http
Access-Control-Expose-Headers: X-Total-Count, X-Trace-Idjs
const res = await fetch('/api/users');
const total = res.headers.get('X-Total-Count');
// ↑ 如果后端没设 Expose-Headers,这行拿到的是 null5.7 一图记牢
┌────────────────────────── 服务端 CORS 响应头 ──────────────────────────┐
│ │
│ Access-Control-Allow-Origin: <源> ← 哪个源能访问(必备) │
│ Access-Control-Allow-Methods: GET,POST... ← 允许的方法(预检) │
│ Access-Control-Allow-Headers: X-Token... ← 允许的请求头(预检) │
│ Access-Control-Allow-Credentials: true ← 允许带 Cookie │
│ Access-Control-Max-Age: 86400 ← 预检结果缓存秒数 │
│ Access-Control-Expose-Headers: X-Total... ← 前端能读的响应头 │
│ │
└───────────────────────────────────────────────────────────────────────┘
┌────────────────────────── 浏览器自动带的请求头 ────────────────────────┐
│ │
│ Origin: http://a.com ← 当前页面源(必有) │
│ Access-Control-Request-Method: PUT ← 预检:将要用的方法 │
│ Access-Control-Request-Headers: X-Token ← 预检:将要用的头部 │
│ │
└───────────────────────────────────────────────────────────────────────┘6. 携带凭证(Cookie / Auth)的跨域
这是 CORS 最常踩坑的地方,单独拎出来讲。
6.1 默认情况:不带凭证
跨源 fetch 默认不发送 Cookie / HTTP Auth,即使你已经登录了 api.com。
js
fetch('https://api.com/me');
// 即使你在 api.com 已登录,这次请求也不会带 cookie6.2 想带 Cookie,前后端都要"签字"
前端 加 credentials: 'include':
js
fetch('https://api.com/me', {
credentials: 'include', // 关键
});
// axios:
axios.get('/me', { withCredentials: true });
// 老 XHR:
xhr.withCredentials = true;后端响应必须同时满足:
http
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.com # 不能用 *
Access-Control-Allow-Headers: Content-Type # 不能用 *
Access-Control-Allow-Methods: GET, POST # 不能用 *Cookie 自身还要满足:
http
Set-Cookie: token=xxx; SameSite=None; Secure; HttpOnly注意三件事:
SameSite=None才允许跨站发送(默认Lax不行)SameSite=None必须配合Secure,意味着必须 HTTPSHttpOnly防 XSS 偷 cookie,强烈建议加
6.3 一张图看懂"credentials 配错的坑"
前端 credentials: 'include' + 后端 Allow-Credentials: true ?
│ │
├── 都对 ───────► ✅ 带 cookie,能读响应
│
├── 前端没加 ───► ❌ 不带 cookie(请求没带身份证)
│
├── 后端没设 ───► ❌ 浏览器拒绝读响应(CORS 错误)
│
└── Allow-Origin: * + 带凭证 → ❌ CORS 报错
"不能在带凭证时用通配符"7. 服务端配置实战
CORS 是服务端的事,前端调不通的根因 99% 在后端没配对。下面给出常见后端的配置。
7.1 Node.js 原生 HTTP
js
const http = require('http');
http.createServer((req, res) => {
// ⭐ CORS 头
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
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') {
res.writeHead(204);
return res.end();
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ok: true }));
}).listen(3001);7.2 Express + cors 中间件(推荐)
bash
npm i corsjs
const express = require('express');
const cors = require('cors');
const app = express();
// 方式 1:粗暴允许所有
app.use(cors());
// 方式 2:精细控制
app.use(cors({
origin: ['http://localhost:5173', 'https://app.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'],
maxAge: 86400,
}));
// 方式 3:动态白名单
const whitelist = new Set(['http://localhost:5173', 'https://app.example.com']);
app.use(cors({
origin: (origin, cb) => {
if (!origin || whitelist.has(origin)) cb(null, true);
else cb(new Error('Not allowed by CORS'));
},
credentials: true,
}));
app.listen(3001);7.3 Spring Boot
java
@RestController
@CrossOrigin(origins = "http://localhost:5173", allowCredentials = "true")
public class UserController {
@GetMapping("/users")
public List<User> list() { ... }
}
// 或全局配置
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(86400);
}
}7.4 Nginx 反向代理 + CORS 头
nginx
server {
listen 80;
server_name api.example.com;
location / {
# 处理预检
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods 'GET,POST,PUT,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers 'Content-Type,Authorization' always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
# 真实请求
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
proxy_pass http://backend;
}
}💡 用
$http_origin动态回显请求源,比硬编码灵活;记得加always让错误响应也带头。
8. 前端配置实战
8.1 fetch
js
// 简单跨域 GET
const res = await fetch('https://api.example.com/users');
// 带 cookie 的跨域请求
const res = await fetch('https://api.example.com/me', {
credentials: 'include',
});
// JSON POST(会触发预检)
const res = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
body: JSON.stringify({ name: 'Alice' }),
credentials: 'include',
});
// 读取自定义响应头(要后端 Expose-Headers)
const total = res.headers.get('X-Total-Count');credentials 的三个值:
| 值 | 含义 | 何时用 |
|---|---|---|
'omit' | 不带 cookie | 公开 API(默认值) |
'same-origin' | 同源带,跨源不带 | fetch 默认值 |
'include' | 一律带 | 跨源也要带 cookie |
8.2 axios
js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true, // 全局带 cookie
timeout: 10000,
});
// 单次覆盖
api.get('/users', { withCredentials: false });8.3 XMLHttpRequest(很少手写了,写库时见)
js
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://api.example.com/users');
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ name: 'Alice' }));9. 开发期最常用:本地代理
开发期 localhost:5173 → localhost:8080 跨域,最优雅的方法是让前端不知道自己跨域。
9.1 Vite 代理
js
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
};前端写:
js
// 看起来是同源请求 ↓
fetch('/api/users');
// 实际由 Vite dev server 转发到 http://localhost:8080/users
// 浏览器一直认为是同源,零 CORS 问题9.2 Webpack devServer 代理
js
// webpack.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
},
};9.3 Next.js rewrites
js
// next.config.js
module.exports = {
async rewrites() {
return [
{ source: '/api/:path*', destination: 'http://localhost:8080/:path*' },
];
},
};9.4 为什么代理能绕过 CORS?
[ 浏览器 ]
│ fetch('/api/users') ← 请求自家域名(http://localhost:5173)
▼
[ Vite Dev Server ] ← Node 服务器,**没有同源策略**!
│ 代为请求 http://localhost:8080/users
▼
[ 后端 ]关键认知:同源策略只存在于浏览器,Node / 浏览器扩展 / curl / Postman 一概不受限。代理就是把"跨域"这件事偷偷搬到了"非浏览器环境"里完成。
⚠️ 生产环境要用 Nginx 做同样的事,别指望生产环境前端 dev server 帮你代理。
10. 其他跨域方案大盘点
| 方案 | 原理 | 适用场景 | 优缺点 |
|---|---|---|---|
| CORS | 服务端响应头 | 主流 REST API | 通用、安全、要后端配合 |
| JSONP | <script> 不受同源限制 | 老 IE / 老接口 | 仅 GET、不安全(恶意 JS)、已淘汰 |
| 代理(dev) | 前端 dev server 转发 | 本地开发 | 仅开发期,零 CORS 配置 |
| Nginx 反向代理 | 网关同域转发 | 生产部署 | 最常用、零浏览器限制 |
| postMessage | window 间消息通信 | 跨源 iframe / 新窗口 | 双方主动通信、需要约定协议 |
| WebSocket | 升级后无同源策略 | 实时通信 | 不走 HTTP 后无 CORS,但握手要看 Origin |
| CORS Anywhere(公开代理) | 第三方代理服务 | 临时调试 | 不安全、有限流、绝不能用于生产 |
| document.domain | 同主域不同子域 | 老项目 | 已废弃,仅 a.com 与 b.a.com |
| window.name | 利用 name 属性跨页传值 | 极特殊场景 | 已淘汰 |
| iframe + 主域 | 类似 document.domain | 老项目 | 已淘汰 |
10.1 JSONP 怎么实现(理解原理用,别再用)
html
<!-- 客户端 -->
<script>
function handleData(data) {
console.log('收到数据:', data);
}
</script>
<script src="https://api.example.com/users?callback=handleData"></script>服务端返回的不是 JSON,而是一段 JS:
js
// https://api.example.com/users?callback=handleData 实际返回:
handleData({ id: 1, name: 'Alice' });利用 <script> 标签不受同源限制的特性,让服务端"包装"数据为函数调用。只能 GET,且如果服务端返回恶意脚本你就完了。
10.2 postMessage 跨源 iframe 通信
js
// 父页面 a.com 发消息
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage(
{ type: 'login', token: 'xxx' },
'https://b.com' // ⭐ 目标源校验
);
// iframe(b.com)收消息
window.addEventListener('message', (e) => {
if (e.origin !== 'https://a.com') return; // ⭐ 必校验来源
console.log('收到:', e.data);
e.source.postMessage({ ack: true }, e.origin);
});⚠️ 安全要点:双向校验 origin,目标 origin 不能写
'*'(除非你真的不在乎被任何源接收)。
11. ⚠️ 易踩的坑(Top 15)
- 预检失败但报错信息说不清 —— OPTIONS 状态非 2xx 都算失败,先用 Network 看 OPTIONS 状态码。
- 后端代码加了 CORS 头但 Nginx 又加一次 —— 浏览器看到两个 Allow-Origin 直接拒绝。
Allow-Origin: *+credentials: include—— 永远报错,记住"带 cookie 不能用 *"。Allow-Origin写多个源 —— 不允许,只能动态返回单个或*。- 以为后端没收到请求 —— 实际请求已经到了!只是浏览器拦了响应。删除/修改类操作要做幂等。
- 预检请求 OPTIONS 没经过权限校验 —— 后端 OPTIONS 应该跳过鉴权,只返回 CORS 头。
- Cookie 的
SameSite=Lax—— 默认 Lax 跨站不发 cookie,要 None+Secure。 - 本地用
localhostvs127.0.0.1—— 视为不同源,调试时容易混。 - Origin 大小写敏感 —— 后端校验白名单时记得小写化对比。
- CDN 缓存了 CORS 响应 —— 必须设
Vary: Origin,否则不同源命中同一份缓存出错。 - WebSocket 也校验 Origin —— 服务端要看
Origin头,否则任何站都能连。 - iframe 嵌入跨域页面读不到 DOM —— 即使你能加载,也读不到 contentDocument。
- 重定向后丢失 CORS 头 —— 跨域请求被 302 后,新位置必须也带 CORS 头。
- 图片 CORS 与 canvas tainted —— 跨域图加载没问题,但画到 canvas 后
toDataURL会抛错;需要<img crossorigin="anonymous">+ 后端配合。 - fetch 不会自动 reject CORS 错误吗? —— 会 reject,且错误信息很简陋(出于安全),细节只能在 DevTools Console 看。
12. 安全相关:CORS 不等于安全
很多人误以为"开了 CORS 后端就安全了",完全错误:
| 误区 | 真相 |
|---|---|
| CORS 能防 CSRF | ❌ 不能!CSRF 利用的是 <form>/<img> 等不受 CORS 限制的方式 |
| CORS 能防爬虫 | ❌ 不能!爬虫用 Node/Python 直接请求,根本没浏览器 |
| CORS 是"权限控制" | ❌ 不是!它只控制"谁能用浏览器读响应",不影响"谁能调用接口" |
| Allow-Origin: * 危险 | ⚠️ 只对带凭证的接口危险;对公开 API 没问题 |
正确做法:
- 鉴权用 Token / Cookie
- 防 CSRF 用 SameSite Cookie + CSRF Token
- 防爬虫用 限流 + 反机器人
- CORS 只是给浏览器划线,让 JS 能或不能读响应
13. 完整请求生命周期图
14. 一句话总结
CORS 是浏览器对跨域 fetch / XHR 的安全护栏,靠服务端在响应头里写
Access-Control-Allow-*系列字段来"放行"。简单请求直接发,复杂请求先 OPTIONS 预检;带 Cookie 时前后端都要签字且Allow-Origin不能写*。开发期最舒服的解法是 Vite/Webpack 代理,生产环境用 Nginx 反向代理。
15. 延伸阅读
- MDN:CORS 跨源资源共享
- MDN:同源策略
- 阮一峰:跨域资源共享 CORS 详解
- W3C:CORS Spec
- WHATWG:Fetch Standard - CORS Protocol
- Chrome DevTools:Network 面板查看 CORS
📂 本目录其他资源
qa.md- 面试高频题集(含考察点、加分回答、追问)examples/- 7 个可独立运行的代码示例(Node 服务端 + 各种前端代码)demo/index.html- 双击即可打开的 CORS 流程交互演示SETUP.md- 如何在本地把示例跑起来