前端开发几乎人人都遇到过这种报错:Access to fetch at 'http://api.example.com' from origin 'http://localhost:3000' has been blocked by CORS policy。明明是合法的请求,为什么浏览器要拦?本文讲透 CORS 的前因后果,并用手写服务器演示如何正确放行跨域请求,以后再看到这个报错,你能一眼判断问题出在服务器还是浏览器。
1. 同源策略:浏览器的安全底线
要理解 CORS,先理解同源策略。浏览器规定:一个页面只能自由读写"同源"的数据。所谓同源,是协议 + 域名 + 端口三者完全一致。比如页面在 https://a.com:443,那么:
https://a.com/api—— 同源,自由访问;http://a.com/api—— 不同源(协议不同);https://b.com/api—— 不同源(域名不同);https://a.com:8080/api—— 不同源(端口不同)。
为什么要有这条规则?想象你登录了网银,又打开了恶意网站。没有同源策略,恶意网站的脚本就能随意读取网银页面的数据、替你转账。同源策略把页面关进"沙箱",恶意脚本只能在自己的源里折腾。需要说明的是,同源策略限制的主要是"读取响应"——<img>、<script>、表单这些标签发起的跨域请求其实一直能发出去,只是页面里的脚本读不到跨域响应里的数据,而 fetch 和 XHR 这类"可读"的请求才会被严格把关。
2. 跨域请求是怎么被拦的
注意一个关键事实:请求其实发出去了,服务器也处理了,被拦的是"响应"。浏览器发出跨域请求后,会检查响应头里有没有 Access-Control-Allow-Origin(简称 ACAO)。没有,或者值不匹配,浏览器就把响应"吞掉",然后抛给你上面的 CORS 报错。
也就是说:CORS 是服务器通过响应头给浏览器开的"通行证"。服务器愿意放行谁,就在响应头里写谁。用 curl 模拟一下——curl 不执行同源策略,所以能看到真实响应:
# 假设 127.0.0.1:8000 上跑着一个 API
curl -i http://127.0.0.1:8000/api
如果响应头里没有 Access-Control-Allow-Origin,浏览器就会拦截;但 curl 不在乎,照样打印全部内容。这个对比很直观:CORS 是"浏览器端的检查",不是服务器拒绝了你。
生产环境还有个细节:Access-Control-Allow-Origin 一个响应里只能有一个值,要么写具体的源,要么写 *。如果你的 API 要同时服务多个前端域名,服务器得根据请求头里的 Origin 动态回显——这也是"回显"模式在实战中更常见的原因。
顺便学会读报错:浏览器抛出的 CORS 错误信息里,通常会直接告诉你缺了什么——比如 "No 'Access-Control-Allow-Origin' header is present" 说明服务器压根没配,而 "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*'" 则提示你带了凭据却用了通配符。把报错信息当成服务器响应头的"体检报告",排查会快很多。
3. 简单请求与预检请求
CORS 把请求分成两类。简单请求需要同时满足三个条件:方法限于 GET/POST/HEAD;请求头只允许 Accept、Content-Type 等少数几个"安全"头;而且 Content-Type 只能是 application/x-www-form-urlencoded、multipart/form-data、text/plain 这三种之一。简单请求浏览器直接发,靠响应头决定放不放行。
预检请求(preflight)则是"正式请求前的探路":浏览器先发一个 OPTIONS 请求,问服务器"我打算这么请求,你允许吗?"服务器回答允许后,浏览器才发真正的请求。什么时候会触发预检?常见的有:用了 PUT/DELETE 方法、带了自定义请求头(如 Authorization、X-Token)、Content-Type 不是上面那三种简单值。很多人第一次写 fetch(url, {method: "POST", headers: {"Content-Type": "application/json"}}) 就撞上 CORS,正是因为 application/json 不属于简单值,触发了预检。用 JavaScript 发起一个跨域请求看看:
// 在 localhost:3000 的页面上请求 8000 端口的接口
fetch("http://127.0.0.1:8000/api")
.then(r => r.json())
.then(data => console.log(data))
.catch(err => console.error("被拦截:", err.message));
报错信息里一定会出现 "CORS policy" 字样,这是它和普通网络错误的区别。另外注意:fetch 默认不带 Cookie,跨域请求要带登录态,得显式设置 credentials,这一点第 5 节会细讲。
4. 实战:手写一个支持 CORS 的 API
下面这个 Python 服务器,先演示"什么都不配"的样子——任何跨域请求都会被拦。我们用 http.client 模拟浏览器,带上 Origin 头请求:
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import http.client
class Api(BaseHTTPRequestHandler):
def do_GET(self):
# 注意:这里故意不加任何 CORS 头
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"message": "hello"}')
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", 0), Api)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
conn.request("GET", "/api", headers={"Origin": "http://localhost:3000"})
r = conn.getresponse()
print("状态码:", r.status)
print("Access-Control-Allow-Origin:", r.getheader("Access-Control-Allow-Origin"))
conn.close()
server.shutdown()
输出里 Access-Control-Allow-Origin: None——服务器正常返回了 200,但响应头里没有 ACAO,浏览器就会把响应拦下来。curl 能看到全部内容,是因为它不做这个检查。
接下来加上 CORS 头,并正确回应预检请求:
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import http.client
class Api(BaseHTTPRequestHandler):
def _cors(self):
# 回显 Origin,而不是写死 *,方便后续支持带凭据的请求
origin = self.headers.get("Origin", "*")
self.send_header("Access-Control-Allow-Origin", origin)
def do_OPTIONS(self):
# 预检请求:回答"允许哪些方法、哪些请求头"
self.send_response(204)
self._cors()
self.send_header("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers",
"Content-Type, Authorization")
self.end_headers()
def do_GET(self):
self.send_response(200)
self._cors()
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"message": "hello"}')
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", 0), Api)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
def probe(method, path, headers):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
conn.request(method, path, headers=headers)
r = conn.getresponse()
print(method, path, "->", r.status,
"| ACAO:", r.getheader("Access-Control-Allow-Origin"),
"| Allow-Headers:", r.getheader("Access-Control-Allow-Headers"))
conn.close()
probe("GET", "/api", {"Origin": "http://localhost:3000"})
probe("OPTIONS", "/api", {"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "GET"})
server.shutdown()
输出里,GET 请求拿到了 ACAO: http://localhost:3000,OPTIONS 预检请求得到 204,并带上允许的方法和请求头列表。两个关键点:第一,do_OPTIONS 必须实现,否则预检请求会得到 501,浏览器直接判失败;第二,Access-Control-Allow-Headers 要覆盖你实际用到的请求头,漏了 Authorization,带 token 的请求照样被拦。如果想减少预检次数,还可以加 Access-Control-Max-Age 头,让浏览器把预检结果缓存一段时间。
用 curl 带 Origin 头验证一下(带上 Origin 头相当于模拟浏览器的跨域请求):
curl -i -H "Origin: http://localhost:3000" http://127.0.0.1:8000/api
# 响应头里应该能看到:
# Access-Control-Allow-Origin: http://localhost:3000
5. 带凭据的请求与通配符的坑
如果跨域请求要带 Cookie(比如登录态),浏览器有更严格的要求:Access-Control-Allow-Origin 不能是 *,必须回显具体 Origin;响应头还要加 Access-Control-Allow-Credentials: true;前端 fetch 也必须显式声明 credentials: "include"。三个条件缺一不可,漏一个请求就会被拦。
这个设计不是刁难人:如果允许 * 加凭据,任何网站都能借你的 Cookie 调接口,同源策略就形同虚设了。这也是上面代码选择"回显 Origin"而不是写死 * 的原因——写死通配符,等于提前堵死了带凭据这条路。实际开发里,如果你的接口只需要给自家前端用,优先考虑反向代理而不是 CORS:少配置、少出错,还能顺便隐藏后端地址。
6. 其他跨域方案
CORS 不是唯一解,实际项目里常见这几条路:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| CORS | 服务器声明放行 | 前后端分离、对外开放 API |
| 反向代理 | 浏览器只访问同源地址,代理转发到后端 | 开发环境(Vite/webpack proxy)、同域部署 |
| JSONP | 用 script 标签绕开限制,只支持 GET | 历史遗留方案,新项目不推荐 |
反向代理的思路最干净:让浏览器以为只有一个源,跨域问题直接消失。前端开发时,localhost:3000 的页面请求 /api,开发服务器(如 Vite)把请求转发到 localhost:8000 的后端,浏览器全程只看到同源的 localhost:3000。生产环境用 Nginx 做同样的事:
# 浏览器访问 /api 时,代理转发到后端服务
location /api/ {
proxy_pass http://127.0.0.1:8000;
add_header Access-Control-Allow-Origin *;
}
7. 总结与练习
CORS 的真相一句话:CORS 不是服务器拒绝你,是浏览器检查后拒绝了你;解决方案是让服务器在响应头里声明"我允许这个源访问"。记住三类知识:同源判定(协议 + 域名 + 端口)、预检触发条件(非简单请求)、放行三件套(ACAO、Allow-Methods、Allow-Headers)。练习建议:
- 把第 4 节的服务器跑起来,分别请求 GET 和带
Content-Type: application/json的 POST,观察哪次触发预检,哪次没有; - 给服务器加一个需要
Authorization头的接口,先不配Allow-Headers,看浏览器报什么错,再补上对比; - 打开任意网站的 DevTools → Network,筛选
OPTIONS请求,找一个真实世界的 preflight,看看它的响应头都写了什么; - 用 Vite 的
server.proxy配置体验一次反向代理方案,感受"同源化"的便利。
💡 调试 CORS 有个固定顺序:先用 curl 确认服务器响应头对不对,再看浏览器报错。curl 能看到完整响应,能帮你快速区分"服务器没配"和"浏览器没放行"。