浏览器跨域请求:Fetch 完整时序与排障

验证:未记录
适用版本
未记录
验证范围
尚未登记验证记录。

一次 fetch() 不一定只产生一个请求:浏览器可能先发 OPTIONS,通过后再发业务请求;业务响应也要再次通过跨域检查,JavaScript 才能读取。

HTTP 是 Hypertext Transfer Protocol(超文本传输协议);CORS 是 Cross-Origin Resource Sharing(跨源资源共享);API 是 Application Programming Interface(应用程序接口)。本文聚焦浏览器时序,字段说明见 Header 基础。

1. 浏览器如何判断跨域#

源由 协议 + 主机 + 端口组成,任一不同即跨源,通常称“跨域”。路径不同不算。

页面 → API 是否跨源
https://shop.example.com/a → 同域 /api/orders 否
https://shop.example.com → https://api.example.com 是;子域不同
https://shop.example.com → 同主机 :8443 是;端口不同
HTTPS 页面 → HTTP API 是;还可能被混合内容策略直接拦截

浏览器按地址栏页面的源判断,不关心后端是否在同一个集群。同源 /api 即使被代理转到其他服务器,浏览器侧仍可保持同源。

2. 一次 JSON POST 的完整过程#

页面 https://shop.example.com 执行:

const response = await fetch('https://api.example.com/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ productId: 123 })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const order = await response.json();

这是时序示例,真实订单接口还需身份验证、授权和幂等控制。JSON Content-Type 已足以触发预检;增加 Authorization 也会触发。

页面 JavaScript          浏览器                         API
      │ fetch()             │                            │
      ├────────────────────>│                            │
      │                     ├── OPTIONS /orders ─────────>│
      │                     │<─ 204 + 允许规则 ───────────┤
      │                     │ 检查状态、源、方法、请求头  │
      │                     ├── POST /orders + JSON ─────>│
      │                     │                       执行业务
      │                     │<─ 201 + 跨域头 + JSON ──────┤
      │                     │ 检查实际响应的跨域许可      │
      │<── Response ────────┤                            │
      │ await response.json():读取并解析正文             │

① OPTIONS:询问能否发送#

浏览器自动生成,不需要业务代码先写一次 fetch(..., {method: 'OPTIONS'})。

OPTIONS /orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

没有订单正文。带 Authorization 的业务请求会在预检中声明头名称,不是把 token 发在预检里。

② 预检响应:2xx + 规则匹配才继续#

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 300
Vary: Origin

204 是常见选择,200 等其他 2xx 也可以。浏览器还检查源和请求头等许可;对于 PUT、DELETE 等非安全列表方法,还必须明确允许该方法。通常把业务方法完整列入 Allow-Methods,便于审计。只有 204、缺少正确跨域头,仍然失败。

③ 真正 POST:这时才执行业务#

POST /orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Content-Type: application/json

{"productId":123}

服务端仍必须独立验证身份、权限和参数,预检通过不代表有权下单。

④ 实际响应:浏览器再次检查#

HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://shop.example.com
Vary: Origin
Content-Type: application/json

{"orderId":456}

实际响应仍需 Allow-Origin;不能只在 OPTIONS 返回它。通过检查后 fetch() 返回 Response,response.json() 再异步读取正文。201 不会自动触发下一次 GET,是否继续查询由业务代码决定。协议依据见 Fetch 标准。

3. 哪些情况下没有 OPTIONS#

  • 同源请求不需要 CORS 预检。
  • 常见跨源简单 GET,以及满足安全列表条件的 HEAD/POST,可以直接发送。POST 的 Content-Type 通常需是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain,并满足头值等限制;不能额外带 Authorization、自定义头等。
  • 有效预检缓存可允许后续请求直接发送。Access-Control-Max-Age 控制缓存期限,浏览器还会施加自己的上限;URL、源、方法、头和凭证模式等会影响复用。

简单请求仍检查实际响应。credentials: 'include' 本身不会让一个简单 GET 必然触发预检。预检缓存与普通 HTTP 内容缓存不同,不能只靠页面刷新判断它已清空。

const response = await fetch('https://api.example.com/account', {
  credentials: 'include'
});
console.log(response.status, response.ok);

默认 credentials 是 same-origin,跨源会话请求通常需要显式 include。服务端实际响应需:

Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

若请求需要预检,预检响应也要声明这些许可。按标准,跨源预检不带业务 Cookie;不要要求 OPTIONS 先登录,实际请求必须保留鉴权。

  • Allow-Origin 不能是 *,也不能是逗号分隔的多个源;按白名单匹配后返回单个源。
  • 跨源不一定跨站:同协议的 shop.example.com 与 api.example.com 通常是同站。Cookie 是否携带还取决于 Domain、Path、Secure、SameSite 和浏览器策略。
  • 真正跨站的 Cookie 通常需 SameSite=None; Secure,但第三方 Cookie 限制仍可能阻止它;include 不能绕过这些限制。
  • JavaScript 不能自行设置 Cookie 请求头,也不能通过 response.headers 读取 Set-Cookie;在浏览器 Application/Storage 和 Network 检查。
  • Cookie 自动携带的写接口还需防 CSRF(Cross-Site Request Forgery,跨站请求伪造),不能只靠 CORS。

5. 失败发生在哪里#

情况 业务请求是否已发送 JavaScript 看见什么
预检 403/500,或 204 但跨域许可不足 本次不会发送 fetch 拒绝,通常 TypeError
POST 执行成功,但实际响应缺少 Allow-Origin 已发送,可能已写入 fetch 拒绝,不能读取该响应的 201/正文
实际响应 401/403/500,跨域许可正确 已发送 正常拿到 Response;ok === false
网络中断/超时/主动取消 可能尚未到达,也可能已执行 拒绝;不能仅凭错误判断服务端未执行
JSON 格式错误,跨域许可正确 已发送 fetch 可成功;response.json() 失败

业务写请求须使用服务端支持的幂等键和结果查询,不能因为 CORS 错误就无条件重发。mode: 'no-cors' 也不是解决方案:响应通常是 opaque(不透明),脚本看到 status 0、读不到正文,并且请求方法和头受限。Fetch 的错误处理见 浏览器 Fetch 使用说明。

6. Hands-on:在真实预发布环境逐步观察#

第一步:准备观察条件#

使用自己负责的前端预发布页面,以及一个无副作用的 GET API。后端允许该前端 Origin,并允许 X-Request-ID 请求头;响应返回同名关联 ID 时,通过 Access-Control-Expose-Headers: X-Request-ID 暴露给脚本。首次用无需会话的健康/查询接口,Cookie 场景最后测试。

打开前端页面的开发者工具,Network 选择 All、启用 Preserve log,显示 Method 列;清除列表。确保页面内容安全策略允许访问该 API。不要在无关网站的 Console 执行,也不要关闭浏览器安全功能。

第二步:触发“OPTIONS → GET”#

在该页面 Console 执行,替换 URL。唯一查询参数用于避免已有预检缓存干扰,接口须允许忽略此诊断参数。

var corsUrl = new URL('https://api.staging.example.com/health/ready');
corsUrl.searchParams.set('cors_probe', crypto.randomUUID());
var corsOptions = {
  headers: { 'X-Request-ID': crypto.randomUUID() },
  credentials: 'omit',
  signal: AbortSignal.timeout(10000)
};
var corsResponse = await fetch(corsUrl, corsOptions);
console.log({
  status: corsResponse.status,
  ok: corsResponse.ok,
  requestId: corsResponse.headers.get('X-Request-ID')
});
console.log(await corsResponse.text());

预期先 OPTIONS,再 GET:预检声明 x-request-id;2xx + 正确许可后才发送 GET。后端日志也应能找到两次请求。脚本读取的 requestId 为 null 时,先检查实际响应是否有该头,以及是否通过 Expose-Headers 暴露。

随后重新创建 timeout signal、对同一 URL 再请求一次。若服务器设置了正值 Max-Age 且缓存有效,可能只看到 GET。再用新 URL、不带自定义头发 GET,观察简单请求直接发送;不把“没 OPTIONS”当成异常。

第三步:验证三种失败分支#

只在隔离的预发布路由进行,一次改一项,记录并恢复原配置;每次更换诊断查询参数。不要改共享生产监听器。

临时配置/操作 必须观察到
OPTIONS 不允许 X-Request-ID,实际 GET 保持原配置 OPTIONS 后没有 GET;Console 报跨域错误
OPTIONS 允许,实际 GET 去掉 Allow-Origin Network/服务端日志证明 GET 已发送;脚本仍读不到响应
实际 GET 返回 401,但保留正确跨域头 fetch 返回 Response,status 401、ok false

每项验收后恢复,再用第二步确认成功。若暂时没有修改后端权限,只做正常链路观察,不将失败分支标为已验收。

第四步:验收会话与真实业务#

在测试账号已登录的前提下,把 URL 换成只读 /account,设置 credentials: 'include',检查实际 Cookie 是否发送、响应是否使用精确 Origin 和 Allow-Credentials。然后用测试租户执行一笔具备幂等保护的业务请求,核对浏览器、入口日志、应用日志和业务记录中的关联信息;日志不记录 Cookie/Authorization 内容。

完成后恢复临时配置、清除测试数据,保存脱敏后的状态码与头部截图。HAR(HTTP Archive,HTTP 请求归档)可能含凭证和业务正文,分享前必须脱敏。

7. ALB 链路上的定位顺序#

浏览器 → CDN(内容分发网络,可选)→ ALB(应用负载均衡器)→ 应用:先看 OPTIONS 是否到达入口、是否被认证重定向或防火墙拒绝,再看应用是否处理,最后核对实际响应头是否被代理覆盖。

ALB 添加跨域响应头不会自动替应用完成 OPTIONS。401/403/500 缺少跨域头时,浏览器可能只显示 CORS 错误,真实原因需查入口和应用日志。配置和回退见 ALB Header 实战。

本文示例已做静态检查;真实浏览器与目标服务的联调需按上述步骤验收。

搜索文档

输入关键词,搜索所有文档。

↑ ↓ 选择 · Enter 打开 · Esc 关闭