前端怎么拿到文件下载进度:Content-Length 缺失时的三种取法

编程狮(w3cschool.cn) 2026-10-31 07:07:01 浏览数 (19)
反馈

能拿到,但前提说在前面:百分比不是一个「读出来」的值,而是「已接收字节数 ÷ 总字节数」算出来的。所以只要服务端没有给出总大小——响应头里没有 Content-Length,或者跨域时没把它暴露出来——浏览器就只能告诉你已经收了多少兆,算不出分母,百分比自然无从下手。本文给出前端监听下载进度的三种取法:XMLHttpRequest 的 progress 事件最省事,fetch 配 ReadableStream 最通用,服务端补一个自定义总大小头最可靠。适用边界一并写清:超过几百兆的文件不该用 JS 拼 Blob,也不该期待每种服务端都能给出准确分母。

前端怎么拿到文件下载进度?

先看结论

取法 依赖什么 能拿到百分比吗 适合场景
XMLHttpRequest 的 progress 响应头给出 Content-Length 能,但要判 lengthComputable 常规文件下载、最快落地
fetch + ReadableStream Response.body 可用 有分母才能算,否则只给字节数 需要自己控 SIZE、取消、解压后的处理
服务端补自定义总大小头 后端配合暴露响应头 能,且最准确 流式压缩、分片包加速器场景

一句话总结:能算下载进度百分比就显示百分比,拿不到分母就诚实地显示已下载字节数,不要为了好看去伪造分母。下面三种取法按「改动成本从小到大」排列,实际项目里经常组合使用。

一、先搞清楚百分比的分母从哪来

浏览器下载文件时,服务端返回 Content-Length 表示本次响应体的字节数,这就是分母。分子是浏览器已经收到的字节数,前端通过事件拿到。

三个必须先确认的前提:

  • 服务端用的是不是分块传输(Transfer-Encoding: chunked)。分块传输时通常没有 Content-Length,此时前端事件里的 total 为 0、lengthComputable 为 false。
  • 响应有没有被 gzip 或 br 压缩。压缩之后 Content-Length 记的是压缩后的字节数,浏览器收到的也是压缩后的字节,两者对得上,但和文件解压后的真实大小不是一回事。这也是为什么有的人会发现「进度到 100% 了,文件大小却比预期小」。
  • 是不是跨域请求。跨域时响应头默认只对少数几个简单响应头可见,Content-Length 必须靠服务端返回 Access-Control-Expose-Headers: Content-Length 才会出现在前端可读列表里。

这三个前提对应的知识在 HTTP 教程 里有更系统的说明,尤其是响应头那一节。想不通为什么前端读不到某个响应头,先回到这里看跨域那一条。三个前提里只要有一个不成立,下载进度就只能退化成字节数显示。

二、第一种写法:XMLHttpRequest 的 progress 事件

这是最简单的一条路,浏览器原生支持,兼容性也最好。想监听下载进度,只需要给 XHR 挂一个 progress 回调:

function downloadWithProgress(url, onPercent) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.responseType = "blob";
    xhr.onprogress = (e) => {
      if (e.lengthComputable && e.total > 0) {
        onPercent(Math.round((e.loaded / e.total) * 100));
      } else {
        onPercent(null, e.loaded);
      }
    };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
      else reject(new Error("服务端返回 " + xhr.status));
    };
    xhr.onerror = () => reject(new Error("网络中断"));
    xhr.send();
  });
}

预期结果:下载过程中 onPercent 被反复调用,参数从 1 递增到 100;下载完成后 Promise 兑现为 Blob 对象。

失败分支要写清楚。第一,e.lengthComputable 为 false 时不要硬算,否则会得到 Infinity 或 0%;上面的写法用 null 表示「没有百分比」,同时把已下载字节数交给界面显示。第二,xhr.status 非 2xx 时 xhr.response 可能是服务端返回的错误体(比如一段 JSON),此时要按失败处理而不是当成文件。第三,想中途取消就调用 xhr.abort(),它会触发 onerror 分支,界面上要能区分「用户取消」和「网络错误」。

拿到 Blob 之后再把落盘动作补上:

function saveBlob(blob, filename) {
  const href = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = href;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(href), 0);
}

注意 revokeObjectURL 不能在 click 之前调用,否则下载会拿不到内容,这也是新手最常踩的一处。

按服务端是否给出总大小判断能不能算出百分比

三、第二种写法:fetch 配 ReadableStream 自己累计

用 XMLHttpRequest 的好处是事件现成,代价是不太好接现代流式处理。想自己控速、想顺便做校验,又想继续拿到下载进度,就得上 fetch 的 Response.body:

async function downloadByStream(url, onUpdate) {
  const res = await fetch(url);
  if (!res.ok) throw new Error("服务端返回 " + res.status);
  if (!res.body) throw new Error("当前环境不支持流式读取");

  const total = Number(res.headers.get("Content-Length")) || 0;
  const reader = res.body.getReader();
  const chunks = [];
  let received = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    received += value.byteLength;
    onUpdate(total ? Math.round((received / total) * 100) : null, received, total);
  }

  return new Blob(chunks, { type: res.headers.get("Content-Type") || "" });
}

预期结果:每读到一个分块就回调一次,回调非常密集,界面上应当做降频处理(比如每 200 毫秒更新一次)再渲染,避免频繁重排把主线程拖住。

这段代码用到了 Promise 与异步迭代的基础,需要补的话可以看 JavaScript 教程 的异步章节。

流式累计时每次回调拿到的字节与百分比

失败分支:res.body 在某些老环境里是 null,必须显式判并给出提示;Content-Length 读不到时 total 为 0,此时退化为显示字节数。还有一种容易忽略的情况——服务端是分块传输且不压缩时,响应体大小未知,估算不准的问题不是前端能解决的,得回到第四节的方案。

四、第三种做法:让服务端把分母递过来

如果下载链路中间经过了自己的网关,最稳的做法是让网关或后端额外返回一个自定义头,比如 X-File-Size,并在跨域时把它列进 Access-Control-Expose-Headers。想在本地把这一套跑通,可以先跟着 Node.js 教程 起一个最小的静态服务。服务端用 Node 写起来也就几行:

const express = require("express");
const fs = require("fs");
const app = express();

app.get("/download/:name", (req, res) => {
  const path = "./files/" + req.params.name;
  const stat = fs.statSync(path);
  res.setHeader("Content-Length", stat.size);
  res.setHeader("Access-Control-Expose-Headers", "Content-Length, X-File-Size");
  res.setHeader("X-File-Size", stat.size);
  fs.createReadStream(path).pipe(res);
});

预期结果:前端拿到的响应头里同时有 Content-Length 与 X-File-Size,两者一致。实践中的注意点是:为了传输效率开启 gzip 之后,Content-Length 会变成压缩后的大小,而 X-File-Size 保留的是原文件大小——此时应该拿 X-File-Size 当分母,并把 XHR 收到的 loaded 视为压缩字节数,两者相除会产生偏差,所以 gzip 场景下最诚实的做法是显示字节数而不是百分比。这段 Node 代码是口径说明,未在本机执行。

服务端在 Nginx 一侧也可以通过反向代理与静态资源配置影响这些响应头,相关写法在 Nginx 入门指南 里能查到,尤其是静态资源的 etag 与 gzip 配置那一节。最后补一句成本提醒:为了算百分比而额外发一次 HEAD 请求是常见的兜底做法,但会给每一次下载多加一次往返,做之前先算一下值不值。

动手核对清单

  1. 前置:准备任意一个本地静态服务(本机打算提供 http 与静态文件即可),放一个几十兆的文件进去用于测试。
  2. 命令:启动服务后记录它的地址,把浏览器开发者工具的网络面板打开。
  3. 运行:分别用第二、三节的两段代码各下载一次,观察回调参数。
  4. 检查:网络面板里确认响应头包含 Content-Length,界面上的数字能爬到 100。
  5. 预期:有 Content-Length 时百分比从 1 递增到 100;删掉该响应头后百分比为 null,只显示已下载字节数。
  6. 失败:百分比一直停在 0 或界面报错,多数是跨域未暴露该响应头,修复方式是在服务端加上 Access-Control-Expose-Headers。
  7. 验证:把服务端的压缩开关打开再跑一次,确认百分比行为仍然符合预期,至此才算闭环。

以上为本机的常规操作流程,未在本文写作环境实际执行,输出为预期结果。

总结

  • 下载进度百分比 = 已接收字节 ÷ 总字节,缺了分母就寸步难行。
  • 简单场景用 XMLHttpRequest 的 progress,判 lengthComputable,异常分支单独处理。
  • 需要更细控制时用 fetch 配 ReadableStream 自己累计,别忘判 res.body 是否存在。
  • Content-Length 缺失或经过压缩时,让服务端补一个自定义总大小头,界面上诚实显示字节数。
  • 超过几百兆的文件不要拼 Blob,直接交给浏览器原生下载更稳。

常见问题

Q:lengthComputable 一直是 false,一定是服务端没给 Content-Length 吗?

不一定,还有两种常见情况:请求跨域但服务端没有用 Access-Control-Expose-Headers 暴露该头,以及请求 mode 为 no-cors 导致前端连响应头都读不到。下载进度算不出百分比时先看这两处,往往比重写代码更快见效。跨域报文本身的含义与排查顺序,可以展开读 跨域常见报错解读 这条笔记。验证办法是打开浏览器开发者工具的网络面板,直接看响应头列表里有没有这一项。

Q:进度条走到 100% 之后为什么还要等很久?

因为 100% 只代表字节接收完毕,后面还有拼接 Blob、写临时文件、触发保存 dialog 这几步。大文件尤其明显。解决办法是把文案写成「接收完成,正在生成文件」,而不是停在「下载中 100%」。

Q:能不能用这种方式断点续传?

单独的前端做不到。续传依赖服务端支持 Range 请求头,前端只负责在请求头里带上 Range: bytes=xxx-,并且要把已下载的分片和剩余部分重新拼接。本文的三种写法都解决不了服务端的 Range 支持问题。

延伸学习

  • Web前端入门扫盲课程:把请求、响应、资源加载这条链路补齐的配套课程,遇到奇怪的网络现象可以跟一遍。
  • Blob文件下载怎么实现:专门讲 HTML5 里 Blob 下载的一条笔记,和第二节的落盘写法正好对上。
  • Web 标准详解:把响应头、请求方法这些属于 Web 标准的约定放在一条线上理解,读本文第一节会轻松很多。

0 人点赞