直接请求静态文件 URL
思路
后端
接口返回静态文件的 URL
前端
使用 window.open 根据 URL 打开新页面,浏览器自动下载文件
代码
const downloadURL = async () => {
const res = await fetch(`/rest/download/videoURL`);
const { url } = await res.json(); // 响应头格式为:{ url: '/rest/download/test.wmv'}
window.open(url);
};
优点
前后端实现简单
缺点
无法做文件的权限控制
暴露了文件存储的位置,有安全风险
使用场景
用于动态返回一些不需要做权限控制的静态资源 URL
文件内容以流的形式返回,通过 JS 下载文件
后端
响应头带上 content-type: application/octet-stream 表示以流的形式返回。
transfer-encoding: chunked 表示分块传输。
文件名用 Content-Disposition 响应头来设置。
把文件内容以 stream 的方式写入响应体中。
前端
从 Content-Disposition 响应头中获取文件名。
把响应体内容保存成文件。
保存文件有 2 种方式
方式一:等文件内容返回完全后,再一次性保存到文件中
思路
通过 axios 或者 fetch 等 API,返回响应的 promise,等 promise resolve 了,就可以获取到整个文件的内容,文件内容为 Blob 或者 ArrayBuffer 类型。通过 file-saver 等工具库,以 URL.createObjectURL 的方式,把 Blob 或者 ArrayBuffer 对象保存到文件中。
代码
import { saveAs } from "file-saver";
const downloadByFileSaver = async () => {
const res = await fetch(`/rest/download/video`);
const blob = await res.blob();
saveAs(blob, "video-file-saver.mp4");
};
优点
逻辑简单,绝大多数场景下适用。
缺点
必须等待整个文件的内容返回完毕,才能开始创建文件。那么,如果文件内容很大,就会带来 2 个问题:
- 页面上表现为:等待很久,浏览器才提示文件保存成功。
Blob的大小是有限制的。不同浏览器对 Blob 对象的大小限制不同。(见:github.com/eligrey/Fil…)文件大小超过Blob的限制,会无法保存。- 文件内容一直储存在内存中,导致浏览器占用内存过多,可能会 导致
OOM问题。
使用场景
文件大小不超过 2GB 时可用(以 Chrome 为例)
方式二:流式下载文件
2.1 使用原生 API 来保存单个文件。
思路
我们知道,文件内容在后端是通过 stream 的方式返回的,那么,前端能不能也通过 stream 的方式来写文件呢?也就是,HTTP 请求一边返回内容,浏览器一边写入文件。
要实现这个效果,关键就在于前端如何以 stream 的方式写文件。
找了下 API,发现结合使用 showSaveFilePicker 和 createWritable 可以创建一个 WritableStream,再把 fetch 返回的 ReadableStream 使用 pipeTo 连接到 WritableStream ,即可实现流式下载。
注:
fetch返回的body是一个ReadableStream对象,可以使用StreamAPI 来流式处理。- 当
HTTP响应体是stream时,fetch返回的promise不会等到所有内容都返回完毕才resolve,相反,当接收到第一个响应分片时,promise就resolve了。所以可以使用response.body获取到响应体的stream,流式处理。 - 由于
axios在浏览器端用XHR来发起请求,而XHR不支持流式传输数据,所以,浏览器端不能用axios实现流式下载(NodeJS服务端可以)。
代码
const downloadStreamSingle = async () => {
const res = await fetch(`/rest/download/video`);
await saveFile(res.body);
};
async function saveFile(readableStream) {
try {
const newHandle = await window.showSaveFilePicker();
const writableStream = await newHandle.createWritable();
await readableStream.pipeTo(writableStream);
} catch (err) {
console.error(err);
}
}
优点
使用 JS 原生的 API,无需引入 npm 包。 接口一开始返回内容,浏览器就开始下载,用户能更快感知到下载动作,用户体验好。
缺点
由于创建 WritableStream 是通过 showSaveFilePicker 和 createWritable实现的,而 showSaveFilePicker 会导致浏览器自动弹出文件保存对话框,让用户确认,所以,无法做到批量下载多个文件。
如果有多个文件需要下载,如何实现呢?我们来看第二种方案。
2.2 使用 StreamSaver 来保存单个/多个文件
思路
StreamSaver 实现大文件下载的逻辑是:
创建一个 ServiceWorker 来做本地代理服务器。
接口返回的内容被 ServiceWorker 拦截,加上 Content-Disposition 和 transfer-encoding: chunked 响应头。
前端和 ServiceWorker 之间通过 postMessage 来通信
通过创建的 TransformStream 或者 WritableStream 来实现流式下载
代码
根据 StreamSaver 的官方文档,很容易写出以下代码
保存单个文件
import streamSaver from "streamsaver";
const fileSaveStream = async () => {
const res = await fetch(`/rest/download/video`, {
method: "get",
});
const fileStream = streamSaver.createWriteStream("test.mp4");
try {
await res.body.pipeTo(fileStream);
} catch (err) {
console.error(err);
}
};
保存多个文件
const downloadStreamMulti = async () => {
const urls = ["/rest/download/video", "/rest/download/video2"];
const resArr = await Promise.all(urls.map((item) => fetch(item)));
const filenames = ["one.mp4", "two.mp4"];
resArr.forEach((res, index) => {
const fileStream = streamSaver.createWriteStream(filenames[index]);
res.body.pipeTo(fileStream);
});
};
如何把多个大文件打成一个压缩包下载?
可能的方案
方案 1:后端直接创建 zip 压缩包并返回
zip 包的大小超出 Blob 的最大限制时会导致文件损坏,此方案不可行。
方案 2:前端并行请求几个文件,把数据全部放内存中,再一次性压缩
内存是有限的,全部放内存中可能导致内存耗尽。此方案不可行。
方案 3:前端并行请求几个文件,流式压缩打包
流式处理不会使用过多内存,此方案核心在于如何做到流式压缩打包。
StreamSaver 提供了 ZipStream 的实现,可以做到流式压缩打包。此方案可行。
思路
创建一个 ZipStream,在内部实现并行下载多个大文件。
将 ZipStream 连接到 WritableStream
这里的 ZipStream 直接引用了 StreamSaver 的实现。
代码
import "streamsaver/examples/zip-stream";
const downloadStreamZip = async () => {
try {
const newHandle = await window.showSaveFilePicker();
const writableStream = await newHandle.createWritable();
const zipStream = getZipStream();
await zipStream.pipeTo(writableStream);
} catch (err) {
console.error(err);
}
};
const getZipStream = () => {
const zipStream = new window.ZIP({
start(ctrl) {},
async pull(ctrl) {
const url = "/rest/download/video";
const url2 = "/rest/download/video2";
const [res2, res] = await Promise.all(
[url2, url].map((item) => fetch(item))
);
const stream = () => res.body;
const name = "streamsaver-zip-example/test.wmv";
ctrl.enqueue({ name, stream });
const stream2 = () => res2.body;
const name2 = "streamsaver-zip-example/video2.mp4";
ctrl.enqueue({ name: name2, stream: stream2 });
ctrl.close();
},
});
return zipStream;
};
优点
前端打包压缩,避免了 Blob 的大小限制。
实现逻辑简单。
缺点
需要引入 StreamSaver
由于创建 WritableStream 是通过 showSaveFilePicker 和 createWritable实现的,而 showSaveFilePicker 会导致浏览器自动弹出文件保存对话框,让用户确认,所以,无法做到批量下载多个文件。
方案 4:StreamSaver+ZipStream 实现流式压缩打包下载
此方案是方案 3 的优化,把创建 WritableStream 也交给 StreamSaver 来实现。
代码
import streamSaver from "streamsaver";
import "streamsaver/examples/zip-stream";
const downloadStreamSaverZip = async () => {
const writableStream = streamSaver.createWriteStream("test.zip");
const zipStream = getZipStream();
try {
await zipStream.pipeTo(writableStream);
} catch (err) {
console.error(err);
}
};
const getZipStream = () => {
const zipStream = new window.ZIP({
start(ctrl) {},
async pull(ctrl) {
const url = "/rest/download/video";
const url2 = "/rest/download/video2";
const [res2, res] = await Promise.all(
[url2, url].map((item) => fetch(item))
);
const stream = () => res.body;
const name = "streamsaver-zip-example/test.wmv";
ctrl.enqueue({ name, stream });
const stream2 = () => res2.body;
const name2 = "streamsaver-zip-example/video2.mp4";
ctrl.enqueue({ name: name2, stream: stream2 });
ctrl.close();
},
});
return zipStream;
};
优点
使用简单
缺点
StreamSaver需要依赖mitm.html,而这个文件,默认是请求 Github 上面的资源,才能得到。对于内网的场景,需要修改streamSaver的mitm属性,指向内网环境的mitm.html。- 由于使用了
ServiceWorker,所以必须在 HTTPS 环境下使用,HTTP 环境下不可用。