
9 次跨域 fetch,浏览器一共发出了 13 个 HTTP 请求。其中 5 次本可以直接发出去,另外 4 次被预检拦了一道。下面两张表全部来自本机实测:页面在 127.0.0.1:8801,接口在 127.0.0.1:8802,浏览器是真实 Chrome,服务端把每个到达的请求的报文特征逐条记下来;驱动走的是 CDP,脚本在 tools/verify-cors-preflight.mjs 和 tools/cdp-run.mjs。
浏览器这一侧的结果
场景 结果
s1 GET 普通请求 ok 200
s2 POST + Content-Type: text/plain ok 200
s3 POST + Content-Type: application/json ok 200
s4 PUT(第一次) ok 200
s5 PUT(同一 URL 再来一次) ok 200
s6 GET + 自定义头 X-Token ok 200
s7 POST JSON + credentials: include ok 200
s8 POST JSON + credentials,接口只回 ACAO:* 失败 TypeError: Failed to fetch
s9 GET,接口不回任何 CORS 头 失败 TypeError: Failed to fetch
服务端这一侧收到的报文
每一行是接口进程真实写下来的记录,isPreflight 由服务端按方法判断:
{"method":"GET","path":"/s1","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"no","ct":"-"}
{"method":"POST","path":"/s2","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"no","ct":"text/plain"}
{"method":"OPTIONS","path":"/s3","origin":"http://127.0.0.1:8801","acrm":"POST","acrh":"content-type","cookie":"no","ct":"-"}
{"method":"POST","path":"/s3","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"no","ct":"application/json"}
{"method":"OPTIONS","path":"/s4","origin":"http://127.0.0.1:8801","acrm":"PUT","acrh":"content-type","cookie":"no","ct":"-"}
{"method":"PUT","path":"/s4","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"no","ct":"application/json"}
{"method":"PUT","path":"/s4","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"no","ct":"application/json"}
{"method":"OPTIONS","path":"/s5","origin":"http://127.0.0.1:8801","acrm":"GET","acrh":"x-token","cookie":"no","ct":"-"}
{"method":"GET","path":"/s5","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"no","ct":"-"}
{"method":"OPTIONS","path":"/s7","origin":"http://127.0.0.1:8801","acrm":"POST","acrh":"content-type","cookie":"no","ct":"-"}
{"method":"POST","path":"/s7","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"yes","ct":"application/json"}
{"method":"OPTIONS","path":"/s8","origin":"http://127.0.0.1:8801","acrm":"POST","acrh":"content-type","cookie":"no","ct":"-"}
{"method":"GET","path":"/s9","origin":"http://127.0.0.1:8801","acrm":"-","acrh":"-","cookie":"no","ct":"-"}
acrm 是 Access-Control-Request-Method,acrh 是 Access-Control-Request-Headers,cookie 只记有没有带上,ct 是 Content-Type。
逐条对读
s1 和 s2 是简单请求,服务端只看到一个请求,没有预检。s2 用的 text/plain 属于预检豁免的三种 Content-Type 之一,另外两种是 application/x-www-form-urlencoded 和 multipart/form-data,这两种这次没跑,来自规范里的定义。这里埋着一个常见的错配:为了省一次预检把 Content-Type 改成 text/plain,服务端却按 JSON 解析,请求能到、体也能读,但后端框架经常在没有 application/json 的时候拒绝解析。
s3 开始出现成对的请求。预检的形态在所有场景里都一样:方法是 OPTIONS,路径和真实请求相同,带上 Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers 三个头,没有请求体。acrh 里的头名是小写的 content-type,和 fetch 里写的 Content-Type 大小写不同,这是浏览器在构造 CORS 预检头时统一转小写的结果,与协议版本无关,服务端比对允许头清单时不该做大小写敏感匹配。
s5 这一对更值得看:预检里的 acrh 是 x-token,来自页面里加的一个自定义头。自定义头必然触发预检,改名字救不了,能省下来的办法是把 token 换成不使用自定义头的传递方式。
s4 和紧随其后的一次 PUT 是同一个 URL。第二次没有出现新的 OPTIONS,只有第二个 PUT。第一次的预检响应里带了 Access-Control-Max-Age: 600,浏览器把这条结果记了 10 分钟,同一个 URL 再发就不问了。预检缓存的有效期上限各浏览器不同,这里只验证了本机这次行为。
s7 是凭据那一组,服务端记录得很干净:预检那条 cookie: "no",真实请求那条 cookie: "yes"。页面在 8801 上写了一个 127.0.0.1 的 Cookie,同源判的是主机,端口不参与,所以它会被带进跨端口的请求。预检不带凭据是设计如此,于是有个容易踩的坑:如果服务端把鉴权做在预检响应上,预检永远拿不到会话,会一直回 401 或 403,真实请求根本发不出去。
s8 的结果和很多人预期相反。接口对预检回了 Access-Control-Allow-Origin: *,真实请求一条都没到,fetch 直接抛 TypeError。原因是这次请求带了凭据,而凭据模式下通配符不成立,浏览器在预检阶段就判定放行条件不满足,往下的请求不必发。日志里只有一条 OPTIONS,没有 POST,这一点比错误信息本身更好用。
s9 走的是另一条失败路径。GET 是简单请求,不预检,请求正常到达并被服务端处理,服务端也正常返回了,但响应里没有任何 CORS 头,浏览器把响应体丢掉,fetch 抛 TypeError。前端看到的错和 s8 一模一样,服务端看到的却是两种完全不同的东西:s8 少了一次真实请求,s9 一次都没少。
排障顺序
按这两张表,跨域报错的排查顺序基本是固定的。先看接口日志里有没有收到 OPTIONS,没有说明请求停在浏览器这一侧,问题在 URL 或者混合内容之类的地方。收到了 OPTIONS 但没有后续的真实请求,去核对预检响应:Origin 是否精确匹配、Allow-Methods 有没有包含真实方法、Allow-Headers 有没有覆盖 acrh 里列出的每个头。三项都在,再看是否用了通配符而真实请求带了凭据。真实请求到了、响应也回了,前端仍报失败,那就是响应头缺了 Allow-Origin,或者 Allow-Credentials 没跟着一起给。
写这套东西时有两个取舍。接口这侧我倾向于把所有允许来源写成配置项逐个列举,别用通配符,因为一旦有 Cookie 就必须列举,早一点统一比事后改要省事。页面这侧能省一次预检就省,但只省在明确知道没有副作用的请求上,为了省预检去改 Content-Type 属于把问题从网络层挪到解析层。
这些结论出自同机同版本的 Chrome,跨域缓存的时长和 Cookie 的策略在不同浏览器上会有差别,换浏览器没实测。真正需要确认的是接口自己那一步:预检响应答得对不对,只有服务端日志能回答。