前端知库

大文件下载和流式下载怎样做?a[download]、Range 断点和 Service Worker 各适合什么场景?

文件已经在服务器上时,交给浏览器下载最省事:服务器返回 Content-Disposition: attachment,或者用同源的 a[download]。先 fetch 成 Blob 再下载,整个文件要先读进内存。断点续传靠 Range 和 If-Range,服务器返回 206 才能接着写。showSaveFilePicker 能让用户选保存位置,再把响应流 pipeTo 写进文件,但只有部分浏览器支持。Service Worker 可以拦截下载地址,返回一个流式的 Response,让浏览器边收边存。

参考答案

简答

比如用户要下载一个 2 GB 的导出包。最简单的做法是让浏览器自己下载:服务器响应带上 Content-Disposition: attachment,或者在同源链接上加 download 属性,数据不经过网页的 JavaScript。

常见的坑是先 fetch 再 response.blob(),然后用 object URL 触发下载。blob() 要把整个响应读完才返回,文件越大,占的内存越多,用户也要等全部下完才看到下载提示。所以只在需要请求头鉴权、或者文件是前端生成时才这样做,用完要 revokeObjectURL。

断点续传用 Range 请求:记下已经收到的字节数,下次发 Range: bytes=已收到-,同时带 If-Range 确认文件没变。服务器返回 206 就接着写,返回 200 说明拿到的是完整文件,只能从头开始。想边下边写磁盘,可以用 showSaveFilePicker 让用户选位置,把响应流 pipeTo 写进文件;也可以让 Service Worker 拦截下载地址,返回一个流式的 Response。

要注意,download 属性只对同源地址、同源 blob 和 data URL 生效;showSaveFilePicker 不是 Baseline,要由用户点击触发;Service Worker 要在 HTTPS 下注册,只控制作用域内的页面。

能让浏览器直接下载时,就别经过 JavaScript

服务器上已经有的文件,最好的下载方式是交给浏览器。MDN 介绍 Content-Disposition 时说,这个响应头决定内容是在浏览器里显示,还是作为附件下载到本地。写成 attachment; filename*=UTF-8''%E6%8A%A5%E8%A1%A8.zip 这样,还能给出带中文的文件名。

前端这边,<a> 的 download 属性让浏览器把链接当成下载处理,可以带一个建议的文件名。MDN 特别提醒,download 只对同源地址、同源的 blob: 和 data: 生效;其他地址会忽略这个属性,变成普通跳转,不过目标服务器仍然可以用 Content-Disposition 要求下载。所以文件放在其他域名的 CDN 上时,download 属性改不了文件名,要由 CDN 返回响应头。

fetch 成 Blob 再下载有什么问题

有时下载接口要在请求头里带 token,链接没法直接点。常见写法是:

JavaScript
const response = await fetch('/api/export', { headers: { Authorization: `Bearer ${token}` } });
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '报表.zip';
link.click();
URL.revokeObjectURL(url);

MDN 说明,response.blob() 会把响应流读到结束,才返回一个 Blob。所以整个文件下完以前,用户看不到浏览器的下载提示,文件内容也要先完整地读进来。小文件没问题,几个 GB 的文件就容易出问题。用完的 object URL 要调用 revokeObjectURL,MDN 的说法是让浏览器不再保留对这个文件的引用。object URL 的更多细节见《File、Blob 和 object URL 怎样用于文件预览?》。

断点续传怎样判断能不能接着写

断点续传的思路和《大文件上传怎样设计分片、断点续传、秒传和并发控制?》方向相反:上传是客户端记住传了多少,下载是客户端记住收了多少,再向服务器要剩下的部分。

MDN 的 Range 指南给出了判断依据:响应带 Accept-Ranges 且值不是 none,说明服务器支持范围请求;请求时带上 If-Range,条件满足就返回 206 Partial Content 和对应的那一段,不满足就返回 200 OK 和完整内容。Content-Range 告诉你这一段在整个文件里的位置。下面把这几条判断写成函数:

JavaScript
// 根据已下载的字节数生成续传请求头。
function resumeHeaders(received, etag) {
  return { Range: `bytes=${received}-`, 'If-Range': etag };
}
console.log(resumeHeaders(1048576, '"v1"').Range); // bytes=1048576-

// 解析 Content-Range,确认服务器返回的是哪一段。
function parseContentRange(value) {
  const match = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(value);
  if (!match) return null;
  const [, start, end, total] = match;
  return { start: Number(start), end: Number(end), total: total === '*' ? null : Number(total) };
}
const range = parseContentRange('bytes 1048576-2097151/5242880');
console.log(range.end - range.start + 1); // 1048576
console.log(range.total); // 5242880

// 决定怎样处理续传响应:206 接着写,200 说明文件变了或不支持 Range,只能从头开始。
function nextStep(status, contentRange, received) {
  if (status === 200) return 'restart';
  if (status !== 206) return 'fail';
  const info = parseContentRange(contentRange);
  return info && info.start === received ? 'append' : 'restart';
}
console.log(nextStep(206, 'bytes 1048576-2097151/5242880', 1048576)); // append
console.log(nextStep(200, null, 1048576)); // restart

bytes=1048576- 的意思是从第 1048576 个字节一直要到结尾。Content-Range 里的两个数字都包含在范围内,所以这一段的长度要加 1,正好是 1 MB。

为什么收到 200 不能接着写?因为 200 带的是完整文件,可能是服务器不支持 Range,也可能是 If-Range 发现文件已经变了。把它拼到旧数据后面,得到的就是一个损坏的文件。所以续传时要带 If-Range,MDN 说明它可以配合 ETag 或 Last-Modified 使用,但两者不能同时用。Range 请求的更多写法见《HTTP Range 请求适合什么场景?》。

怎样边下载边写到磁盘

要避免把整个文件读进内存,就得一边收数据一边写出去。File System Access API 能做到:showSaveFilePicker() 弹出保存对话框,用户选好位置后拿到文件句柄,再用 createWritable() 得到一个可写流。fetch 返回的 response.body 是可读流,用 pipeTo 接上:

JavaScript
saveButton.addEventListener('click', async () => {
  const handle = await window.showSaveFilePicker({ suggestedName: '报表.zip' });
  const writable = await handle.createWritable();
  const response = await fetch('/api/export');
  await response.body.pipeTo(writable);
});

pipeTo 返回一个 Promise,传输完成时兑现,出错时拒绝;默认情况下,可读流结束会把目标可写流也关掉。MDN 说明,写入的内容要等流关闭以后才会出现在文件里,浏览器一般先写临时文件,关闭时再替换。

为什么要写在点击事件里?MDN 列出的异常里有一条:调用不是由按钮点击这类用户交互触发时,会抛出 SecurityError。用户取消对话框时,会抛出 AbortError。更大的限制是兼容性,MDN 标注这个方法不是 Baseline,在一些主流浏览器里不能用,还只能在 HTTPS 下使用,所以要准备降级方案。

它也能和断点续传结合:createWritable({ keepExistingData: true }) 会先把已有内容复制到临时文件,write() 可以用 position 指定从哪个字节开始写。权限和句柄的管理见《File System Access API 的权限和文件句柄怎样管理?》。

Service Worker 怎样做流式下载

另一种思路是让浏览器的下载管理器写磁盘,数据由网页提供。Service Worker 能拦截作用域内的请求,MDN 说明 respondWith() 会阻止浏览器默认的请求处理,改由你提供 Response。而 Response 的构造函数接受 ReadableStream 作为响应体。两者组合起来:

JavaScript
// sw.js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.pathname !== '/download/report.zip') return;
  // createReportStream 返回一个 ReadableStream,数据由页面通过 postMessage 分块送进来
  const stream = createReportStream();
  event.respondWith(
    new Response(stream, {
      headers: {
        'Content-Type': 'application/zip',
        'Content-Disposition': 'attachment; filename="report.zip"',
      },
    }),
  );
});

页面跳转到 /download/report.zip,请求被 Service Worker 截住,返回的响应带 attachment,浏览器就把它当成下载,边从流里取数据边写盘。这适合前端生成的大文件,比如边打包边下载的 zip,数据一块块送进流,不用先拼成一个完整的 Blob。

限制也要说清楚。MDN 写明注册 Service Worker 需要安全上下文(HTTPS);它激活后只控制作用域内的页面,已经打开的页面要重新加载才受控制。生命周期的细节见《Service Worker 的安装、激活与缓存更新有哪些坑?》。

常见追问

自己用 fetch 下载时,怎样显示进度?

用 response.body.getReader() 一块块读,累加已经收到的字节数,再除以 Content-Length 算出百分比。响应没有 Content-Length 时拿不到总长度,只能显示已下载多少。交给浏览器下载时,进度在浏览器的下载栏里,网页拿不到。

跨域的文件为什么设了 download 属性还是在新页面打开?

MDN 说明 download 只对同源地址、同源 blob 和 data URL 生效,跨域地址会忽略它,当成普通跳转。要么让文件服务器返回 Content-Disposition: attachment,要么在 CORS 允许的前提下先 fetch 成 Blob 再下载,后者要注意内存。

带 token 的下载接口,怎样避免把文件读进内存?

可以让后端先签发一个短期有效的下载地址,前端拿到后直接跳转,这样请求不用带请求头,又能交给浏览器的下载管理器。浏览器支持时,也可以用 showSaveFilePicker 加 pipeTo,fetch 照样能带请求头。