浏览器跨域请求: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: Origin204 是常见选择,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 内容缓存不同,不能只靠页面刷新判断它已清空。
4. Cookie 登录请求多了哪些条件#
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 实战。
本文示例已做静态检查;真实浏览器与目标服务的联调需按上述步骤验收。