React 管理后台实战 · 后端异步导出做好了,前端下载却总是失败?轮询进度 + 双形态下载一次搞定

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已过期超过保留期未下载重新导出

下载接口的错误码,前端要逐个接住:

HTTPcode前端处理
409CONFLICT未完成就下载 → 提示「生成中,请稍候」
404NOT_FOUND任务失效或文件丢失 → 提示失效
429RATE_LIMIT每小时超 2 次 → 禁用按钮/提示剩余
401AUTH_EXPIREDtoken 过期 → 走请求层静默刷新
500INTERNAL服务端异常 → 重试/联系后端

五、几个容易漏的细节

  1. R2 预签名直链有时效:形态 A 的 URL 通常只有几分钟有效期,过期下载会 403/404。我们的处理是「每次下载都重新调一次 download 接口拿新链接」,而不是把第一次拿到的 URL 缓存下来——这点前端别自作聪明做缓存。
  2. blob URL 用完要 revoke:形态 B 用 URL.createObjectURL 造的临时地址,.click() 之后记得 URL.revokeObjectURL(a.href) 释放,不然内存泄漏。
  3. 权限前置:导出含敏感字段(比如明文联系方式),只开放给平台超管和租户超管,路由和菜单层面就要收敛,普通角色连入口都看不到。多租户下这块的门禁我在 Node 后端实战 · 多租户 SaaS 怎么防止租户串数据? 里写过;租户超管只能导出自己租户的数据,跨租户隔离见 Node 后端实战 · 多租户数据隔离
  4. 敏感操作记审计:这种导出在后端强制写审计日志,前端在按钮旁加一行「操作将被记录」的弱提示即可,别做成强拦截影响体验。

小结

异步导出前端看着简单,真正落地就三件事:轮询别死轮(动态间隔 + 终态停)、下载别走 JSON 层(按 Content-Type 分流)、保存别用 window.open(用 <a> 模拟点击)。这几点都是我在真机和联调里踩出来的,希望对做管理后台的你有点用。


相关阅读:

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FungLeo

您的鼓励,是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值