背景
WEB端传统文件下载一般只适用于单文件下载,如果同时下载多个文件则一般需要借助客户端实现,如百度网盘,迅雷等。现讨论几种WEB端多文件下载方式的可行性。
方案
1. 延迟发起多个下载请求
function sleep(time = 1000) {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}
const files = ["1m.zip", "2m.zip", "3m.zip"];
const download1 = () => {
files.forEach(async (file, i) => {
// 延迟下载
await sleep(i * 1000);
const a = document.createElement("a");
a.href = `path/${file}`;
a.download = file;
a.click();
});
};
这里要着重强调下延时这个点。你可以尝试同时发起多个下载请求,最终结果却只能正确下载最后一个文件,其他文件则在DevTools中的Network中显示已取消错误。
原因在于这种下载方式采用
a
标签多次触发下载,a
标签对于浏览器来说意味着页面跳转,无延迟多次触发跳转时,浏览器认为用户有了新的访问目标,因而取消了访问上一个页面的请求。
缺点:
- 浏览器会提示用户是否允许自动下载多个文件,需要用户手动确认
- 当下载文件数量过多且文件较小时,延时所用的时间占大过大。
还有种类似的下载方式,用window.open(href, "_blank")
代替a
标签,这种方式同样缺点明显。首先浏览器默认会阻止弹出多个标签页,只能成功下载第一个文件(和a
标签正好相反);其次用户手动授权同意打开多个浏览器标签页,当下载文件过多时,一排的页面同时打开,体检将会很差。
2. 服务端生产归档文件
当拥有服务端操作权限时,可以让服务器端进行预处理,把所有将要下载的文件归档为一个zip包。WEB端按照单文件流程下载即可。由于是服务端操作,不在前端范畴内,不做讨论。
3. 客户端生成归档文件
当服务端不可控时,可以采用这种方式。
文件归档方面比较知名的库是:JSZip 。但是此次采用另一个库:client-zip 。相对于JSZip的优势在于部分逻辑由webassembly编写,采用流式读写,README介绍比JSZip快40倍并且代码体积更加小,但是需要注意其兼容性问题。
const download3 = async () => {
const responses = await Promise.all(files.map((file) => {
// 下载后可以将blob文件存在indexDB中
// 下载先检测indexDB中是否已经存在这个文件,避免重复下载
return fetch(`path/${file}`, { mode: "cors" });
}));
// 可自定处理错误
if (responses.some(response => {
return response.status === 401;
})) {
alert("未授权的下载请求");
return;
}
// 归档所有下载的文件
const blob = await downloadZip(responses).blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = "files.zip";
a.href = url;
a.click();
URL.revokeObjectURL(url);
// 如果存储了indexDB,清理掉对应的缓存
};
优点:
- 下载无延迟
- fetch方式下载,可计算下载进度展示到UI
- 下载过程可鉴权
- 可将已下载的文件存在indexDB中,防止已下载文件丢失
缺点:
- 浏览器对blob大小存在限制(与操作系统、磁盘和内存大小有关),可以借助indexDB突破这个限制(这个问题中浏览器限制blob为500MB,现代浏览器远远超出了这个数值)。
- 如果用到了indexDB,将占用额外缓存空间
4. file-system-access
使用新的原生文件api来处理文件,将会大大简化多文件下载流程。
const download4 = async () => {
// 可以将handler存储到indexDB,避免每次都请求权限
const dirHandle = await window.showDirectoryPicker();
const promises = files.map(async file => {
// 可以先遍历dirHandler中的文件,跳过已下载文件。也可以将已下载完成的文件储存在indexDB中,后续过滤使用。
const res = await fetch(`path/${file}`, { mode: "cors" });
// 创建新文件handler
const newFileHandle = await dirHandle.getFileHandle(file, { create: true });
const writable = await newFileHandle.createWritable();
// 管道流式存储
await res.body!.pipeTo(writable);
// 此处可以更新下载进度,展示到UI。
});
await Promise.all(promises);
alert("下载完成");
};
即使关闭浏览器,这种方案的下载进度也可以做到不丢失,此外file-system-access
在瞎子方面还有很多优秀的能力
- 追加写入,指定位置写入
- 遍历下载目录,展示已下载文件
- 文件流写入,高速和低内存占用
- 下载文件状态可存储到indexDB,进度不丢失
- 可以设置下载文件的命名建议
- 存储文件到类型默认目录
- ...
在上面代码的基础上一个更加完整的例子:
// 读写权限获取
async function verifyPermission(fileHandle, readWrite) {
const options = {};
if (readWrite) {
options.mode = "readwrite";
}
if ((await fileHandle.queryPermission(options)) === "granted") {
return true;
}
if ((await fileHandle.requestPermission(options)) === "granted") {
return true;
}
return false;
}
import { get, set } from "idb-keyval";
const download4 = async () => {
// 尝试从indexDB中获取目录handle
let dirHandle = await get("dirHandle");
if (!dirHandle) {
dirHandle = await window.showDirectoryPicker();
// 保存下载路径handle,避免每次都让用户选择
await set("dirHandle", dirHandle);
}
const hasPermission = await verifyPermission(dirHandle, true);
if (!hasPermission) {
alert("未授权读写");
return;
}
// 过滤已下载文件
for await (let entry of dirHandle.values()) {
const filename = entry.name;
let index = files.indexOf(filename);
if(index !== -1) {
console.log(`文件${filename}已下载`)
files.splice(index, 1);
}
}
const promises = files.map(async file => {
const res = await fetch(`path/${file}`, { mode: "cors" });
const newFileHandle = await dirHandle.getFileHandle(file, { create: true });
const writable = await newFileHandle.createWritable();
await res.body.pipeTo(writable);
// 此处可以更新下载进度,展示到UI。
});
await Promise.all(promises);
alert("下载完成");
};
优点:
- 其他方案的优点
- 性能最好
- 基础实现简单
- 不生成额外归档文件
缺点:
- 兼容性捉急
结论
考虑兼容性,可以选择客户端生成归档文件。如果只考虑Chrome浏览器,可以尝试file-system-access,这也是未来视频剪辑,图像编辑的文件处理方向。
大家如果有其他方案,请多多讨论下。