前端下载文件的5种方式

1,485 阅读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,发现结合使用 showSaveFilePicker 和 createWritable 可以创建一个 WritableStream,再把 fetch 返回的 ReadableStream 使用 pipeTo 连接到 WritableStream ,即可实现流式下载。

注:

  1. fetch 返回的 body 是一个 ReadableStream 对象,可以使用 Stream API 来流式处理。
  2. 当 HTTP 响应体是 stream 时,fetch 返回的 promise 不会等到所有内容都返回完毕才 resolve,相反,当接收到第一个响应分片时,promise 就 resolve 了。所以可以使用 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 是通过 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;
};

优点

使用简单

缺点

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