先说结论:按钮没有卡死,是导出任务从未创建
一次报表导出功能上线后,页面源是 https://dashboard.example.test,导出 API 在 https://api.example.test。用户点击“导出 CSV”后,鼠标旁的加载图标一直转,进度停在 0%,下载链接始终没有出现。
第一次排查很容易把锅甩给“导出服务变慢”:临近汇报时,大家反复点击按钮,却只看到同一个转圈动画。直到打开 Network 才发现,浏览器先发送 OPTIONS /v2/report-exports,CDN 返回了一份过期的预检响应;因为响应没有允许前端新增的 X-Export-Source 请求头,浏览器直接取消了后续 POST。导出任务根本没有创建,页面当然只能一直等。
排查跨域时,先确认
OPTIONS的响应,再确认实际请求是否存在;“看到 CORS 报错”不等于“POST 已经到达服务端”。
问题现场:从按钮到 HTTP 报文
前端为了创建异步导出任务,调用:
POST /v2/report-exports HTTP/1.1
Host: api.example.test
Origin: https://dashboard.example.test
Content-Type: application/json
X-Export-Source: dashboard
{"format":"csv","filter":{"month":"2026-08"}}
任务创建成功后,前端才会继续请求:
GET /v2/report-exports/job_20260806_001/status HTTP/1.1
Host: api.example.test
Origin: https://dashboard.example.test
为了兼容跨源 JSON 请求,浏览器会先自动发预检:
OPTIONS /v2/report-exports HTTP/1.1
Host: api.example.test
Origin: https://dashboard.example.test
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-export-source
错误的 CDN 响应
第一次上线时,前端还没有 X-Export-Source,CDN 缓存了一份看似正常的响应:
HTTP/1.1 204 No Content
Age: 86400
X-Cache: HIT
Cache-Control: public, max-age=86400
Access-Control-Allow-Origin: https://dashboard.example.test
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
本次故障中,CDN 有一条主动缓存 OPTIONS 的边缘规则;这不是浏览器或所有 CDN 的默认行为。问题在于该规则只按 URL 复用响应,没有把 Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers 纳入变化维度。
后来前端新增了 X-Export-Source,但 CDN 的缓存键没有考虑 Access-Control-Request-Headers,于是仍把旧响应发给浏览器。结果是:
OPTIONS 204 (网络层成功,但 CORS 校验失败)
POST 不存在
GET 不存在
HTTP 状态码是 204,并不代表浏览器会放行。预检响应必须同时满足 CORS 规则,缺一个允许的请求头也会让实际请求停在浏览器这一层。
把故障画成一条 HTTP 时序
这个时序里,API 甚至没有机会执行导出逻辑。把前端 fetch 改成另一个库、把按钮组件重写一遍,都不会改变结果;必须先修正预检响应或请求本身。
为什么这个 POST 会触发 OPTIONS
跨源请求不是“只要是 POST 就预检”。只有不满足“简单请求”条件时,浏览器才会先发预检:
- 方法不是
GET、HEAD、POST; - 使用了不在 CORS 安全列表里的请求头,例如
X-Export-Source、Authorization; Content-Type不是application/x-www-form-urlencoded、multipart/form-data或text/plain,典型例子就是application/json。
因此本例同时踩中了两个触发条件:JSON 请求体和自定义请求头。浏览器自动生成的 Origin 不是开发者手动设置的自定义头,也不是单独触发预检的条件。
一个合格的预检响应可以是:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://dashboard.example.test
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Export-Source
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
如果请求还要带 Cookie,前端通常会设置 credentials: "include",响应还要有:
Access-Control-Allow-Credentials: true
这时 Access-Control-Allow-Origin 不能写 *,必须返回经过校验的具体 Origin。预检请求本身不会携带跨源 Cookie 等凭证,但它会按照实际请求的凭证模式检查响应是否允许后续请求。
普通 HTTP 的 Allow: GET, POST 只声明资源支持哪些方法,不能替代 CORS 所需的 Access-Control-Allow-Methods。
“跨域失败”到底是哪一层失败
把网络层、CORS 层和业务层分开看,定位会快很多:
| Network 现象 | 结论 | 排查方向 |
|---|---|---|
OPTIONS 为 4xx/5xx、被重定向,或缺少 Access-Control-Allow-*;看不到 POST | 预检失败,实际请求没有被浏览器放行 | 检查 CDN/网关的 OPTIONS 路由和响应头 |
OPTIONS 正常,POST 已发出,但 JS 进入 catch | 实际响应缺 CORS 头、凭证不匹配,或业务返回错误 | 检查 POST 的状态码、响应头和响应体 |
curl 的 POST 成功,浏览器失败 | curl 不执行浏览器 CORS 拦截 | 用带 Origin 的预检命令复现 |
OPTIONS、POST 都成功,页面仍一直 loading | 前端状态机或响应解析有问题 | 检查 loading 终止、重试和 JSON 解析 |
还有两个常见反例:
- 简单跨源
POST可能已经到达服务器,只是响应没有 CORS 头,JavaScript 读不到结果;服务端仍需做好鉴权、幂等和 CSRF 防护。 POST返回204 No Content时,如果前端无条件调用response.json(),会得到Unexpected end of JSON input。这属于响应解析错误,不是 CORS。
可复制的排查步骤
1. 在浏览器里制造一个确定会预检的请求
const url = 'https://api.example.test/v2/report-exports';
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Export-Source': 'debug',
},
body: JSON.stringify({ format: 'csv' }),
})
.then(async (response) => ({ status: response.status, body: await response.text() }))
.then(console.log)
.catch(console.error);
打开 Network 的 Preserve log,按 exports 过滤,确认是否出现 OPTIONS → POST。如果只有 OPTIONS,先不要改 UI 代码。
2. 用 curl 模拟预检
curl -i -X OPTIONS \
'https://api.example.test/v2/report-exports' \
-H 'Origin: https://dashboard.example.test' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-export-source'
逐项核对:
- 状态码是否为 2xx/204,是否存在 301/302。
Access-Control-Allow-Origin是否精确匹配Origin(协议、主机、端口都要一致)。Access-Control-Allow-Methods是否包含POST。Access-Control-Allow-Headers是否覆盖预检中的每个请求头。- 如果使用凭证,是否同时满足
Access-Control-Allow-Credentials: true和 Cookie 的SameSite/Secure条件。
curl 不会替浏览器执行 CORS 校验,所以它能收到 204,只能证明网络可达,不能证明页面一定能读取响应。
3. 判断是不是 CDN 缓存污染
重点查看预检响应中的 Age、Date、Via、X-Cache 等信息,并做三组对比:
- 经过 CDN 的 URL 与直连源站的 URL;
- 带不同
Origin的请求; - 带不同
Access-Control-Request-Headers的请求。
如果响应体/状态相同,但 Allow-Headers 没有随请求变化,通常就是缓存键或 Vary 配置不完整。注意:不要把真实 token 放进命令行或截图。
修复方案:先止血,再治理缓存
临时止血
- 清理 CDN 上旧的 OPTIONS 缓存,同时禁用或修正会复用错误响应的缓存规则。
- 在边缘层临时关闭预检缓存,或缩短
Access-Control-Max-Age。 - 把当前前端真实使用的请求头补进
Access-Control-Allow-Headers。
临时措施只解决当前版本,不能保证下一次新增请求头后仍然正确。
长期方案
网关/CDN 应把 CORS 作为一条明确的策略链处理:
- 校验
Origin,只从受控 allowlist 中选择允许来源,不要原样信任请求头。 OPTIONS在鉴权、业务路由和复杂 WAF 规则之前返回 204/200。- 预检响应至少按
Origin、请求方法和请求头维度正确变化;可以不经 CDN 缓存,或使用Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers。 Access-Control-Allow-Methods、Access-Control-Allow-Headers显式覆盖真实请求。- 实际
POST的成功和错误响应也带Access-Control-Allow-Origin。 - 若使用凭证,返回具体 Origin +
Access-Control-Allow-Credentials: true,不要使用*。
一个与产品无关的伪代码示例:
const allowedOrigins = new Set([
'https://dashboard.example.test',
]);
function cors(req, res, next) {
const origin = req.get('Origin');
if (origin && allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader(
'Vary',
'Origin, Access-Control-Request-Method, Access-Control-Request-Headers',
);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, X-Export-Source',
);
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
return next();
}
如果接口完全不使用凭证,可以用 Access-Control-Allow-Origin: *,但也不建议把它当成长期的任意来源策略;带 Cookie 或其他凭证时必须返回具体 Origin。
前端防御:不要让网络失败变成无限 loading
即使网关已经修好,导出页面也应该:
- 在
fetch失败(包括预检失败)时结束 loading,并展示可操作的错误信息; - 对创建任务设置超时和退避重试,不要无间隔重发;
- 让创建导出任务具备幂等键,避免用户重复点击生成多个任务;
- 只有拿到明确的任务 ID 后,才开始状态轮询。
验收清单
- 每个允许的 Origin 的
OPTIONS都返回 2xx/204,且无重定向。 -
Access-Control-Allow-Origin与实际 Origin 精确匹配。 -
Access-Control-Allow-Methods包含真实方法POST。 -
Access-Control-Allow-Headers覆盖真实请求头。 - CDN 不会把一个 Origin 或请求头集合的预检响应错误复用给另一个请求。
-
POST的成功、失败和网关错误响应都带 CORS 头。 - Network 中能看到
OPTIONS → POST → GET status的完整链路。 -
204 No Content不再被前端当作 JSON 强行解析。 - 页面失败时能结束 loading,用户可以重新发起导出。
最后复盘:记住三个问题
遇到“按钮一直转圈”的跨域问题,不要从按钮组件开始猜。先回答:
- 页面发出的
Origin是什么? OPTIONS是否明确允许这次方法和请求头?- 真正的
POST有没有出现在 Network 和服务端日志里?
只要这三个答案清楚,问题通常就能从“前端玄学”还原成一组可验证的 HTTP 报文。
相关旧文: 同源与跨域、HTTP OPTIONS 请求详解、HTTP 协议、HTTP 请求方式详解。
权威参考: