Skip to content

文件断点续传

思路脉络

  • 解决方案核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片
  • 预先定义好单个切片大小,将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片。
  • 这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间
  • 另外由于是并发,传输到服务端的顺序可能会发生变化,因此还需要给每个切片记录顺序
js
const SIZE = 10 * 1024 * 1024; // 10MB

class fileUpload {
  // 生成文件切片
  createFileChunk(file, size = SIZE) {
    const fileChunkList = [];
    let cur = 0;
    while (cur < file.size) {
      fileChunkList.push({ file: file.slice(cur, cur + size) });
      cur += size;
    }
    return fileChunkList;
  }

  // 上传切片
  async uploadChunks() {
    const requestList = this.data
      .map(({ chunk, hash }) => {
        const formData = new FormData();
        formData.append("chunk", chunk);
        formData.append("hash", hash);
        formData.append("filename", this.container.file.name);
        return { formData };
      })
      .map(({ formData }) =>
        this.request({
          url: "http://localhost:3000",
          data: formData,
        })
      );
    // 并发请求
    await Promise.all(requestList);
    //  合并切片
    await this.mergeRequest();
  }

  async mergeRequest() {
    await this.request({
      url: "http://localhost:3000/merge",
      headers: {
        "content-type": "application/json",
      },
      data: JSON.stringify({
        filename: this.container.file.name,
      }),
    });
  }

  async handleUpload() {
    if (!this.container.file) return;
    const fileChunkList = this.createFileChunk(this.container.file);
    this.data = fileChunkList.map(({ file }, index) => ({
      chunk: file,
      hash: this.container.file.name + "-" + index,
    }));
    await this.uploadChunks();
  }
}

功能点:显示进度条

  • XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件

功能点:断点续传

  • 断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能
    • 前端使用 localStorage 记录已上传的切片 hash (缺陷:换浏览器就失去了记忆的效果)
    • 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片

生成 hash

  • 无论是前端还是服务端,都必须要生成文件和切片的 hash

  • 如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,

  • 所以可以使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互

  • 在 worker 线程中,接受文件切片 fileChunkList,利用 fileReader 读取每个切片的 ArrayBuffer

  • 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程

方案一: web-worker

js
// web-worker 代码

// 库 spark-md5,它可以根据文件内容计算出文件的 hash
self.importScripts("/spark-md5.min.js");

// 生成文件 hash
self.onmessage = e => {
  const { fileChunkList } = e.data;
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0;
  let count = 0;

  const loadNext = index => {
    const reader = new FileReader();
    reader.readAsArrayBuffer(fileChunkList[index].file);
    reader.onload = e => {
      count++;
      spark.append(e.target.result);

      if (count === fileChunkList.length) {
        self.postMessage({
          percentage: 100,
          hash: spark.end()
        });
        self.close();
      } else {
        percentage += 100 / fileChunkList.length;
        self.postMessage({
          percentage
        });
        // calculate recursively
        loadNext(count);
      }
    };
  };
  loadNext(0);
};
js
// 和主线程的通讯
// 主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList,
// 并监听 worker 线程发出的 postMessage 事件拿到文件 hash
function calculateHash(fileChunkList) {
  return new Promise((resolve) => {
    this.container.worker = new Worker("/hash.js");
    this.container.worker.postMessage({ fileChunkList });

    this.container.worker.onmessage = (e) => {
      const { percentage, hash } = e.data;

      this.hashPercentage = percentage;
      if (hash) {
        resolve(hash);
      }
    };
  });
}

方案二:requestIdleCallback 时间切片

  • window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。
  • 这能够在主事件循环上执行后台和低优先级工作 requestIdelCallback 执行的方法,会传递一个 deadline 参数,能够知道当前帧的剩余时间
js
async function calculateHashIdle(chunks) {
  return new Promise((resolve) => {
    const spark = newSparkMD5.ArrayBuffer();
    let count = 0;
    // 根据文件内容追加计算
    const appendToSpark = async (file) => {
      return newPromise((resolve) => {
        const reader = newFileReader();
        reader.readAsArrayBuffer(file);
        reader.onload = (e) => {
          spark.append(e.target.result);
          resolve();
        };
      });
    };
    const workLoop = async (deadline) => {
      // 有任务,并且当前帧还没结束
      while (count < chunks.length && deadline.timeRemaining() > 1) {
        await appendToSpark(chunks[count].file);
        count++;
        // 没有了 计算完毕
        if (count < chunks.length) {
          // 计算中
          this.hashProgress = Number(
            ((100 * count) / chunks.length).toFixed(2)
          );
        } else {
          // 计算完毕
          this.hashProgress = 100;
          resolve(spark.end());
        }
      }
      window.requestIdleCallback(workLoop);
    };
    window.requestIdleCallback(workLoop);
  });
}

文件秒传

  • 所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功
  • 文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可

暂停上传

  • 原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此需要将上传每个切片的 xhr 对象保存起来

恢复上传

  • 由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片

  • 这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果

    • 服务端已存在该文件,不需要再次上传
    • 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端

注意点

  1. 计算 hash 耗时的问题,不仅可以通过 web-workder,还可以参考 React 的 FFiber 架构,通过 requestIdleCallback来利用浏览器的空闲时间计算,也不会卡死主线程

  2. 文件 hash 的计算,是为了判断文件是否存在,进而实现秒传的功能,所以我们可以参考 布隆过滤器的理念, 牺牲一点点的识别率来换取时间,比如我们可以 抽样算 hash

  3. web-workder 让 hash 计算不卡顿主线程,但是大文件由于切片过多,过多的 HTTP 链接过去,也会把浏览器打挂 , 可以通过控制异步请求的并发数来解决

  4. 并发上传中,报错如何重试,比如每个切片我们允许重试两次,三次再终止

  5. 由于文件大小不一,每个切片的大小设置成固定的也有点略显笨拙,可以参考 TCP 协议的 慢启动策略, 设置一个初始大小,根据上传任务完成的时候,来动态调整下一个切片的大小, 确保文件切片的大小和当前网速匹配

  6. 文件碎片清理:很多人传了一半就离开了,这些切片存在就没意义了,可以考虑定期清理,当然 ,可以使用 node-schedule 来管理定时任务 比如我们每天扫一次 target,如果文件的修改时间是一个月以前了,就直接删除

参考文献

Released under the MIT License.