前端开发几乎人人都遇到过这种报错: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,那么:

为什么要有这条规则?想象你登录了网银,又打开了恶意网站。没有同源策略,恶意网站的脚本就能随意读取网银页面的数据、替你转账。同源策略把页面关进"沙箱",恶意脚本只能在自己的源里折腾。需要说明的是,同源策略限制的主要是"读取响应"——<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)。练习建议:

💡 调试 CORS 有个固定顺序:先用 curl 确认服务器响应头对不对,再看浏览器报错。curl 能看到完整响应,能帮你快速区分"服务器没配"和"浏览器没放行"。