前端下载文件的5种方式

1,282 阅读6分钟

直接请求静态文件 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 个问题:

  1. 页面上表现为:等待很久,浏览器才提示文件保存成功。
  2. Blob 的大小是有限制的。不同浏览器对 Blob 对象的大小限制不同。(见:github.com/eligrey/Fil…)文件大小超过 Blob 的限制,会无法保存。
  3. 文件内容一直储存在内存中,导致浏览器占用内存过多,可能会 导致 OOM 问题。

使用场景

文件大小不超过 2GB 时可用(以 Chrome 为例)

方式二:流式下载文件

2.1 使用原生 API 来保存单个文件。

思路

我们知道,文件内容在后端是通过 stream 的方式返回的,那么,前端能不能也通过 stream 的方式来写文件呢?也就是,HTTP 请求一边返回内容,浏览器一边写入文件。 要实现这个效果,关键就在于前端如何以 stream 的方式写文件。 找了下 API,发现结合使用 showSaveFilePickercreateWritable 可以创建一个 WritableStream,再把 fetch 返回的 ReadableStream 使用 pipeTo 连接到 WritableStream ,即可实现流式下载。

注:

  1. fetch 返回的 body 是一个 ReadableStream 对象,可以使用 Stream API 来流式处理。
  2. HTTP 响应体是 stream 时,fetch 返回的 promise 不会等到所有内容都返回完毕才 resolve,相反,当接收到第一个响应分片时,promiseresolve 了。所以可以使用 response.body 获取到响应体的 stream,流式处理。
  3. 由于 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 是通过 showSaveFilePickercreateWritable实现的,而 showSaveFilePicker 会导致浏览器自动弹出文件保存对话框,让用户确认,所以,无法做到批量下载多个文件

如果有多个文件需要下载,如何实现呢?我们来看第二种方案。

2.2 使用 StreamSaver 来保存单个/多个文件

思路

StreamSaver 实现大文件下载的逻辑是: 创建一个 ServiceWorker 来做本地代理服务器。 接口返回的内容被 ServiceWorker 拦截,加上 Content-Dispositiontransfer-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 是通过 showSaveFilePickercreateWritable实现的,而 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;
};

优点

使用简单

缺点

  1. StreamSaver 需要依赖 mitm.html,而这个文件,默认是请求 Github 上面的资源,才能得到。对于内网的场景,需要修改 streamSavermitm 属性,指向内网环境的mitm.html
  2. 由于使用了 ServiceWorker,所以必须在 HTTPS 环境下使用,HTTP 环境下不可用。