React 管理后台实战 · 后端异步导出做好了,前端下载却总是失败?轮询进度 + 双形态下载一次搞定
各位看官,上一篇聊了自封请求层(React 管理后台实战 · 前端请求层怎么写?),把鉴权头、401 静默刷新、强制改密这些横切逻辑焊死在 fetch 里。那一层有个前提:所有接口都返回统一 JSON 信封。但有一个接口例外——它就是异步导出文件下载,差点让我栽跟头。
场景是这样的:管理后台要支持运营同学把大数据量业务数据导出成 CSV。后端是 Cloudflare Workers + D1,单请求有 CPU 时间上限,几万行的 CSV 不可能同步生成,所以走「建任务 → 后台异步生成 → 前端轮询 → 下载」的异步模式。后端那套我之前写过(Node 后端实战 · Serverless 导出 CSV 总超时?用 Queue + R2 异步任务彻底解决),今天专门讲前端这一个页面怎么写才稳。

一、先把整条链路说清楚
点击「导出」 → POST /tasks(建任务,限流 2 次/小时)→ 立即返回 taskId + pending
→ 后端 Queue 异步生成 CSV(游标分页,自动续跑)
→ 前端轮询 GET /tasks 直到 status=done
→ 调用 GET /tasks/:id/download 拿到文件 → 浏览器保存 CSV
前端要做三件事:建任务、轮询进度、下载文件。看着平平无奇,坑全在轮询和下载这两步。
| 环节 | 关键点 | 做错会怎样 |
|---|---|---|
| 建任务 | 限流 2 次/小时,超了返回 429 | 疯狂点导出被限流,体验差 |
| 轮询 | 动态间隔,终态后停止 | 2.5s 死轮询,烧后端 Worker 额度 |
| 下载 | 响应可能是 JSON 直链或 CSV 流 | 直接走 JSON 层解包 → 下载失败 |
二、轮询进度:别用定时器死轮,2.5s 是真坑
一开始我图省事,脑子里想的是「每 2~3 秒打一次任务列表」。后端同学找过来说:你这一页好几个任务在跑,每个 2.5s 轮一次,Worker 调用量直接翻倍,而且 D1 游标分页扫大表本身就不轻,雪上加霜。
正确做法是用 React Query 的 refetchInterval,并且动态判定:只要还有 pending/processing 的任务才轮询,且把间隔从 2.5s 提到 10s;全部到终态就返回 false 停掉。
const EXPORT_KEY = ['export-tasks'] as const
export function useExportTasks(params?: Record<string, unknown>) {
return useQuery({
queryKey: [...EXPORT_KEY, params],
queryFn: () => fetchExportTasks(params),
refetchInterval: (query) => {
const items = (query.state.data as { items?: { status: string }[] })?.items ?? []
const active = items.some(
(t) => t.status === 'pending' || t.status === 'processing',
)
return active ? 10000 : false // 有进行中的才轮,10s 一次
},
})
}

这里有个细节:轮询用的 queryKey 必须带 params。我之前在别的列表页栽过——两个不同筛选条件下的列表共用了不带参的 key,导致切换筛选时缓存互相覆盖、列表乱跳。轮询场景尤其要小心,否则进度条会跟着 params 错乱。
三、下载最坑:这个接口不能走统一请求层
这是整篇文章最核心的一点。我们那层请求层会自动把响应当 JSON 解包、读 error.code。但下载接口响应体可能不是 JSON:
- 形态 A(预签名直链):后端返回
application/json,里面是 R2 的预签名 URL,前端再去下载。 - 形态 B(流式代理):后端直接把 CSV 文件流吐出来,响应头是
text/csv,根本不是 JSON。
所以下载函数必须独立 fetch、按 Content-Type 分流,绝不能塞进那层 JSON 解包里,否则形态 B 一进来 res.json() 直接抛异常,文件永远下不下来。
export async function downloadExportFile(taskId: string): Promise<void> {
const token = useAuthStore.getState().token
const base = import.meta.env.VITE_API_BASE ?? ''
const res = await fetch(`${base}/api/tenant/export/tasks/${taskId}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
// 非 2xx:尽量解析错误体(可能是 JSON 错误信封)
const text = await res.text()
let code = ''
let message = `下载失败(${res.status})`
try {
const json = JSON.parse(text)
code = json.error?.code ?? ''
message = json.error?.message ?? message
} catch { /* 非 JSON,忽略 */ }
if (res.status === 409) throw new ApiRequestError('CONFLICT', '文件生成中,请稍候', 409)
if (res.status === 404) throw new ApiRequestError('NOT_FOUND', '任务已失效或文件丢失', 404)
throw new ApiRequestError(code || `HTTP_${res.status}`, message, res.status)
}
const ct = res.headers.get('content-type') || ''
// 形态 A:取预签名直链,用 <a> 模拟点击下载(非 window.open)
if (ct.includes('application/json')) {
const json = await res.json()
const data = json.data
if (!data?.url) throw new ApiRequestError('NO_URL', '未获取到下载地址', 200)
const a = document.createElement('a')
a.href = data.url
a.download = data.fileName || 'export.csv'
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
a.remove()
return
}
// 形态 B:CSV 流,直接存 blob
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
const cd = res.headers.get('content-disposition') || ''
a.download = cd.match(/filename="?([^";]+)/)?.[1] || 'export.csv'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(a.href)
}
真实事故:window.open 打开新标签页下载,在不少浏览器里不触发保存
我第一版形态 A 用的是 window.open(data.url, '_blank'),觉得「打开个新标签让它自己下载」最省事。结果真机一测:Chrome 对来自脚本的 window.open 会当成弹窗拦截,就算没拦,R2 预签名链接返回的响应是 Content-Disposition: attachment,新标签页打开后要么一片空白、要么根本没下载动作。运营同学反馈「点了下载没反应」。
改成上面那样——动态创建一个 <a>,设好 download 属性,.click() 模拟点击,再 remove() 掉——强制触发浏览器保存对话框,两个形态都好使。这是前端下载文件的老经验,但踩过才知道有多关键。

四、任务状态机与错误码
任务从建出来到终态,状态机一目了然:
| status | 前端文案 | 含义 | 可操作 |
|---|---|---|---|
pending | 排队中 | 等待队列领取 | 等待 |
processing | 生成中 | 正在游标分页生成 CSV | 等待 |
done | 可下载 | 文件就绪 | 下载按钮 |
failed | 失败 | 生成异常 | 重试 |
expired | 已过期 | 超过保留期未下载 | 重新导出 |
下载接口的错误码,前端要逐个接住:
| HTTP | code | 前端处理 |
|---|---|---|
| 409 | CONFLICT | 未完成就下载 → 提示「生成中,请稍候」 |
| 404 | NOT_FOUND | 任务失效或文件丢失 → 提示失效 |
| 429 | RATE_LIMIT | 每小时超 2 次 → 禁用按钮/提示剩余 |
| 401 | AUTH_EXPIRED | token 过期 → 走请求层静默刷新 |
| 500 | INTERNAL | 服务端异常 → 重试/联系后端 |
五、几个容易漏的细节
- R2 预签名直链有时效:形态 A 的 URL 通常只有几分钟有效期,过期下载会 403/404。我们的处理是「每次下载都重新调一次 download 接口拿新链接」,而不是把第一次拿到的 URL 缓存下来——这点前端别自作聪明做缓存。
- blob URL 用完要 revoke:形态 B 用
URL.createObjectURL造的临时地址,.click()之后记得URL.revokeObjectURL(a.href)释放,不然内存泄漏。 - 权限前置:导出含敏感字段(比如明文联系方式),只开放给平台超管和租户超管,路由和菜单层面就要收敛,普通角色连入口都看不到。多租户下这块的门禁我在 Node 后端实战 · 多租户 SaaS 怎么防止租户串数据? 里写过;租户超管只能导出自己租户的数据,跨租户隔离见 Node 后端实战 · 多租户数据隔离。
- 敏感操作记审计:这种导出在后端强制写审计日志,前端在按钮旁加一行「操作将被记录」的弱提示即可,别做成强拦截影响体验。
小结
异步导出前端看着简单,真正落地就三件事:轮询别死轮(动态间隔 + 终态停)、下载别走 JSON 层(按 Content-Type 分流)、保存别用 window.open(用 <a> 模拟点击)。这几点都是我在真机和联调里踩出来的,希望对做管理后台的你有点用。
相关阅读:
- Node 后端实战 · Serverless 导出 CSV 总超时?用 Queue+R2 异步任务彻底解决
- React 管理后台实战 · 前端请求层怎么写?401 静默刷新、token 并发竞争、强制改密拦截一次说清
- Node 后端实战 · 多租户 SaaS 怎么防止租户串数据?接口门禁 + 租户隔离 + 行级权限三层实战
- Node 后端实战 · 多租户数据隔离
- Node 后端实战 · JWT 双密钥轮转与 token 版本号
- Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
- Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
- Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战
- Node 后端实战 · 老系统数据迁移怎么不出乱子?V1→V2 重构实战与 3 个生产坑
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!
276

被折叠的 条评论
为什么被折叠?



