WEB多文件下载,技巧与实现

2,937 阅读2分钟

背景

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标签对于浏览器来说意味着页面跳转,无延迟多次触发跳转时,浏览器认为用户有了新的访问目标,因而取消了访问上一个页面的请求。

缺点:

  1. 浏览器会提示用户是否允许自动下载多个文件,需要用户手动确认
  2. 当下载文件数量过多且文件较小时,延时所用的时间占大过大。

还有种类似的下载方式,用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,清理掉对应的缓存
};

优点:

  1. 下载无延迟
  2. fetch方式下载,可计算下载进度展示到UI
  3. 下载过程可鉴权
  4. 可将已下载的文件存在indexDB中,防止已下载文件丢失

缺点:

  1. 浏览器对blob大小存在限制(与操作系统、磁盘和内存大小有关),可以借助indexDB突破这个限制(这个问题中浏览器限制blob为500MB,现代浏览器远远超出了这个数值)。
  2. 如果用到了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在瞎子方面还有很多优秀的能力

  1. 追加写入,指定位置写入
  2. 遍历下载目录,展示已下载文件
  3. 文件流写入,高速和低内存占用
  4. 下载文件状态可存储到indexDB,进度不丢失
  5. 可以设置下载文件的命名建议
  6. 存储文件到类型默认目录
  7. ...

在上面代码的基础上一个更加完整的例子:

// 读写权限获取
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("下载完成");
};

优点:

  1. 其他方案的优点
  2. 性能最好
  3. 基础实现简单
  4. 不生成额外归档文件

缺点:

  1. 兼容性捉急

结论

考虑兼容性,可以选择客户端生成归档文件。如果只考虑Chrome浏览器,可以尝试file-system-access,这也是未来视频剪辑,图像编辑的文件处理方向。

大家如果有其他方案,请多多讨论下。