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

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

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. 不能做下载的暂停和继续,因为流没办法做到
相关推荐
申朝先生4 分钟前
JS:什么是闭包,以及它的应用场景和缺点是什么?
开发语言·javascript·ecmascript
念九_ysl7 分钟前
暴力搜索算法详解与TypeScript实战
javascript·算法
uhakadotcom8 分钟前
解锁 Google Cloud Pub/Sub 的强大功能
后端·面试·github
beibeibeiooo28 分钟前
【CSS3】02-选择器 + CSS特性 + 背景属性 + 显示模式
前端·css·css3
uhakadotcom1 小时前
Vulkan API 入门指南:跨平台、高性能的图形和计算解决方案
后端·面试·github
uhakadotcom1 小时前
Meta Horizon OS 开发工具:打造更好的 MR/VR 体验
javascript·后端·面试
helbyYoung1 小时前
【零基础JavaScript入门 | Day7】三大交互案例深度解析|从DOM操作到组件化开发
开发语言·javascript
uhakadotcom2 小时前
刚刚发布的React 19.1提供了什么新能力?
前端·javascript·面试
uhakadotcom2 小时前
Rust中的reqwest库:轻松实现HTTP请求
后端·面试·github
uhakadotcom2 小时前
Expo 简介:跨平台移动应用开发的强大工具
前端·javascript·面试