专栏第十四篇,网络与协议篇第七章。上一篇我们聊了 Cookie 和 Session,文末预告了今天的主角。做前后端分离开发时,你大概率见过这样的画面:前端跑在
localhost:3000,后端跑在localhost:8080,接口明明写对了,浏览器却一片红------No 'Access-Control-Allow-Origin' header is present。这就是每个前端工程师都绕不开的"拦路虎"------CORS(跨域资源共享)。今天我们就把它彻底讲明白:跨域到底是怎么回事?预检请求为什么存在?带 Cookie 时又有哪些坑?
一、先看症状:那片熟悉的红色报错
只要你做过前后端分离开发,一定见过它:
text
Access to XMLHttpRequest at 'http://api.example.com' from origin 'http://localhost:3000'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
后端说:"我接口明明返回 200 了,数据也查出来了,你前端是不是写错了?" 前端说:"Network 里明明能看到响应,可 JS 就是拿不到数据!"
问题不在后端,也不完全在前端------问题出在浏览器这个"中间人"身上 。响应其实已经回来了,但浏览器看了一眼响应头,发现"没有通行证",于是把数据扣押了,不让 JS 读取。
这就是跨域。
二、什么是"域"(Origin)?
要理解跨域,先得知道什么叫"同源"。
域(Origin)= 协议 + 域名 + 端口。三者必须完全一致,才叫同源。只要有一个不一样,就是跨域。
| URL A | URL B | 是否同源 | 原因 |
|---|---|---|---|
http://localhost:3000 |
http://localhost:4000 |
❌ 跨域 | 端口不同 |
http://127.0.0.1:3000 |
http://localhost:3000 |
❌ 跨域 | 域名不同 |
https://example.com |
http://example.com |
❌ 跨域 | 协议不同 |
https://api.example.com |
https://www.example.com |
❌ 跨域 | 二级域名不同 |
https://www.example.com/a |
https://www.example.com/b |
✅ 同源 | 路径不影响 |
注意最后一行:路径不一样不算跨域 。很多初学者会以为 /a 和 /b 是不同源,其实它们同源。跨域比的是"站",不是"页面"。
三、浏览器为什么要拦你?(同源策略 SOP)
很多人第一次遇到跨域时,都会在心里骂一句:"浏览器是不是有病?我自己调我自己的接口都不行?"
但这真不是浏览器多管闲事,它是在保护你 。这背后是浏览器的安全基石------同源策略(Same-Origin Policy, SOP)。
假设没有跨域限制......
- 你登录了
bank.com,Cookie 里存了你的登录态。 - 你又打开了一个恶意网站
evil.com。 evil.com页面里的 JS 偷偷向bank.com/api/transfer发了一个转账请求。- 浏览器会自动带上
bank.com的 Cookie(上一篇讲过,Cookie 是自动携带的)。 - 服务器一看 Cookie 是合法的,转账成功。
这就是著名的 CSRF(跨站请求伪造) 攻击。
为了防止这种情况,浏览器规定:脚本只能自由访问同源的资源。不同源的请求,要经过一套授权机制------这套机制就是 CORS。
💡 同源策略限制的是脚本读取响应,它并不阻止请求发出去。这也是为什么你在 Network 面板里能看到请求和响应,但 JS 拿不到。理解这一点非常关键。
四、什么是 CORS?(官方解法)
CORS = Cross-Origin Resource Sharing(跨域资源共享) ,是 W3C 的标准,也是解决跨域最正统、最官方的方案。
它的核心思想就一句话:
浏览器继续拦,但让服务器来决定"是否放行"。
服务器通过在 HTTP 响应头里加上几个特定字段,告诉浏览器:"这个请求我允许来自 http://localhost:3000 的访问,你别拦。"浏览器看到通行证,就把扣押的数据交给 JS。
CORS 不是要你绕过浏览器的安全机制,而是让服务器主动、显式地授权。
五、CORS 是怎么工作的?(两种请求)
CORS 不是简单加个 Header 就完事。浏览器根据请求的"危险程度",把它分成两类,处理方式完全不同。
1. 简单请求(Simple Request)
同时满足以下条件,才叫简单请求:
- 方法只能是
GET、POST、HEAD; - 只能使用这几个安全头:
Accept、Accept-Language、Content-Language、Content-Type; Content-Type的值只能是application/x-www-form-urlencoded、multipart/form-data、text/plain。
也就是说,我们常用的 Content-Type: application/json 的 POST 请求,不属于简单请求。
流程:
text
浏览器 服务器
| Origin: http://localhost:3000 |
|---------------------------------------->| (直接发请求)
| |
| Access-Control-Allow-Origin: * |
|<----------------------------------------| (服务器回应是否允许)
| |
浏览器在请求头里带上 Origin 字段,表明"我从哪来";服务器在响应头里返回 Access-Control-Allow-Origin,表明"我允许谁来"。两边对得上,就放行。
2. 预检请求(Preflight Request)
大部分 AJAX 请求都属于这一类 ,比如 Content-Type: application/json、自定义请求头(Authorization)、PUT/DELETE 方法等。
浏览器不放心,会在真正发请求之前,先问一嘴------这就是"预检"。
完整流程分三步:
第一步:浏览器先发一个 OPTIONS 请求。
http
OPTIONS /api/data HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type
浏览器在问:"我打算用 POST 方法、带 Content-Type 头发请求,你允许吗?"
第二步:服务器回应。
http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
服务器说:"可以,这几个方法和头我都接受。24 小时内(86400 秒)别再来问了。"
第三步:预检通过,浏览器才发送真正的业务请求。
http
POST /api/data HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Content-Type: application/json
{"name": "张三"}
💡 为什么要多此一举搞预检? 因为有些老旧的服务器根本不认识 CORS,也不支持
PUT/DELETE。如果浏览器直接发一个 DELETE 过去,可能会在服务器上造成真实的副作用(删数据)。预检机制保证了:在确认服务器"懂 CORS 且愿意接受"之前,不发送任何可能产生副作用的真实请求。这是一种对老系统的保护。
六、CORS 的核心响应头(重点)
这些响应头是服务器必须配置的,也是面试常考的点:
| 响应头 | 作用 |
|---|---|
Access-Control-Allow-Origin |
必填 。允许的源,值为 *(允许任意)或具体域名(如 http://localhost:3000)。 |
Access-Control-Allow-Methods |
预检响应。允许的 HTTP 方法,如 GET, POST, PUT, DELETE。 |
Access-Control-Allow-Headers |
预检响应。允许的请求头,如 Content-Type, Authorization。 |
Access-Control-Allow-Credentials |
重要 。设为 true 才允许跨域请求携带 Cookie。 |
Access-Control-Max-Age |
预检结果缓存时间(秒),期间不再重复发 OPTIONS。 |
Access-Control-Expose-Headers |
允许 JS 读取的响应头白名单。默认只能读 Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma 这几个。 |
Expose-Headers 这个头容易被忽略。如果你在响应里放了一个自定义头(比如 X-Request-Id),前端在 response.headers 里是读不到的,除非服务器把它列进 Access-Control-Expose-Headers。
七、CORS 与 Cookie:一个经典大坑
上一篇我们讲了 Cookie/Session 登录。跨域时带 Cookie,有一个非常容易踩的坑。
前端要做的
默认情况下,跨域请求不会携带 Cookie。你需要显式打开:
javascript
// axios
axios.defaults.withCredentials = true;
// fetch
fetch(url, { credentials: 'include' });
后端必须做的
这才是关键,两个响应头缺一不可:
http
Access-Control-Allow-Origin: http://localhost:3000 // ❗不能是 *
Access-Control-Allow-Credentials: true
注意:当 Allow-Credentials 为 true 时,Allow-Origin 绝对不能是 *,必须是明确的域名。否则浏览器会直接拒绝。
为什么不能用通配符?
这是浏览器的安全规定。如果 Allow-Origin: * 同时又允许带凭证,浏览器无法判断该把哪个域的 Cookie 发过去,等于把所有网站都放进了信任名单------这就违背了同源策略的初衷。所以浏览器要求:要带 Cookie,就必须精确指定来源。
💡 同理,
Access-Control-Allow-Headers和Access-Control-Allow-Methods在带凭证时也不能用*(虽然这一条各浏览器实现略有差异,但最好都显式列出)。
八、CORS 的常见应用场景
1. 前后端分离开发(最常见)
- 场景 :前端
localhost:3000(React/Vue),后端localhost:8080(Spring Boot/Express)。 - 做法 :后端配置
Access-Control-Allow-Origin: http://localhost:3000。
2. 调用第三方 API
- 场景:前端直接调用高德地图、天气等公开 API。
- 做法:第三方服务器必须开启 CORS,否则你只能让自己的后端做代理转发。
3. 微服务网关
- 场景 :
www.example.com调用api-user.example.com、api-order.example.com。 - 做法:在网关层(Nginx/Kong/Spring Cloud Gateway)统一配置 CORS,避免每个服务各写一遍。
4. WebSocket 的"跨域"
WebSocket 不受同源策略限制 ,浏览器允许从任意页面发起 WS 连接。但 WS 有自己的握手校验机制,而且如果握手时依赖 Cookie/Session,浏览器仍会遵循 Cookie 的 SameSite 规则。
九、解决跨域的其他方案(对比)
除了 CORS,开发中还有几种常见手段:
| 方案 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| CORS | 服务器返回授权头 | 生产环境首选 | 需要服务器配置支持 |
| Nginx 反向代理 | 把跨域请求伪装成同源 | 生产环境、运维统一配置 | 需要服务器权限 |
| Node 代理(Vite/Webpack) | 开发服务器中间件转发 | 本地开发首选 | 只在开发环境生效 |
| JSONP | 利用 <script> 标签不受同源限制 |
老式浏览器、只支持 GET | 仅 GET、有安全隐患、已淘汰 |
开发环境最佳实践:代理
本地开发时,最省事的做法不是让后端配 CORS,而是用前端构建工具的代理:
javascript
// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
});
这样前端访问 /api/users,实际上是 Vite 开发服务器帮你转发到 http://localhost:8080/api/users。浏览器看到的是同源请求,自然不拦截。请求是在服务器端转发的,不经过浏览器的同源策略。
上线之后怎么办?两种选择:
- 后端直接配 CORS,灵活可控;
- 用 Nginx 把前端静态资源和 API 代理到同一个域名下,从根本上消除跨域。
十、串联:一个完整的跨域登录流程
把前两篇的内容串起来,看一个真实的登录流程:
- 跨域请求 :浏览器(
localhost:3000)向 API(localhost:8080)发登录请求,Content-Type: application/json。 - 预检介入 :浏览器发现是非简单请求,先发一个
OPTIONS预检;服务器返回允许的方法和头。 - 正式请求:预检通过,浏览器发送真正的 POST 请求,带上用户名密码。
- Set-Cookie :服务器校验密码,创建 Session,通过
Set-Cookie: sessionId=xxx; HttpOnly; SameSite=Lax下发。 - Cookie 入库 :浏览器收到 Cookie,校验
Domain、SameSite等属性后存储。 - 后续请求 :前端再次发起跨域请求,因为配置了
withCredentials: true,浏览器自动带上 Cookie。 - CORS 校验 :服务器返回
Access-Control-Allow-Origin(具体域名)和Access-Control-Allow-Credentials: true,浏览器放行。 - 鉴权通过 :服务器通过 Cookie 里的
sessionId查到 Session,确认用户身份,返回数据。
看,CORS 和 Cookie/Session 就是这样配合工作的。
十一、总结
- 同源策略:浏览器的安全锁,协议+域名+端口三者一致才叫同源,主要用来防 CSRF。
- CORS:服务器返回的一张"通行证",显式授权哪些跨域请求可以被接受。
- 简单请求 vs 预检请求:简单请求直接发;非简单请求先发 OPTIONS 预检,再发真实请求。
- 带 Cookie 的坑 :前端开
withCredentials,后端Allow-Credentials: true且Allow-Origin不能用*。 - 实践经验:本地开发用 Vite/Webpack 代理,生产用 CORS 或 Nginx 反向代理;JSONP 已经是历史。
写在最后
CORS 本质上不是什么高深的技术,它更像是浏览器和服务器之间的一套"礼貌用语"------浏览器问一句"可以吗",服务器答一句"可以"。但很多人一直停留在"复制一段配置解决报错"的阶段,没真正理解预检、凭证、响应头之间的关系。
理解了 CORS,你就能想明白:
- 为什么 POST 一个 JSON 会发两个请求(OPTIONS + POST);
- 为什么后端说"我 Postman 调得通",前端却调不通;
- 为什么带 Cookie 时把
Allow-Origin设成*反而报错; - 为什么 Vite 的
proxy配一下,跨域就"消失"了。
下一篇,我们来聊一个你每天上网都在默默使用、却很少深究的系统:什么是 DNS?为什么在浏览器里输入一个域名就能找到服务器?域名解析的完整过程是怎样的?A 记录、CNAME、NS 记录又分别是什么? 敬请期待。
如果这个系列对你有帮助,欢迎点赞、关注、收藏三连,我们下篇见 👋