js - 大文件上传下载 - 分片上传、并行下载

js - 大文件上传下载

大文件上传-分片上传

分片上传的好处是将一个大请求分成多个小请求来执行,这样当其中一些请求失败后,不需要重新上传整个文件,而只需要上传失败的分片就可以了。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>upload</title>
</head>
<body>
    <input type="file" name="file" id="file">
    <button id="upload" onClick="upload()">upload</button>
    <script type="text/javascript">
        var bytesPerPiece = 1024 * 1024; // 每个文件切片大小定为1MB .
        var totalPieces;
        //发送请求
        function upload() {
            var blob = document.getElementById("file").files[0];
            var start = 0;
            var end;
            var index = 0;
            var filesize = blob.size;
            var filename = blob.name;

            //计算文件切片总数
            totalPieces = Math.ceil(filesize / bytesPerPiece);
            console.log('blob:',blob)
            console.log('totalPieces:',totalPieces)
            while(start < filesize) {
                end = start + bytesPerPiece;
                if(end > filesize) {
                    end = filesize;
                }

                var chunk = blob.slice(start,end);//切割文件    
                var sliceIndex= blob.name + index;
                var formData = new FormData();
                formData.append("file", chunk, filename);
                console.log('start:',start)
                console.log('end:',end)
                console.log('chunk:',chunk)
                console.log('sliceIndex:',sliceIndex)
                console.log('formData:',formData)
                // $.ajax({
                //     url: 'http://localhost:9999/test.php',
                //     type: 'POST',
                //     cache: false,
                //     data: formData,
                //     processData: false,
                //     contentType: false,
                // }).done(function(res){ 

                // }).fail(function(res) {

                // });
                start = end;
                index++;
            }
        }
    </script>
</body>
</html>

断点上传

  1. 实现文件块的上传函数
// 文件切块大小为1MB
const chunkSize = 1024 * 1024;

// 从start字节处开始上传
function upload(start) {
    let fileObj = document.getElementById('file').files[0];
    // 上传完成
    if (start >= fileObj.size) {
        return;
    }
    // 获取文件块的终止字节
    let end = (start + chunkSize > fileObj.size) ? fileObj.size : (start + chunkSize);
    // 将文件切块上传
    let fd = new FormData();
    fd.append('file', fileObj.slice(start, end));
    // POST表单数据
    let xhr = new XMLHttpRequest();
    xhr.open('post', 'upload.php', true);
    xhr.onload = function() {
        if (this.readyState == 4 && this.status == 200) {
            // 上传一块完成后修改进度条信息,然后上传下一块
            let progress = document.getElementById('progress');
            progress.max = fileObj.size;
            progress.value = end;
            upload(end);
        }
    }
    xhr.send(fd);
}
  1. 如果突然断网或者浏览器意外关闭,那么上传的是不完整的文件,我们只需要在选择了文件以后向服务器查询一下服务器上相同文件名的大小,然后将开始上传位置(字节)设置到这个大小即可:
// 初始化上传大小
function init() {
    let fileObj = document.getElementById('file').files[0];
    let xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            // 将字符串转化为整数
            let start = parseInt(this.responseText);
            // 设置进度条
            let progress = document.getElementById('progress');
            progress.max = fileObj.size;
            progress.value = start;
            // 开始上传
            upload(start);
        }
    }
    xhr.open('post', 'fileSize.php', true);
    // 向服务器发送文件名查询大小
    xhr.send(fileObj.name);
}

文件流下载

特殊的情况下我们不希望暴露文件下载地址,且文件下载地址需要有登录权限的cookie或者token鉴权后才允许下载,并不希望用户拿到下载地址后去别的工具上下载,能尽量规避文件传播的风险。适用的范围可能还会有其他的场景。

axios({
    method: 'post',
    url: 'api/file',
    responseType: 'blob'
}).then(res=> {
     if (res.data){
      filename = 'filename';
      let blob = new Blob([res.data],{type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8"});
      if (window.navigator.msSaveOrOpenBlob){
          // IE10+下载
        navigator.msSaveOrBlob(blob, filename);
      }else{
          // 非IE10+下载
        let link = document.createElement('a');
        link.href = window.URL.createObjectURL(blob);
        link.download = filename;
        document.body.appendChild(link);
        var evt = document.createEvent("MouseEvents");
        evt.initEvent("click", false, false);
        link.dispatchEvent(evt);//释放URL 对象
        document.body.removeChild(link);
      }
}).catch((error) => {
  console.log(error)
})

大文件的下载

需求:大文件分片下载,合并,另存为文件,完成大文件的断点续传下载功能。

道理差不多,户端的下载文件拼接的方式,并且压缩包要考虑服务端的打包压缩时间

var blob = new Blob([arrFiles], { type: 'application/octet-stream' });
var glbReader = new window.FileReader();
reader.readAsArrayBuffer(blob);
reader.onloadend = function () {
    var newBuffer = reader.result;
      // 再转成blobURL的形式本地下载
};

分片下载

断点下载

断点下载原理参考断点上传

  1. js 通过ajax 下载分片文件获得blob是很好实现。
var xhr = new XMLHttpRequest();
xhr.open("GET", downobj.filePartitionUrls[downobj.downSuccessPartitionCount], true);//open false 是同步请求,不会异步触发
xhr.responseType = 'blob';
xhr.onload=(e)=>{this.partitionDownSucCall(downobj,xhr,"onload");};//3.数据是否已经全部下载完成,如果是执行最后的下载操作
xhr.ontimeout =(e)=>{this.partitionDownSucCall(downobj,xhr,"timeout");};
xhr.onerror =(e)=>{this.partitionDownSucCall(downobj,xhr,"error");};
xhr.send();
  1. 下载的blob文件需要在本地浏览器中做持久化存储,为什么要在浏览器中持久化存储,因为几千个分片的文件,用户可能下载了几个分片,就把浏览器关掉,去干别的事情了,回头再来打开浏览器进行下载,这个情况下如果已经下载的分片没有持久化存储,就会丢失,文件就得从头重新下载了。

那h5中如何实现类容的持久存储呢,我们都知道浏览器有LocalStorage 和sessionStorage,但是很明显sessionStorage是不适合的,因为浏览器关闭会清空会话数据,LocalStorage能够实现持久化存储,不会在网页关闭后丢失数据,但是LocalStorage有一个限制,同一个域名下最大存储5M数据(浏览器不同稍有差异)。

显然LocalStorage的存储容量5M完全无法满足我们的文件分片存储需求,超过5M的文件无法被存起来。

其实浏览器还有一个数据存储,但是日常中非常少用到,所以很多人不知道,这就是 indexedDb,浏览器提供的索引数据库(https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API),能持久化存储数据,并且存储容量很大,主要容量现在来源于硬盘,所以容量范围很宽。具体限制:https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria

有了indexedDb ,我们下载的文件分片就能全部存在indexedDb中了。

因为indexedDb 的原生调用方式还是很复杂的,我们就引用localforage.min.js查件,简化对indexedDb的操作。读写数据非常方便,文档:https://localforage.github.io/localForage/

  1. 当文件分片都下载完成了,我们就应该从indexedDb 中取出所有的分片文件,并且将分片按照顺序合并成一个文件包

将所有分片文件的blob数据合并成一个数组。并将数组合并为一个Blob数据对象。

伪代码
let pratition1=blobData1;//分片1的blob数据
let pratition2=blobData2;//分片2的blob数据
...
let allFileData = [pratition1,pratition2,...];//将所有分片BLOB数据合并到一个数组
var fileBlob = new Blob(allData,{type:"application/octet-stream;charset=utf-8"});//合并后的数组转成一个Blob对象。
  1. 将合并好的文件触发本地下载或者说另存为的操作。
      if (window.navigator.msSaveOrOpenBlob){
          // IE10+下载
        navigator.msSaveOrBlob(blob, filename);
      }else{
          // 非IE10+下载
        let link = document.createElement('a');
        link.href = window.URL.createObjectURL(blob);
        link.download = filename;
        document.body.appendChild(link);
        var evt = document.createEvent("MouseEvents");
        evt.initEvent("click", false, false);
        link.dispatchEvent(evt);//释放URL 对象
        document.body.removeChild(link);
      }

并发下载

  1. 发送head请求获取文件大小
  2. 计算文件分块数
  3. 使用asyncPool执行并发下载
  4. 分块下载完成后转换为Uint8Array
  5. 执行合并操作
  6. 利用BlobUR执行保存操作
// 1. 获取文件的长度。在该函数中,我们通过发送 HEAD 请求,然后从响应头中读取 Content-Length 的信息,进而获取当前 url 对应文件的内容长度。
function getContentLength(url) {
  return new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest();
    xhr.open("HEAD", url);
    xhr.send();
    xhr.onload = function () {
      resolve(
        ~~xhr.getResponseHeader("Content-Length") 
      );
    };
    xhr.onerror = reject;
  });
}

// 2. 实现异步任务的并发控制
// poolLimit(数字类型):表示限制的并发数;
// array(数组类型):表示任务数组;
// iteratorFn(函数类型):表示迭代函数,用于实现对每个任务项进行处理,该函数会返回一个 Promise 对象或异步函数。

async function asyncPool(poolLimit, array, iteratorFn) {
  const ret = []; // 存储所有的异步任务
  const executing = []; // 存储正在执行的异步任务
  for (const item of array) {
    // 调用iteratorFn函数创建异步任务
    const p = Promise.resolve().then(() => iteratorFn(item, array));
    ret.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(ret);
}

// 3. 根据传入的参数发起范围请求,从而下载指定范围内的文件数据块:
function getBinaryContent(url, start, end, i) {
  return new Promise((resolve, reject) => {
    try {
      let xhr = new XMLHttpRequest();
      xhr.open("GET", url, true);
      xhr.setRequestHeader("range", `bytes=${start}-${end}`); // 请求头上设置范围请求信息
      xhr.responseType = "arraybuffer"; // 设置返回的类型为arraybuffer
      xhr.onload = function () {
        resolve({
          index: i, // 文件块的索引
          buffer: xhr.response, // 范围请求对应的数据
        });
      };
      xhr.send();
    } catch (err) {
      reject(new Error(err));
    }
  });
}
// 需要注意的是 ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。我们不能直接操作 ArrayBuffer 的内容,而是要通过类型数组对象或 DataView 对象来操作,它们会将缓冲区中的数据表示为特定的格式,并通过这些格式来读写缓冲区的内容。

// 4. 由于不能直接操作 ArrayBuffer 对象,所以我们需要先把 ArrayBuffer 对象转换为 Uint8Array 对象,然后在执行合并操作。以下定义的 concatenate 函数就是为了合并已下载的文件数据块,具体代码如下所示:

function concatenate(arrays) {
  if (!arrays.length) return null;
  let totalLength = arrays.reduce((acc, value) => acc + value.length, 0);
  let result = new Uint8Array(totalLength);
  let length = 0;
  for (let array of arrays) {
    result.set(array, length);
    length += array.length;
  }
  return result;
}

// 5. saveAs 函数用于实现客户端文件保存的功能,这里只是一个简单的实现。在实际项目中,你可以考虑直接使用 FileSaver.js 

function saveAs({ 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 || Math.random();
  a.href = blobUrl;
  a.click();
  URL.revokeObjectURL(blob);
}

// 6. download 函数用于实现下载操作,它支持 3 个参数:
// url(字符串类型):预下载资源的地址;
// chunkSize(数字类型):分块的大小,单位为字节;
// poolLimit(数字类型):表示限制的并发数。

async function download({ url, chunkSize, poolLimit = 1 }) {
  const contentLength = await getContentLength(url);
  const chunks = typeof chunkSize === "number" ? Math.ceil(contentLength / chunkSize) : 1;
  const results = await asyncPool(
    poolLimit,
    [...new Array(chunks).keys()],
    (i) => {
      let start = i * chunkSize;
      let end = i + 1 == chunks ? contentLength - 1 : (i + 1) * chunkSize - 1;
      return getBinaryContent(url, start, end, i);
    }
  );
  const sortedBuffers = results
    .map((item) => new Uint8Array(item.buffer));
  return concatenate(sortedBuffers);
}

大文件下载使用示例


// 基于前面定义的辅助函数,我们就可以轻松地实现大文件并行下载,具体代码如下所示:

function multiThreadedDownload() {
  const url = document.querySelector("#fileUrl").value;
  if (!url || !/https?/.test(url)) return;
  console.log("多线程下载开始: " + +new Date());
  download({
    url,
    chunkSize: 0.1 * 1024 * 1024,
    poolLimit: 6,
  }).then((buffers) => {
    console.log("多线程下载结束: " + +new Date());
    saveAs({ buffers, name: "我的压缩包", mime: "application/zip" });
  });
}

参考地址

JS大文件上传解决方案
js实现大文件分片上传的方法
js大文件上传解决方案(500M以上)
js大文件上传
基于Node.js的大文件分片上传
使用JS实现可断点续传的文件上传方案
JavaScript 中如何实现大文件并行下载?
js文件下载,使用indexedDB 在H5页面中完成大文件的断点分片下载能力,并完成最终的分片合并另存为文件

posted @ 2021-09-15 14:53  zc-lee  阅读(2922)  评论(0编辑  收藏  举报