登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

Fetch API流式读取响应并显示下载进度的实现

来源:17golang原创

时间:2026-09-20 08:48:35 359浏览 收藏

下载大文件时,如果直接调用 response.blob()response.arrayBuffer(),业务代码通常要等整个响应读完才拿到结果。需要实时显示进度时,应改为读取 response.body:它是一个 ReadableStream,每次得到一块 Uint8Array,累计字节数后再更新界面。

真正可靠的实现有两个前提:响应头能提供 Content-Length 时才计算百分比;没有总长度时只展示“已接收多少”,不要把未知状态硬算成 0% 或 100%。下面的方案还会补上取消请求、异常提示和 Blob URL 清理。

要点速览
  • response.body.getReader() 让下载响应按块到达,value.length 是当前字节块的大小。
  • Content-Length 缺失并不表示下载失败,只表示无法可靠计算整体百分比。
  • 文件合并为 Blob 后要释放 URL.createObjectURL() 创建的临时 URL。

先把 Fetch 的响应流接到可观察状态

示例准备一个按钮、进度条和状态文本。函数不假定框架,调用方只需要传入下载地址和文件名。先检查 HTTP 状态与 body,再读取响应头;这样 404 或没有响应体时不会在后面的 getReader() 位置才暴露模糊异常。

// 中文注释:这些元素分别承载取消按钮、进度条和可读状态
const downloadButton = document.querySelector('#downloadButton');
const cancelButton = document.querySelector('#cancelButton');
const progressBar = document.querySelector('#progressBar');
const statusText = document.querySelector('#statusText');

let activeController = null;

function formatBytes(bytes) {
  // 中文注释:用二进制单位显示已接收大小,避免界面只出现很长的数字
  if (bytes 

这里的 activeController 只保存当前请求。新的下载开始前先阻止重复点击,取消按钮则调用同一个控制器;不要为每个 UI 事件临时创建多个互相不知道的取消对象。

Fetch Response body ReadableStream reader read 和已接收字节数之间的静态关系图
图1:Fetch 响应体从 Response.body 进入 ReadableStream,再由 reader.read() 返回字节块并累加接收量。

用 reader.read() 累计每个响应块

核心循环只关心两个返回值:done 表示流已经结束,value 是本次到达的字节数组。读取器会锁定流,因此不要在同一个 response.body 上再调用 text()blob();两套消费方式只能选一套。

// 中文注释:流式下载并返回 Blob;signal 让调用方可以主动取消
async function downloadWithProgress(url, filename, signal) {
  const response = await fetch(url, { signal });
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  if (!response.body) {
    throw new Error('响应没有可读取的 body');
  }

  // 中文注释:Content-Length 不存在时 totalBytes 为 null,进度必须保持不确定状态
  const lengthHeader = response.headers.get('Content-Length');
  const totalBytes = lengthHeader ? Number(lengthHeader) : null;
  const reader = response.body.getReader();
  const chunks = [];
  let receivedBytes = 0;

  while (true) {
    // 中文注释:每次 read 等待下一块数据,直到 done=true 才结束循环
    const { done, value } = await reader.read();
    if (done) break;
    if (!value) continue;

    chunks.push(value);
    receivedBytes += value.length;

    if (Number.isFinite(totalBytes) && totalBytes > 0) {
      const percent = Math.min(100, (receivedBytes / totalBytes) * 100);
      progressBar.max = 100;
      progressBar.value = percent;
      progressBar.removeAttribute('aria-busy');
      statusText.textContent = `${percent.toFixed(1)}%(${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)})`;
    } else {
      // 中文注释:没有可靠总长度时只报告已接收字节,不伪造百分比
      progressBar.removeAttribute('value');
      progressBar.setAttribute('aria-busy', 'true');
      statusText.textContent = `已接收 ${formatBytes(receivedBytes)},总大小未知`;
    }
  }

  return new Blob(chunks, {
    type: response.headers.get('Content-Type') || 'application/octet-stream'
  });
}

Content-Length 的单位是字节,但它可能不存在,或者因为动态生成、分块传输、代理处理而不能代表最终可显示的总量。因此代码把它解析为有限正数后才计算百分比。即便收到的字节数超过了头部值,也只把界面上限夹到 100%,不要据此断言文件内容一定完整。

把两种进度语义明确地呈现出来

有总长度时,进度条可以表达比例;没有总长度时,进度条只能表达“仍在读取”。如果页面必须显示百分比,应让服务端提供稳定的长度,或改用分片协议单独传递总大小。CORS 场景还要确认响应头能够被浏览器脚本读取,否则 headers.get('Content-Length') 仍可能得到 null

响应条件界面状态实现建议
Content-Length 是正数确定进度按 receivedBytes / totalBytes 更新百分比
没有 Content-Length不确定进度显示已接收大小和“总大小未知”
响应失败或 body 为空失败状态停止进度并给出 HTTP 状态或读取错误
Fetch下载进度中Content-Length已知未知分支以及AbortController和Blob URL资源边界图
图2:Content-Length 决定是否能计算百分比;AbortController、Blob 和 object URL 则负责请求取消与下载资源清理。

完成下载后触发文件保存并释放 URL

拿到 Blob 后创建临时 URL,借助隐藏的 a 元素触发浏览器保存。点击完成后仍要调用 URL.revokeObjectURL();它不是可选的“性能优化”,而是避免临时对象长期占用内存的收尾动作。

// 中文注释:把 Blob 转成一次性下载链接,并在点击后释放临时 URL
function saveBlob(blob, filename) {
  const objectUrl = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = objectUrl;
  link.download = filename;
  link.click();

  // 中文注释:延迟释放,给浏览器完成本次点击导航的机会
  setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}

把取消、失败和按钮复位放进同一条路径

完整调用还要区分用户主动取消和真正失败。AbortController 抛出的通常是 AbortError,它不应该被页面提示成服务器故障;而 finally 负责无论成功还是失败都恢复按钮状态。

// 中文注释:统一管理一次下载的开始、取消、成功和失败状态
async function startDownload() {
  if (activeController) return;

  activeController = new AbortController();
  downloadButton.disabled = true;
  cancelButton.disabled = false;
  statusText.textContent = '正在连接…';

  try {
    const blob = await downloadWithProgress(
      '/files/report.zip',
      'report.zip',
      activeController.signal
    );
    saveBlob(blob, 'report.zip');
    statusText.textContent = '下载完成';
  } catch (error) {
    if (error.name === 'AbortError') {
      statusText.textContent = '已取消下载';
    } else {
      statusText.textContent = `下载失败:${error.message}`;
    }
    progressBar.removeAttribute('value');
  } finally {
    // 中文注释:清空引用并恢复控件,避免下一次下载被旧状态挡住
    activeController = null;
    downloadButton.disabled = false;
    cancelButton.disabled = true;
  }
}

downloadButton.addEventListener('click', startDownload);
cancelButton.addEventListener('click', () => activeController?.abort());

示例中的官方资料入口可以直接复制:https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch。如果需要进一步确认流锁定、响应头或浏览器支持范围,可从该页面进入 Streams API 和 Headers 的说明。

上线前检查三个边界

  • 服务端是否真的返回可读的 Content-Length;没有它时,产品文案应接受不确定进度。
  • 跨域下载是否配置了允许脚本读取所需响应头,失败时不要把“读不到长度”误判为“没有文件”。
  • 请求取消、HTTP 非 2xx、读取中断和 Blob URL 释放是否都能回到可再次点击的状态。

常见问题

为什么进度条一直没有百分比?

最常见原因是响应没有可读取的 Content-Length,此时只能显示已接收字节。也可能是跨域响应头未暴露给脚本,先检查响应头读取权限。

读取 response.body 后还能再调用 response.blob() 吗?

不建议这样做。reader 会锁定流,流已经被消费后再用另一种方式读取会失败;应在同一次读取循环里收集字节块并构造 Blob。

为什么要延迟 revokeObjectURL?

临时 URL 需要先完成这次下载链接点击,再释放其引用。延迟到当前任务结束通常足够,长时间保留则会积累无用对象。

这套实现的关键不是让每次响应都出现漂亮的百分比,而是让界面状态忠实反映协议条件:有总长度就计算比例,没有总长度就报告已接收量;无论哪条分支结束,都释放请求和临时 URL 资源。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>