JavaScript利用切片实现大文件断点续传

最近公司需要用户上传比较大的文件,一个文件可能大于1GB,如果出现网络波动或者用户违规操作导致上传中断,那么就必须重新重头上传。身为前端,与后端商量后,查看了一些已经成熟的的实现方案,最后使用断点续传优化上传的逻辑。

完整代码-github

 

什么是断点续传

在文件上传期间因为一些原因而导致上传终止(刷新或网络中断)时,下次再次上传同一个文件就从上一次上传到一半的地方继续上传,以节省上传时间。

 

实现思路

主体思路是将比较大的文件分成若干个切片。并非一次性将一个文件整体传输给服务器,而是将分割的切片一部分一部分地传给服务器。服务器将已上传的切片暂存,当所有的切片都上传成功了,再将切片合并成一整个文件。

这么做就可以利用切片使用断点续传的功能。具体逻辑是如果在上传切片的途中断了,那么下次再次上传同一个文件的时候可以先向服务器端发一个请求,获取已经上传了哪些切片,与整体切片进行比对后,再将剩下未上传的切片继续上传。

其中后端处理切片和合并的一个细节点为:将大文件进行切片时,后端需要将每一个切片的文件名为${原文件HASH值}_${切片序号}.${文件后缀}例如'f07ec272dbb0b883eed4b2f415625a90_2.mp4'。并且将服务器存的切片的临时文件夹的名字命名为hash值。最后所有的切片上传完成时,再调用合并接口,后端将所有临时文件夹中的切片合并。

 

需要后端提供的api

以下api需要后端提供开发

获取已经上传的切片

  url:/upload_already
method:GET
params:
  HASH:文件的HASH名字
return:application/json
  code:0成功 1失败,
  codeText:状态描述,
  fileList:[...]

此方法用来获取已上传文件的所有切片的切片名,例如返回:

{
fileList:['f07ec272dbb0b883eed4b2f415625a90_1.mp4','f07ec272dbb0b883eed4b2f415625a90_2.mp4','f07ec272dbb0b883eed4b2f415625a90_3.mp4']
}

意思为HASH值为'f07ec272dbb0b883eed4b2f415625a9'的文件已经上传了三个切片,接下来只需要从第四个切片开始上传即可。如果是空数组,说明此文件第一次上传。

上传切片

  url:/upload_chunk
method:POST
params:multipart/form-data
  file:切片数据
  filename:切片名字「文件生成的HASH_切片编号.后缀」
return:application/json
  code:0成功 1失败,
  codeText:状态描述,
  originalFilename:文件原始名称,
  servicePath:文件服务器地址

上传file对象格式的切片文件,并且将切片名字已${原文件HASH值}_${切片序号}.${文件后缀}格式传给后端,后端利用hash值将切片暂存在一个临时文件夹中,最后所有切片上传完成,就将切片合并,然后删除这个临时文件夹。

合并切片

  url:/upload_merge
method:POST
params:application/x-www-form-urlencoded
  HASH:文件的HASH名字
  count:切片数量
return:application/json
  code:0成功 1失败,
  codeText:状态描述,
  originalFilename:文件原始名称,
  servicePath:文件服务器地址

当所有切片上传完成(前段自行判断)之后,调用合并接口,后端会将切片合并,然后删除存切片的临时文件夹。

 

前端代码细节实现

HASH值的获取方法

  • 使用FileReader对象将选择的文件对象转为buffer
  • 依据文件的buffer使用MD5库生成文件的HASH值。

封装成函数:

/**
* 传入文件对象,返回文件生成的HASH值,后缀,buffer,以HASH值为名的新文件名
* @param file
* @returns {Promise<unknown>}
*/
const changeBuffer = file => {
  return new Promise(resolve => {
      let fileReader = new FileReader();
      fileReader.readAsArrayBuffer(file);
      fileReader.onload = ev => {
          let buffer = ev.target.result,
              spark = new SparkMD5.ArrayBuffer(),
              HASH,
              suffix;
          spark.append(buffer);
          HASH = spark.end();
          suffix = /.([a-zA-Z0-9]+)$/.exec(file.name)[1];
          resolve({
              buffer,
              HASH,
              suffix,
              filename: `${HASH}.${suffix}`
          });
      };
  });
};

切片处理

使用Blob对象中的slice函数可以进行对文件进行切片处理。可以将文件切为自定义的大小和数量。

Blob.slice文档参考

例如file.slice(0,1024)代表将文件的0-1024字节数据切片,然后返回一个新的对象。

总体html结构

<div class="container">
  <div class="item">
      <h3>大文件上传</h3>
      <section class="upload_box" id="upload7">
          <input type="file" class="upload_inp">
          <div class="upload_button_box">
              <button class="upload_button select">上传文件</button>
          </div>
          <div class="upload_progress">
              <div class="value"></div>
          </div>
      </section>
  </div>
</div>

使用axios发送请求

/*把axios发送请求的公共信息进行提取*/
//创建一个单独的实例,不去项目全局的或者其他的axios冲突
let instance = axios.create();
instance.defaults.baseURL = 'http://127.0.0.1:8888';
//默认是multipart/form-data格式
instance.defaults.headers['Content-Type'] = 'multipart/form-data';
instance.defaults.transformRequest = (data, headers) => {
  //兼容x-www-form-urlencoded格式的请求发送
  const contentType = headers['Content-Type'];
  if (contentType === "application/x-www-form-urlencoded") return Qs.stringify(data);
  return data;
};
//统一结果的处理
instance.interceptors.response.use(response => {
  return response.data;
},reason=>{
  //统一失败的处理
  return Promise.reject(reason)
});

整体逻辑和代码

详细的逻辑在注释当中,写的比较详细

完整代码-github

(function () {
  let upload = document.querySelector('#upload7'),
      upload_inp = upload.querySelector('.upload_inp'),
      upload_button_select = upload.querySelector('.upload_button.select'),
      upload_progress = upload.querySelector('.upload_progress'),
      upload_progress_value = upload_progress.querySelector('.value');

  const checkIsDisable = element => {
      let classList = element.classList;
      return classList.contains('disable') || classList.contains('loading');
  };

  /**
   * 传入文件对象,返回文件生成的HASH值,后缀,buffer,以HASH值为名的新文件名
   * @param file
   * @returns {Promise<unknown>}
   */
  const changeBuffer = file => {
      return new Promise(resolve => {
          let fileReader = new FileReader();
          fileReader.readAsArrayBuffer(file);
          fileReader.onload = ev => {
              let buffer = ev.target.result,
                  spark = new SparkMD5.ArrayBuffer(),
                  HASH,
                  suffix;
              spark.append(buffer);
              HASH = spark.end();
              suffix = /.([a-zA-Z0-9]+)$/.exec(file.name)[1];
              resolve({
                  buffer,
                  HASH,
                  suffix,
                  filename: `${HASH}.${suffix}`
              });
          };
      });
  };

  upload_inp.addEventListener('change', async function () {
      //get native file object
      let file = upload_inp.files[0];
      if (!file) return;
      //button add loading
      upload_button_select.classList.add('loading');
      //show progress
      upload_progress.style.display = 'block';

      // 获取文件的HASH
      let already = [],//已经上传过的切片的切片名
          data = null,
          {
              HASH,
              suffix
          } = await changeBuffer(file);//得到原始文件的hash和后缀

      // 获取已经上传的切片信息
      try {
          data = await instance.get('/upload_already', {
              params: {
                  HASH
              }
          });
          if (+data.code === 0) {
              already = data.fileList;
          }
      } catch (err) {}

      // 实现文件切片处理 「固定数量 & 固定大小」
      let max = 1024 * 100,//切片大小先设置100KB
          count = Math.ceil(file.size / max),//得到应该上传的切片
          index = 0,//存放切片数组的时候遍历使用
          chunks = [];//用以存放切片值
      if (count > 100) {//如果切片数量超过100,那么就只切成100个,因为切片太多的话也会影响调用接口的速度
          max = file.size / 100;
          count = 100;
      }
      while (index < count) {//循环生成切片
          //index 0 =>  0~max
          //index 1 =>  max~max*2
          //index*max ~(index+1)*max
          chunks.push({
              file: file.slice(index * max, (index + 1) * max),
              filename: `${HASH}_${index+1}.${suffix}`
          });
          index++;
      }

      index = 0;
      const clear = () => {//上传完成后,将状态回归
          upload_button_select.classList.remove('loading');
          upload_progress.style.display = 'none';
          upload_progress_value.style.width = '0%';
      };

      //每一次上传一个切片成功的处理[进度管控&切片合并]
      const complate = async () => {
          // 管控进度条:每上传完一个切片,就将进度条长度增加一点
          index++;
          upload_progress_value.style.width = `${index/count*100}%`;

          if (index < count) return;
          // 当所有切片都上传成功,就合并切片
          upload_progress_value.style.width = `100%`;
          try {
              //调用合并切片方法
              data = await instance.post('/upload_merge', {
                  HASH,
                  count
              }, {
                  headers: {
                      'Content-Type': 'application/x-www-form-urlencoded'
                  }
              });
              if (+data.code === 0) {
                  alert(`恭喜您,文件上传成功,您可以基于 ${data.servicePath} 访问该文件~~`);
                  clear();
                  return;
              }
              throw data.codeText;
          } catch (err) {
              alert('切片合并失败,请您稍后再试~~');
              clear();
          }
      };

      // 循环上传每一个切片
      chunks.forEach(chunk => {
          // 已经上传的无需在上传
          //后台返回的already格式为['HASH_1.png','HASH_2.png'],既已经上传的文件的切片名
          if (already.length > 0 && already.includes(chunk.filename)) {
              //已经上传过了的切片就无需再调用接口上传了
              complate();//动进度条或合并所有切片
              return;
          }
          let fm = new FormData;
          fm.append('file', chunk.file);
          fm.append('filename', chunk.filename);
          instance.post('/upload_chunk', fm).then(data => {//使用form data格式上传切片
              if (+data.code === 0) {
                  complate();////动进度条或合并所有切片
                  return;
              }
              return Promise.reject(data.codeText);
          }).catch(() => {
              alert('当前切片上传失败,请您稍后再试~~');
              clear();
          });
      });
  });
  //触发原生的上传文件框
  upload_button_select.addEventListener('click', function () {
      if (checkIsDisable(this)) return;
      upload_inp.click();
  });
})();

 

实现效果

第一次上传时,分别调用/upload_already,/upload_chunk方法获取已上传的切片(空数组),然后进行切片分割,再一个个进行上传。

当在此时刷新页面,终端切片的上传行为时。此时我们看一下后端的临时数据

可以看到一个以hash至命名的临时文件夹,并且已经上传了24个切片

如果我们再次选择同样的文件上传,进度条会立即到上次已经上传的位置,already接口返回已上传的24个切片的名字的数组。

将剩下的切片上传完成之后,会调用merge接口。完成上传

此时后端的临时文件夹被删除,合并为一整个文件。上传结束

关于JavaScript利用切片实现大文件断点续传的文章就介绍至此,更多相关JavaScript文件断点续传内容请搜索编程宝库以前的文章,希望以后支持编程宝库

 一、 Axios 的封装在 Vue 项目中,和后台进行数据交互是频繁且不可或缺的,刚开始没进行 Axios 封装的时候,每次请求后台数据都是写的完整的路径,特别长,尤其是基准地址,每次都要 ...