前端大文件分片上传解决方案(看这一篇就够了)

前端 0

文章目录

    • 概要
    • 整体架构流程
    • 具体实现
      • 一,分片下载之本地储存(localForage)
      • 前言:
      • 二,本地数据获取(获取下载列表)
      • 二,操作JS
        • 1,下载进度监听
        • 2,文件下载状态控制
        • 3,分片下载
      • 二,VUE页面
    • 小结

概要

本文从前端方面出发实现浏览器下载大文件的功能。不考虑网络异常、关闭网页等原因造成传输中断的情况。分片下载采用串行方式(并行下载需要对切片计算hash,比对hash,丢失重传,合并chunks的时候需要按顺序合并等,很麻烦。对传输速度有追求的,并且在带宽允许的情况下可以做并行分片下载)

整体架构流程

1, 使用分片下载: 将大文件分割成多个小块进行下载,可以降低内存占用和网络传输中断的风险。这样可以避免一次性下载整个大文件造成的性能问题。

2, 断点续传: 实现断点续传功能,即在下载中途中断后,可以从已下载的部分继续下载,而不需要重新下载整个文件。

3, 进度条显示: 在页面上展示下载进度,让用户清晰地看到文件下载的进度。如果一次全部下载可以从process中直接拿到参数计算得出(很精细),如果是分片下载,也是计算已下载的和总大小,只不过已下载的会成片成片的增加(不是很精细)。

4, 取消下载和暂停下载功能: 提供取消下载和暂停下载的按钮,让用户可以根据需要中止或暂停下载过程。

5, 合并文件: 下载完成后,将所有分片文件合并成一个完整的文件。

具体实现

一,分片下载之本地储存(localForage)

前言:

浏览器的安全策略禁止网页(JS)直接访问和操作用户计算机上的文件系统。
在分片下载过程中,每个下载的文件块(chunk)都需要在客户端进行缓存或存储,方便实现断点续传功能,同时也方便后续将这些文件块合并成完整的文件。这些文件块可以暂时保存在内存中或者存储在客户端的本地存储(如 IndexedDB、LocalStorage 等)中。
使用封装,直接上代码

import axios from 'axios';import ElementUI from 'element-ui'import Vue from 'vue'import localForage from 'localforage'import streamSaver from 'streamsaver';import store from "@/store/index"/** * localforage–是一个高效而强大的离线存储方案。 * 它封装了IndexedDB, WebSQL, or localStorage,并且提供了一个简化的类似localStorage的API。 * 在默认情况下会优先采用使用IndexDB、WebSQL、localStorage进行后台存储, * 即浏览器不支持IndexDB时尝试采用WebSQL,既不支持IndexDB又不支持WebSQL时采用 * localStorage来进行存储。 *  *//** * @description 创建数据库实例,创建并返回一个 localForage 的新实例。每个实例对象都有独立的数据库,而不会影响到其他实例 * @param {Object} dataBase 数据库名 * @return {Object} storeName 实例(仓库实例) */function createInstance(dataBase){  return localForage.createInstance({    name: dataBase  });}/** * @description 保存数据 * @param {Object} name 键名 * @param {Object} value 数据 * @param {Object} storeName 仓库 */async function saveData(name, value, storeName){  await storeName.setItem(name, value).then(() => {    // 当值被存储后,可执行其他操作    console.log("save success");  }).catch(err => {    // 当出错时,此处代码运行    console.log(err)  })}/** * @description 获取保存的数据 * @param {Object} name 键名 * @param {Object} storeName 仓库 */async function getData(name, storeName, callback){  await storeName.getItem(name).then((val) => {    // 获取到值后,可执行其他操作    callback(val);  }).catch(err => {    // 当出错时,此处代码运行    callback(false);  })}/** * @description 移除保存的数据 * @param {Object} name 键名 * @param {Object} storeName 仓库 */async function removeData(name, storeName){  await storeName.removeItem(name).then(() => {    console.log('Key is cleared!');  }).catch(err => {    // 当出错时,此处代码运行    console.log(err);  })}/** * @description 删除数据仓库,将删除指定的 “数据库”(及其所有数据仓库)。 * @param {Object} dataBase 数据库名 */async function dropDataBase(dataBase){  await localForage.dropInstance({    name: dataBase  }).then(() => {     console.log('Dropped ' + dataBase + ' database');  }).catch(err => {    // 当出错时,此处代码运行    console.log(err);  })}/** * @description 删除数据仓库 * @param {Object} dataBase 数据库名 * @param {Object} storeName 仓库名(实例) */async function dropDataBaseNew(dataBase, storeName){  await localForage.dropInstance({    name: dataBase,    storeName: storeName  }).then(() => {     console.log('Dropped',storeName)  }).catch(err => {    // 当出错时,此处代码运行    console.log(err);  })}

二,本地数据获取(获取下载列表)

/** * @description 获取下载列表 * @param {String} page 页面 * @param {String} user 用户 */export async function getDownloadList(page, user, callback){  // const key = user + "_" + page + "_";  // const key = user + "_";  const key = "_"; // 因为用户会过期 所以不保存用户名  直接以页面为键即可  // 基础数据库  const baseDataBase = createInstance(baseDataBaseName);  await baseDataBase.keys().then(async function(keys) {      // 包含所有 key 名的数组      let fileList = [];      for(let i = 0; i < keys.length; i++){        if(keys[i].indexOf(key)>-1){          // 获取数据          await getData(keys[i], baseDataBase, async (res) =>{            fileList.push(              {                'fileName': res, // 文件名                'dataInstance': keys[i] // 文件对应数据库实例名              }            );          })        }      }      // 获取进度      for(let i = 0; i < fileList.length; i++){        const dataBase = createInstance(fileList[i].dataInstance);        await getData(progressKey, dataBase,  async (progress) => {          if(progress){            fileList[i].fileSize = progress.fileSize;            fileList[i].progress = progress.progress ? progress.progress : 0;            fileList[i].status = progress.status ? progress.status : "stopped";            fileList[i].url = progress.url;          }        });      }      callback(fileList);  }).catch(function(err) {      // 当出错时,此处代码运行      callback(err);  });}

二,操作JS

1,下载进度监听
/** * 文件下载进度监听 */const onDownloadProgres = (progress) =>{  // progress对象中的loaded表示已经下载的数量,total表示总数量,这里计算出百分比  let downProgress = Math.round(100 * progress.loaded / progress.total);   store.commit('setProgress', {      dataInstance: uniSign,      fileName: data.downLoad,      progress: downProgress,      status: downProgress == 100 ? 'success' : 'downloading',      cancel: cancel  })}
2,文件下载状态控制
// 数据库进度数据主键const progressKey = "progress";/** * @description 状态控制 * @param {String} fileName 文件名 * @param {String} type 下载方式 continue:续传  again:重新下载  cancel:取消 * @param {String} dataBaseName 数据库名 每个文件唯一 *  */async function controlFile(fileName, type, dataBaseName){  if(type == 'continue'){      } else if(type == 'again'){    // 删除文件数据    await dropDataBase(dataBaseName);    // 基础数据库    const baseDataBase = createInstance(baseDataBaseName);    // 基础数据库删除数据库实例    removeData(dataBaseName, baseDataBase);  } else if(type == 'cancel'){    // 删除文件数据    await dropDataBase(dataBaseName);    // 基础数据库    const baseDataBase = createInstance(baseDataBaseName);    // 基础数据库删除数据库实例    await removeData(dataBaseName, baseDataBase);    store.commit('delProgress', {        dataInstance: dataBaseName    })  } else if(type == 'stop'){    store.commit('setProgress', {        dataInstance: dataBaseName,        status: 'stopped',    })  }}
3,分片下载
/** * @description 分片下载 * @param {String} fileName 文件名 * @param {String} url 下载地址 * @param {String} dataBaseName 数据库名 每个文件唯一 * @param {Object} progress 下载进度 type: continue:续传  again:重新下载  cancel:取消 *  */export async function downloadByBlock(fileName, url, dataBaseName, progress) {  //调整下载状态  if(progress.type){    await controlFile(fileName, progress.type, dataBaseName);  }    // 判断是否超过最大下载数量  let downloadNum = store.state.progressList;  if(downloadNum){    if(!progress.type && downloadNum.length == downloadMaxNum){      ElementUI.Message.error("已超过最大下载量,请等待其他文件下载完再尝试!");      return;    }  }    // 基础数据库  const baseDataBase = createInstance(baseDataBaseName);  // 判断数据库中是否已存在该文件的下载任务  let isError = false;  await getData(dataBaseName, baseDataBase, async (res) =>{    if(res && !progress.type){      ElementUI.Message.error("该文件已在下载任务列表,无需重新下载!");      isError = true;    }  });  if(isError){    return;  }    // 储存数据的数据库  const dataBase = createInstance(dataBaseName);    // 获取文件大小  const response = await axios.head(url);  // 文件大小  const fileSize = +response.headers['content-length'];  // 分片大小  const chunkSize = +response.headers['buffer-size'];  // 开始节点  let start = 0;  // 结束节点  let end = chunkSize - 1;  if (fileSize < chunkSize) {    end = fileSize - 1;  }  // 所有分片  let ranges = [];  // 计算需要分多少次下载  const numChunks = Math.ceil(fileSize / chunkSize);  // 回写文件大小  progress.fileSize = fileSize;  progress.url = url;    // 保存数据库实例  await saveData(dataBaseName, fileName, baseDataBase);  if(!progress.progress){    // 保存进度数据    await saveData(progressKey, progress, dataBase);  }  // 保存下载状态至localstorage  如果页面刷新 可重新读取状态 判断是否需要继续下载  sessionStorage.setItem(dataBaseName,"downloading");    // 组转参数  for (let i = 0; i < numChunks; i++) {    // 创建请求控制器     const controller = new AbortController();        const range = `bytes=${start}-${end}`;    // 如果是续传 先判断是否已下载    if(progress.type == 'continue'){      // 先修改状态      store.commit('setProgress', {          dataInstance: dataBaseName,          status: 'downloading'      })      let isContinue = false;      await getData(range, dataBase, async function(res){ if(res) isContinue = true});      if(isContinue){        ranges.push(range);        // 重置开始节点        start = end + 1;        // 重置结束节点        end = Math.min(end + chunkSize, fileSize - 1);        continue;      }    }    let cancel;    const config = {      headers: {        Range: range      },      responseType: 'arraybuffer',      // 绑定取消请求的信号量      signal: controller.signal,       // 文件下载进度监听      onDownloadProgress: function (pro){        if(progress.type == 'stop' || progress.type == 'cancel'){           // 终止请求          controller.abort();        }                // 已加载大小        // progress对象中的loaded表示已经下载的数量,total表示总数量,这里计算出百分比        let downProgress = (pro.loaded / pro.total);         downProgress = Math.round( (i / numChunks) * 100 + downProgress  / numChunks * 100);        progress.progress = downProgress;        // 设置为异常  如果是正常下载完 这个记录会被删除 如果合并失败  则会显示异常        progress.status = downProgress == 100 ? 'error' : 'stopped';        store.commit('setProgress', {            dataInstance: dataBaseName,            fileName: fileName,            progress: downProgress,            status: 'downloading',            cancel: cancel,            url: url,            dataBaseName: dataBaseName        })      }    };    try {      // 开始分片下载      const response = await axios.get(url, config);      // 保存分片数据      await saveData(range, response.data, dataBase);              ranges.push(range);      // 重置开始节点      start = end + 1;      // 重置结束节点      end = Math.min(end + chunkSize, fileSize - 1);            } catch (error) {      console.log("终止请求时catch的error---", error);      // 判断是否为取消上传      if (error.message == "canceled"){          // 进行后续处理          if(progress.type == 'stop'){            // 暂停            await controlFile(fileName, progress.type, dataBaseName);            sessionStorage.setItem(dataBaseName,"stop");             // 终止请求            console.log("stopped");            return;          } else if(progress.type == 'cancel'){            // 取消            await controlFile(fileName, progress.type, dataBaseName);            sessionStorage.removeItem(dataBaseName);            // 终止请求            console.log("canceled");            return;          }      };    }  }  console.log("开始合入");  // 流操作  const fileStream = streamSaver.createWriteStream(fileName, {size: fileSize});  const writer = fileStream.getWriter();  // 合并数据  // 循环取出数据合并  for (let i=0; i < ranges.length; i++) {    var range = ranges[i];    // 从数据库获取分段数据    await getData(range, dataBase, async function(res){      if(res){        // 读取流        const reader = new Blob([res]).stream().getReader();        while (true) {          const { done, value } = await reader.read();          if (done) break;          // 写入流          writer.write(value)        }        // 结束写入        if(i==ranges.length-1){          writer.close();          // 清空数据库          dropDataBase(dataBaseName);          // 基础数据库删除数据库实例          removeData(dataBaseName, baseDataBase);          // 清除store          store.commit('delProgress', {            dataInstance: dataBaseName          })        }       }    });   }}

二,VUE页面

<script>import { downloadByBlock } from "上面的js";export default {  name: "suspension",  data() {    return {      // 下载列表      downloadDialog: false,      fileStatus: {}    };  },  watch: {    "$store.state.progressList": function() {      this.$nextTick(function() {        let progressList = this.$store.state.progressList;        progressList.forEach(item => {          // 获取之前下载状态 还原操作          const status = sessionStorage.getItem(item.dataInstance);          if (status == "downloading" && item.status != status) {            this.startDownload(item);          }        });      });    }  },  methods: {    /**     * @description 重试     * @param {Object} row     */    retryDownload(row) {      this.startDownload(row);    },    /**     * @description 开始下载     * @param {Object} row     */    startDownload(row) {      this.fileStatus[row.dataInstance] = {        type: "continue",        progress: row.progress      };      downloadByBlock(        row.fileName,        row.url,        row.dataInstance,        this.fileStatus[row.dataInstance]      );    },    /**     * @description 暂停下载     * @param {Object} row     */    stopDownload(row) {      this.$set(this.fileStatus[row.dataInstance], "type", "stop");    },    /**     * @description 删除下载     * @param {Object} row     */    delDownload(row) {      if (this.fileStatus[row.dataInstance] && row.status != "stopped") {        this.$set(this.fileStatus[row.dataInstance], "type", "cancel");      } else {        this.fileStatus[row.dataInstance] = { type: "cancel" };        downloadByBlock(          row.fileName,          row.url,          row.dataInstance,          this.fileStatus[row.dataInstance]        );      }    },    /**     * @description 分片下载文件     */    downloadByBlock(fileName, url, dataBaseName, type) {      this.fileStatus[dataBaseName] = { type: type };      downloadByBlock(        fileName,        url,        dataBaseName,        this.fileStatus[dataBaseName]      );      this.btnDownload();    },    /**     * @description 下载列表     */    btnDownload() {      // 通过路由信息判断当前处于哪一个页面      const page = this.$route.name;      this.downloadDialog = true;    },    /**     * @description 取消弹窗     */    downloadBtnCancel() {      this.downloadDialog = false;    }  }};</script>

小结

这只是一个基本示例。在实际应用中,你可能需要考虑以下问题:

并发下载: 如果多个用户同时下载相同的大文件,可能会对服务器造成很大的压力。可以使用并发下载来提高性能。

安全性: 确保只有授权用户可以下载文件,并且不会泄漏敏感信息。

性能优化: 使用缓存、压缩等技术来提高下载速度和用户体验。

服务器资源管理: 下载大文件可能会占用服务器的网络带宽和内存资源,需要适当的管理。

总之,大文件分片下载和合并是一个复杂的任务,需要综合考虑性能、安全性和用户体验。在实际项目中,你可能会使用更高级的工具和技术来处理这些问题。

也许您对下面的内容还感兴趣: