了解大文件分片上传,断点续传,秒传

973 阅读4分钟

使用场景

在日常开发中会遇到头像上传,文件上传,视频上传等场景,若是文件过大(尤其是视频),中途一旦出现网络卡顿,就需要重新上传这个文件,对用户体验造成不好的影响,所以对于数据量大的文件,需要采用断点续传。

概念

断点续传和秒传都是基于分片上传扩展的功能

分片上传原理:客户端将选择的文件进行切分,每个分片都单独发送请求到服务端 断点续传&秒传:客户端发送请求询问服务端某文件的上传状态,服务端返回已上传的分片索引,客户端再判断是否需要继续上传:

  • 如果没有需要上传的分片就是秒传
  • 如果有需要上传的分片就是断点续传

每个文件都有自己唯一的标识,这个标识就是将整个文件进行MD5加密,这是个hash算法,将加密后的Hash值作为文件的唯一标识

  • 使用spark-md5第三方工具库

文件合并: 当服务端确认分片都发送完成后,此时会发送请求通知服务端对文件进行合并操作

前端流程

image.png

  • 第一步:将文件进行分片,并计算其Hash值(文件的唯一标识)
  • 第二步:发送请求,询问服务端文件的上传状态
  • 第三步:根据文件上传状态进行后续上传
    • 文件已经上传过了
      • 结束--秒传功能
    • 文件存在,但分片不完整
      • 将未上传的分片进行上传 --- 断点续传功能
    • 文件不存在
      • 将所有分片上传
  • 第四步:文件分片全部上传后,发送请求通知服务端合并文件分片

具体实现

const handleUpload = async () => {
  if (!file.value) {
    return ElMessage.error("请选择文件");
  }
  // 第一步:将文件进行切片,并计算整个文件的hash值
  let chunks = createFileChunk(file.value);
  // 优化一:hash值使用抽样
  hash.value = await calculateHashSample(file.value);
  // hash.value = await calculateHashWorker(chunks);

  // 第二步发送请求,询问服务端文件的上传状态
  const { uploaded, uploadedList } = await request.post("/check", {
    ext: ext(file.value.name),
    hash: hash.value,
  });
  if (uploaded) {
    return ElMessage.success("秒传: 上传成功");
  }

  // 第三步: 根据文件上传状态进行后续上传
  /**
   * (1)文件已经上传过了:结束---秒传功能
   * (2)文件存在,但分片不完整: 断点续传功能(计算未上传的分片序列并上传对应分片)
   * (3)文件不存在: 将所有分片上传
   */
  // 修改上传的数据类型,将所有的切片重新命名
  chunksList.value = chunks.map((chunk, index) => {
    // 每个切片的名字
    const chunkName = hash.value + "-" + index;
    return {
      hash: hash.value,
      chunk: chunk.file,
      name: chunkName,
      index,
      // 进度条
      progress: uploadedList.indexOf(chunkName) > -1 ? 100 : 0,
    };
  });
  // 断点续传的功能
  await uploadChunks(uploadedList);

文件分片

(1)确定每个分片的大小 1 * 1024 * 1024; (2)返回所有分片的集合

const CHUNK_SIZE = 1 * 1024 * 1024; // 1M
const createFileChunk = (file, size = CHUNK_SIZE) => {
  const chunks = []
  let cur = 0 
  while(cur < file.size) {
    chunks.push({ index: cur, file: file.slice(cur, cur+size)})
    cur += size
  }
  return chunks
}

计算整个文件的hash值

使用第三方库: spark-md5

image.png hash策略:

取前两块分片+ 中间分片的前中后2个字节 + 最后分片偏移量

const calculateHashSample =  async (file) => {
  return new Promise((resolve)=> {
    let spark = new sparkMd5.ArrayBuffer()
    let fileReader = new FileReader()
    // hash的优化值:取前两块分片
    let offset = 2 * 1024 * 1024
    // 前面取2个分片
    let chunks = [file.slice(0, offset)]
    let cur = offset
    while(cur < file.size) {
      // 最后一片加入
      if(cur + offset >= file.size) {
        chunks.push(file.slice(cur, cur + offset))
      } else {
        // 中间分片,前中后取2个字节
        let mid = cur + offset/2
        let end = cur + offset
        chunks.push(file.slice(cur, cur + 2))
        chunks.push(file.slice(mid, mid + 2))
        chunks.push(file.slice(end - 2, end))
      }
      cur += offset
    }
    // 拼接在一起
    fileReader.readAsArrayBuffer(new Blob(chunks))
    fileReader.onload = (e) => {
      // 将当前分块的结果追加到spark对象中
      spark.append(e.target.result)
      // 全部读取,获取文件的hash
      resolve(spark.end())
    }
  })
}

分片上传

服务端返回上传成功的分片文件名uploadedList,由于请求不仅包含文件,还包含文件名以及hash值,请求体应该是formData

const uploadChunks = async (uploadedList = []) => {
  const list = chunksList.value
    .filter((chunk) => uploadedList.indexOf(chunk.name) == -1)
    .map(({ hash, chunk, name, index }) => {
      const form = new FormData();
      form.append("chunkname", name);
      form.append("hash", hash);
      form.append("file", chunk);
      form.append("ext", ext(file.value.name));
      return { form, index, error: 0, progress: 0 };
    });
  try {
    // 分片上传,错误4次结束进程 
    await sendRequest([...list], 4);
    // 通知后端进行合并
    if (list.length + uploadedList.length === chunksList.value.length) {
      await mergeRequest();
    }
  } catch (e) {
    ElMessage.error("上传出了点小问题");
  }
};

前后端流程

image.png

优化

文件非常大时,计算hash值十分耗时,对于一个 2GB 大小的文件来说,即使是使用 MD5 算法来计算 Hash 值,也会造成浏览器的卡顿。 解决hash计算耗时问题:

  • 使用Web Worker,不占用主线程资源(多线程,异步处理)
  • 不一定非要hash整个文件,仅hash文件的第一个分片 + 中间分片的首尾n字节 + 最后一个分片

web Worker

const calculateHashWorker = async (chunks) => {
  return new Promise((resolve) => {
    // web-worker 防止卡顿主线程,创建文件分片worker
    const worker = new Worker("/hash.js");
    // 将文件通过postMessage发送给worker线程
    worker.postMessage({ chunks });
    // 分片处理完之后触发onmessage事件
    worker.onmessage = (e) => {
      // 获取处理结果
      const { progress, hash } = e.data;
      hashProgress.value = Number(progress.toFixed(2));
      if (hash) {
        resolve(hash);
      }
    };
  });
};

参考

juejin.cn/post/732414…