前端实现大文件下载的终极解决方案!!!

介绍两种大文件下载方式,并分析其中的优缺点

1.传统的大文件下载方式

  • 介绍

后端进行文件的异步下载,前端需要做一个类似的下载中心的页面,点击下载文件,然后等待后端异步下载后返回一个下载链接,前端通过这个链接下载文件。

  • 缺点 这种方式最简单直接,但是有一个致命的问题是非常消耗服务器资源,所以不推荐这样做

2.前端后端做 流 + 分片下载

思路

这里实现的思路就是 咱们大文件通常存在存储服务器,比如 minio,会提供一个 url 的地址,如果咱们的文件是一个超大文件,那么我们如果直接拿到 url 地址进行前端创建创建 Blob 对象,然后a标签下载,那么出现前端页面点击了下载按钮,但是长时间浏览器没有反应,因为这种情况,浏览器会将 blob 下载到浏览器的内存中,所以会有很大问题是,可能会造成浏览器卡顿和崩溃。

那么我们需要借鉴分片上传的思路,用分片的思维去进行下载:

后端提供响应头content-range返回分片范围,响应体返回当前分片的可读流

前端请求头发送Range字段,提供当前分片的范围,然后读取可读流,然后创建 blob,利用 URL.createObjectURL(blob)用 a 标签下载

后端实现,这里使用 koa 进行

步骤

  1. 后端需要拿到文件路径然后通过前端发来的分片请求头中的 Range 字段,去获取每次分片的开始和结束位置
  2. 如果分片的范围正确 就返回状态码206 并 创建响应头的content-range ,如 content-range:bytes 0-10485759/60020262 将文件的分片范围和文件总大小返回
  3. 紧接着将分片范围的文件利用可读流createReadStream的形式返回到响应体body

关键代码

javascript 复制代码
// 下载文件分片
router.get("/down/:name", async (ctx) => {
  try {
    const fileName = ctx.params.name;
    // 获取文件的路径和文件的大小
    const filePath = path.resolve(__dirname, DOWNLOAD_DIR, fileName);
    const size = fs.statSync(filePath).size || 0;
    // 获取请求头的 Range 字段
    //range:bytes=0-10485759
    const range = ctx.headers["range"];
    console.log({ range });
    //没有 Range 字段, 则不使用分片下载, 直接传输文件
    if (!range) {
      ctx.set({
        "Content-Disposition": `attachment; filename=${fileName}`,
      });
      ctx.response.type = "text/xml";
      //   https://developer.mozilla.org/zh-CN/docs/Web/API/ReadableStream
      ctx.response.body = fs.createReadStream(filePath);
    } else {
      // 获取分片的开始和结束位置
      const bytesRange = range.split("=")[1];
      let [start, end] = bytesRange.split("-");
      start = Number(start);
      end = Number(end);

      // 分片范围错误
      if (start > size || end > size) {
        ctx.set({ "Content-Range": `bytes */${size}` });
        ctx.status = 416;
        ctx.body = {
          code: 416,
          msg: "Range 参数错误",
        };
        return;
      }

      // 开始下载分片
      //   content-range:bytes 0-10485759/60020262
      ctx.status = 206;
      ctx.set({
        "Accept-Ranges": "bytes",
        "Content-Range": `bytes ${start}-${end ? end : size}/${size}`,
      });

      ctx.response.type = "text/xml";
      ctx.response.body = fs.createReadStream(filePath, { start, end });
    }
  } catch (error) {
    console.log({ error });
    ctx.body = {
      code: 500,
      msg: error.message,
    };
  }
});

前端实现 vue

步骤

  1. 前端首先需要明确分片CHUNK_SIZE的大小,这里以 1MB 为例。
  2. 前端需要从后端拿到文件总大小,然后根据总大小和分片大小计算出分片数量chunksCount
  3. 紧接着需要思考,如果分片量大,需要做一个请求队列,限制请求的并发数量,然后发送请求,拿到每一个分片的可读流,最后组装成 Blob 对象,然后创建临时 url 标签,利用 a 标签进行下载

部分代码

javascript 复制代码
const CHUNK_SIZE = 10 * 1024 * 1024; // 一个分片10MB

async function asyncPool(poolLimit, array, iteratorFn) {
  const allTask = []; // 存储所有的异步任务
  const executing = []; // 存储正在执行的异步任务
  for (const item of array) {
    // 调用 iteratorFn 函数创建异步任务
    const p = Promise.resolve().then(() => iteratorFn(item, array));
    allTask.push(p); // 保存新的异步任务

    // 当 poolLimit 值小于或等于总任务个数时,进行并发控制
    if (poolLimit <= array.length) {
      // 当任务完成后,从正在执行的任务数组中移除已完成的任务
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e); // 保存正在执行的异步任务
      if (executing.length >= poolLimit) {
        await Promise.race(executing); // 等待较快的任务执行完成
      }
    }
  }
  return Promise.all(allTask);
}

const saveFile = (name, buffers, mime = "application/octet-stream") => {
  const blob = new Blob([buffers], { type: mime });
  const blobUrl = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.download = name;
  a.href = blobUrl;
  a.click();
  URL.revokeObjectURL(blob);
};


   // 获取待下载文件的大小
    async getFileSize(name = this.fileName) {
      try {
        const res = await http.get(`/size/${name}`);
        this.fileSize = res.data.data;
        return res.data.data;
      } catch (error) {
        console.log({ error });
      }
    },
    /**
     * 下载分片内容
     * @param {*} start
     * @param {*} end
     * @param {*} i
     * @param {*} ifRange
     */
    async getBinaryContent(start, end, i, ifRange = true) {
      try {
        let options = {
          responseType: "blob",
        };
        if (ifRange) {
          options.headers = {
            Range: `bytes=${start}-${end}`,
          };
        }
        const result = await http.get(`/down/${this.fileName}`, options);
        return { index: i, data: result };
      } catch (error) {
        return {};
      }
    },
    // 分片下载
    async onDownload() {
      try {
        const fileSize = await this.getFileSize(this.fileName);
        const chunksCount = Math.ceil(fileSize / CHUNK_SIZE);

        const results = await asyncPool(
          3,
          [...new Array(chunksCount).keys()],
          (i) => {
            const start = i * CHUNK_SIZE;
            const end =
              i + 1 === chunksCount ? fileSize : (i + 1) * CHUNK_SIZE - 1;
            return this.getBinaryContent(start, end, i);
          }
        );
        results.sort((a, b) => a.index - b.index);
        const buffers = new Blob(results.map((r) => r.data.data));
        saveFile(this.fileName, buffers);
      } catch (error) {
        console.log({ error });
      }
    },
    // 单文件直接下载
    async onDownloadSingle() {
      try {
        const res = await this.getBinaryContent(0, 0, 0, false);
        const buffers = new Blob([res.data.data]);
        saveFile(this.fileName, buffers);
      } catch (error) {
        console.log({ error });
      }
    },

3.前端直接借助 streamsaver + fetch 实现大文件下载

思路:

利用streamsaver创建一个可写流,然后利用fetch流式读取的特性,拿到一点,然后写入磁盘,直到读取完成。

实现:

javascript 复制代码
const handleDownLoad = (val: any) => {
  const url = val.filePath;
  const filename = `${val.filename}.las`; //文件名+格式
  const fileStream = window.streamSaver.createWriteStream(filename); //可写流
  // 获取数据流
  fetch(url)
    .then(async (res: any) => {
      //  读取数据块
      const writer = fileStream.getWriter();
      const reader = res.body.getReader();
      // 文件大小 注意转为数字
      const contentLength = +res.headers.get("Content-Length");
      let receivedLength = 0; // 已下载的字节数
      // 在 body 下载时,一直为无限循环
      while (true) {
        // 当最后一块下载完成时,done 值为 true
        // value 是块字节的 Uint8Array
        const { done, value } = await reader.read();
        if (done) break;
        receivedLength += value.length; // 累加已下载的字节数
        let progress = Math.round((receivedLength / contentLength) * 100);
        console.log(`Progress: ${progress}%`, value.length);
        await writer.write(value); //数据逐块写入文件
      }
      await writer.close(); // 关闭文件写入流 触发浏览器弹出文件保存对话框。
      console.log("Download complete");
    })
    .catch(() => {});
};

各自的优缺点

  1. 后端异步下载:

优缺点:

  1. 耗费性能,相对粗暴,不适合并发量大的情况

  2. 前后端利用 流 + 分片下载的思路

优点:

  1. 能够做下载的暂停和继续和取消
  2. 能做下载的进度条

缺点:

  1. 相对复杂,需要前后端一起配合

  2. 这种方式也是前端截取流,然后拼接成 Blob,这个过程都是存在浏览器内存的,比较耗费内存

  3. streamsaver + fetch 实现大文件下载

优点:

  1. 只需要前端即可实现,代码量小
  2. 可以做下载的取消
  3. 不占用浏览器内存
  4. 能做下载的进度条

缺点:

  1. 不能做下载的暂停和继续,因为流没办法做到
相关推荐
kingwebo'sZone2 分钟前
C#使用Aspose.Words把 word转成图片
前端·c#·word
Serene_Dream21 分钟前
JVM 并发 GC - 三色标记
jvm·面试
xjt_090122 分钟前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农33 分钟前
Vue 2.3
前端·javascript·vue.js
夜郎king1 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳1 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵2 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星2 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_2 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js
未来龙皇小蓝2 小时前
RBAC前端架构-01:项目初始化
前端·架构