前端如何分片上传大文件

4,801 阅读2分钟

最近做了一个需求,让用户本地上传一个最大 300M 的视频文件,下面是前端部分的记录。

需求分析点

  • 校验用户上传的文件类型,以及内存大小,还有视频的播放时长检查
  • 视频过大需要分片上传,记录本次是第几片数据,重新上传则可以实现接着上次的片段继续上传而非重头上传
  • 同个视频需要标记,不可重复上传,减小服务器的内存压力

交互样式

接下来首先如何让用户上传文件,input 标签中type=file可以让用户上传文件,此处我是在 Vue 项目里。

<input ref="videoInput" type="file" title="选择视频文件" @change="handleUpload" />

此时已经可以让用户在本地上传文件了,但是这里有个问题,在该用户已经有上传过的视频文件时,如果再次上传需要首先展示提示信息,重复上传会覆盖原有视频。但是用户点了该 input 按钮,就已经触发选择文件的弹窗了,并不能拦截,加之原生的 input 样式巨丑还自带选择的文件名,这不是我想要的,跟我们的 ui 样式不符,所以我的想法是,隐藏这个 input, 取而代之的是自己重新写一个按钮,给这个按钮加上点击事件,在该点击事件里处理额外逻辑,然后用 js 代码主动触发该 input 的点击事件。

this.$messageBox.confirm("重新上传视频会替换原有视频,是否继续?").then(() => {
  this.$refs.videoInput.click();
});

标记视频

接下来就是如何标记视频,以免重复上传相同的视频。方案是获取视频文件的哈希 md5 值。因为每个文件的 md5 是唯一的,我们在做文件上传的时候,就只要在前端先获取要上传的文件 md5,并把文件 md5 传到服务器,对比之前文件的 md5,如果存在相同的 md5,我们只要把文件的名字传到服务器关联之前的文件即可,并不需要再次去上传相同的文件,再去耗费存储资源、上传的时间、网络带宽。

let file = this.$refs.videoInput.files[0];
console.log(file);

注意这里拿到的file数据,下面会再提到它 此处可以拿到文件 file,它自身可以获取文件的类型type,以及文件的内存大小size,单位是字节。同时也有slice方法用于截取,分片获得子片段数据 file。 但是直接通过它去获取 md5 值发现跟后端(Java)获取不一致。接下来怎么办?

首先要说到的是spark-md5依赖包

SparkMD5 是 MD5 算法的快速实现。此脚本基于 JKM md5 库,这最适合浏览器使用。

它的原理:(化简后的核心代码)

import SparkMD5 from 'spark-md5';

const fileReader = new FileReader();
fileReader.onload = function (e) {
  let spark = new SparkMD5.ArrayBuffer();
  spark.append(e.target.result); // Append array buffer
  console.info("computed hash", spark.end()); // Compute hash
};
fileReader.readAsArrayBuffer(file);

关于FileReader请移步🚀 FileReader

使用browser-md5-file获取文件 md5 值, 它是进一步的封装
import BMF from "browser-md5-file";
const bmf = new BMF();
bmf.md5(
  file,
  (err, md5) => {
    if (err) {
      console.log(err);
    }
    console.log(md5); // md5值
  },
  (process) => {
    //计算进度
  }
);

此时对文件的md5值已经跟后端的保持一致了😍!

获取视频的播放时长

这里有两种场景获取视频时长

  • 通过File数据
  • 通过播放地址
function getVideoDuration(file) {
  return new Promise((resolve, reject) => {
    var audioElement;
    var url;
    if (typeof file == 'string') {
      audioElement = new Audio();
      audioElement.src = file
    } else {
      url = window.URL.createObjectURL(file);
      audioElement = new Audio(url);
    }

    audioElement.addEventListener("loadedmetadata", function() {
      var duration = audioElement.duration;
      // console.log("视频时长", duration);
      window.URL.revokeObjectURL(url);

      resolve(duration)
    });
  })
}

到这里有必要说一说通过用户上传的文件直接获取到的数据File到底是什么了?

File在前端中用于文件操作的二进制对象类,它的父类是Blob。它们是针对文件的,或者可以说它就是一个文件对象。

关于Blob请移步🚀 你不知道的 Blob

上传文件

上传文件的数据格式{"content-type": "multipart/form-data"},html5 提供了 api

FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过 XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 "multipart/form-data",它会使用和表单一样的格式。

let formData = new FormData();
formData.append("file", item);
axios({
  url: "xxx",
  method: "post",
  headers: { "content-type": "multipart/form-data" },
  data: formData,
});

下面是项目中关于上传的代码逻辑,给大家一个参考吧!

/**
  * id
  * fileList 文件分片后的列表
  * hashList 文件分片后对应的md5值列表
  * startIndex 在上一次上传的起点 继续上传 默认第一段
  * processFn 上传进度
  */
function async startMediaPartUpload(id, fileList, hashList, startIndex = 1, processFn) {
  let point = Math.floor(((startIndex - 1) / fileList.length) * 100);
  processFn && processFn(point)
  // 上传
  for (let i = startIndex - 1; i < fileList.length; i++) {
    const item = fileList[i];
    let formData = new FormData();
    formData.append("id", id);
    formData.append("option", "slice");
    formData.append("hash", hashList[i]);
    formData.append("part", i + 1);
    formData.append("file", item);

    let { code, memo, content } = await mediaPartUpload(formData);
    let point = Math.floor(((i + 1) / fileList.length) * 100);
    processFn && processFn(point)

    if (code == "000000") {
      // this.$message.success(`上传ok ${content.part}`);
    } else {
      this.$message.error(memo);

      return Promise.reject("上传失败了");
    }
  }
}

本文使用 mdnice 排版