
前端跨域调用一个 JSON 接口,服务端日志里却先出现 OPTIONS,真正的 POST 还没来。把 OPTIONS 当成业务接口漏配,往往会绕远路。浏览器正在询问目标服务器是否允许这次跨域请求,回答符合条件后才发送正式请求。
只改 Content-Type 就能看到分界
跨源请求能否直接发出,受 CORS 的 safelist 限制。常用的安全类型包括 text/plain、application/x-www-form-urlencoded 和 multipart/form-data。对 POST 而言,浏览器检查 Content-Type 的媒体类型是否属于允许范围;若改成 application/json,它不在 safelist 里,便会先发预检。
可以用同一段浏览器代码比较三种值:
for (const type of [
'text/plain',
'application/x-www-form-urlencoded',
'application/json'
]) {
await fetch('https://api.example.test/echo', {
method: 'POST',
headers: { 'Content-Type': type },
body: 'x=1'
});
}
前两项的媒体类型受 safelist 允许,第三项会触发浏览器检查。示例只比较请求头,实际接口还得满足 CORS 的其他约束。比如自定义请求头也可能触发预检,不能因为内容类型在列表里就认定请求一定不预检。脚本还把 X-Client-Trace 加到另一条本地 POST 上。即使仍然使用 text/plain,浏览器也先向 /custom 发 OPTIONS,并在 Access-Control-Request-Headers 中列出 x-client-trace;服务端放行该头后,POST 才到达。这样能看出判断不是只看 Content-Type,额外请求头也会改变浏览器的发送顺序。若服务端没在 Access-Control-Allow-Headers 中允许这个名称,预检就不能授权页面发出带该头的请求。浏览器会阻止后续请求,页面得到的通常是网络错误,而非接口定义的业务响应。接口排查要记录预检请求头清单,不能只记录媒体类型。
OPTIONS 不是业务正文
预检请求会携带 Origin、Access-Control-Request-Method,以及需要预先许可的非 safelisted 请求头。服务器应以 Access-Control-Allow-Origin 指明允许的来源,以 Access-Control-Allow-Methods 允许 POST;若请求带有 Content-Type 这类非 safelisted 头,还应在 Access-Control-Allow-Headers 中放行它。预检响应成功后,浏览器才继续发送 POST。OPTIONS 通过不代表正式响应也自动满足 CORS,正式响应同样需要合适的 Access-Control-Allow-Origin。
这里最容易混淆的是浏览器控制台里的「CORS 错误」。它可能意味着预检被拒绝,也可能是 POST 已经到达服务器,只是正式响应缺少允许来源的响应头。看 Network 面板里的请求顺序和服务端记录,才能区分两种情况。若只有 OPTIONS,没有 POST,先检查预检响应;若 POST 已存在,再检查实际响应头。服务端即使完成了业务处理,浏览器也可能因 CORS 校验不通过而不把响应交给页面脚本。浏览器对响应的拦截不等于服务端撤销了已经执行的操作;涉及写入时,服务日志和业务状态仍要分别核对。错误提示说明页面拿不到结果,不能单凭提示断定请求没有产生副作用。
本机浏览器的请求顺序
脚本用两个回环地址隔离页面和接口,主机名相同并不等于同源。浏览器还会把端口纳入 origin 判断;页面端口是 41731,接口端口是 41732,因此构成跨源请求。日志里 Origin 固定为页面地址,足以确认请求确实从页面来源发出,而不是 Node 脚本直接请求接口。这个差异很关键:Node 的服务端 fetch 不会替页面执行浏览器的 CORS 访问控制,单用 Node 请求成功不能证明前端跨域调用也可用。脚本把浏览器请求留在真实页面上下文,才能观察 OPTIONS 预检及其后续 POST。
脚本 tools/verify-cors-content-type-20261019.mjs 启动两个回环地址:页面在 127.0.0.1:41731,接口在 127.0.0.1:41732,端口不同,因此浏览器按跨源处理。脚本驱动本机 Chrome 发起四种 POST,接口同时记录访问方法和头部。没有访问外网。
node v26.8.1
browser results: ["text/plain: 200 {\"ok\":true}","application/x-www-form-urlencoded: 200 {\"ok\":true}","application/json: 200 {\"ok\":true}","text/plain + X-Client-Trace: 200 {\"ok\":true}"]
server requests:
POST /echo origin=http://127.0.0.1:41731 content-type=text/plain request-headers=-
POST /echo origin=http://127.0.0.1:41731 content-type=application/x-www-form-urlencoded request-headers=-
OPTIONS /echo origin=http://127.0.0.1:41731 content-type=- request-headers=content-type
POST /echo origin=http://127.0.0.1:41731 content-type=application/json request-headers=-
OPTIONS /custom origin=http://127.0.0.1:41731 content-type=- request-headers=x-client-trace
POST /custom origin=http://127.0.0.1:41731 content-type=text/plain request-headers=-
== end evidence ==
四次调用最终都拿到 200,但 JSON 那次在 POST 前多了一条 OPTIONS,Access-Control-Request-Headers 的值是 content-type。服务器允许该头和 POST,浏览器才继续。两种 safelisted 类型没有这一步;添加 X-Client-Trace 的调用则在 /custom 先出现 OPTIONS,其请求头清单是 x-client-trace。脚本给预检和 POST 都返回 Access-Control-Allow-Origin,所以页面能够读取四次响应;只准预检却漏掉正式响应头,结果会不同。
排查时我会先确认请求是否真的到达,再根据 OPTIONS 与 POST 的先后定位。别为了避开预检把 JSON 硬改成 text/plain:那会改变接口声明的媒体类型,还可能让服务端解析规则不匹配。JSON API 应保留明确的类型。预检是浏览器的权限协商,不是页面代码能绕过的额外网络噪声。实际配置中,按接口需要限定可访问的来源和允许的方法,并把允许头收窄到确实会发送的字段;不必对所有来源一概放行。跨域策略应由服务器明确表达,不能靠更改客户端媒体类型来掩盖配置不匹配。