大文件上传的处理方法——切片上传(前端)

9,191 阅读4分钟

本篇介绍了切片上传的基本实现方式(前端),以及实现切片上传后的一些附加功能,切片上传原理较为简单,代码注释比较清晰就不多赘述了,后面的附加功能介绍了实现原理,并贴出了在原本代码上的改进方式。有什么错误希望大佬可以指出,感激不尽。

切片后上传

切片上传的原理较为简单,即获取文件后切片,切片后整理好每个切片的参数并发请求即可。下面直接上代码。

模板部分

<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <el-button @click="handleUpload">上传</el-button>
  </div>
</template>

逻辑部分

<script>
const SIZE = 10 * 1024 * 1024; // 切片大小(10MB)

export default {
  data: () => ({
    // 存放文件信息
    container: {
      file: null
      hash: null
    },
    data: [] // 用于存放加工好的文件切片列表
    hashPercentage: 0 // 存放hash生成进度
  }),
  methods: {
    // 获取上传文件
    handleFileChange(e) {
      const [file] = e.target.files;
      if (!file) {
        this.container.file = null;
        return;
      }
      this.container.file = file;
    },
        
    // 生成文件切片
    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;
    },
        
    // 生成文件hash    
    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);
          }
        };
      });
    },

    // 切片加工(上传前预处理 为文件添加hash等)
    async handleUpload() {
      if (!this.container.file) return;
      // 切片生成
      const fileChunkList = this.createFileChunk(this.container.file);
      // hash生成
      this.container.hash = await this.calculateHash(fileChunkList);
      this.data = fileChunkList.map(({ file },index) => ({
           chunk: file,
           // 这里的hash为文件名 + 切片序号,也可以用md5对文件进行加密获取唯一hash值来代替文件名
           hash: this.container.hash + "-" + index
      }));
      await this.uploadChunks();
    }
      
    // 上传切片
    async uploadChunks() {
     const requestList = this.data
     	// 构造formData
       .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(async ({ formData }) =>
         uploadRequest(formData) // 这里的uploadRequest是你封装好的上传文件切片接口请求方法
       );
     await Promise.all(requestList); // 等待全部切片上传完毕
     await merge(this.container.file.name) // 发送请求合并文件
    },
  }
};
</script>

生成hash

无论是前端还是服务端,都必须要生成文件和切片的 hash,比较简单的做法是我们使用文件名 + 切片下标作为切片 hash,但这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash,所以我们修改一下 hash 的生成规则

这里用到另一个库 spark-md5,它可以根据文件内容计算出文件的 hash 值,另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互

由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts函数用于导入外部脚本,通过它导入 spark-md5

// public/hash.js
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计算hash
      spark.append(e.target.result);
      if (count === fileChunkList.length) {
        self.postMessage({
          percentage: 100,
          // 获取最终hash
          hash: spark.end()
        });
        self.close();
      } else {
        percentage += 100 / fileChunkList.length;
        self.postMessage({
          percentage
        });
        // 递归计算下一个切片
        loadNext(count);
      }
    };
  };
  loadNext(0);
};

总结

  1. 获取上传文件
  2. 文件切片后存入数组 fileChunkList
  3. 生成文件hash(非必须)
  4. 根据文件切片列表生成请求列表
  5. 并发请求
  6. 待全部请求完成后发送合并请求

文件秒传

实际是障眼法,用来欺骗用户的(这么说是不是有点太直白了)。

原理:在文件上传之前先计算出文件的hash,然后发送给后端进行验证,看后端是否存在这个hash,如果存在,则证明这个文件上传过,则直接提示用户秒传成功

// 切片加工(上传前预处理 为文件添加hash等)
async handleUpload() {
  if (!this.container.file) return;
  // 切片生成
  const fileChunkList = this.createFileChunk(this.container.file);
  // hash生成
  this.container.hash = await this.calculateHash(fileChunkList);
    
++  // hash验证 (verify为后端验证接口请求)
++  const { haveExisetd } = await verify(this.container.hash)
++  // 判断
++  if(haveExisetd) {
++   	this.$message.success("秒传:上传成功") 
++    return   
++  } 
   
  this.data = fileChunkList.map(({ file },index) => ({
    chunk: file,
    hash: this.container.hash + "-" + index
  }));
  await this.uploadChunks();
}

暂停上传

原理:将所有的切片存在一个数组中,每当一个切片上传完毕,从数组中移除,这样就可以实现用一个数组只保存上传中的文件。此外,因为要暂停上传,所以需要中断请求 axios中断请求可以利用AbortController

中断请求示例

const controller = new AbortController()

axios({
  signal: controller.signal
}).then(() => {});

// 取消请求
controller.abort()

添加暂停上传功能

// 上传切片
async uploadChunks() {
   // 注意:这里需要将requestList放到全局,这样暂停上传的回调函数中才可以获取到未上传成功切片对应的请求
-- const requestList = this.data
++ this.requestList = this.data
   // 构造formData
   .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(async ({ formData }, index) =>
     uploadRequest(formData).then(() => {
       // 将请求成功的请求剥离出requestList
++     this.requestList.splice(index, 1)
     })
   );
   await Promise.all(this.requestList); // 等待全部切片上传完毕
   await merge(this.container.file.name) // 发送请求合并文件
},
// 暂停上传   
++ handlePause() {
++   this.requestList.forEach((req) => {
++       // 为每个未上传成功的请求新建一个AbortController实例
++       const controller = new AbortController();
++       req.signal = controller.signal
++       controller.abort()
++   })
++ }

恢复上传

原理:上传切片之前,向后台发送请求,接口将已上传的切片列表返回,通过切片hash将后台已存在的切片过滤,只上传未存在的切片

// 切片加工(上传前预处理 为文件添加hash等)
async handleUpload() {
  if (!this.container.file) return;
  const fileChunkList = this.createFileChunk(this.container.file);
  this.container.hash = await this.calculateHash(fileChunkList);
    
   // hash验证 (verify为后端验证接口请求)
-- const { haveExisetd } = await verify(this.container.hash)
++ const { haveExisetd, uploadSuccessList } = await verify(this.container.hash)
  // 判断
  if(haveExisetd) {
    this.$message.success("秒传:上传成功") 
    return   
  } 
   
  this.data = fileChunkList.map(({ file },index) => ({
    chunk: file,
    hash: this.container.hash + "-" + index
  }));
-- await this.uploadChunks();  
++ await this.uploadChunks(uploadSuccessList);
}


// 上传切片
async uploadChunks(uploadSuccessList = []) {
   this.requestList = this.data
   // 过滤出来未上传的切片
++ .filter(({ hash }) => !uploadSuccessList.includes(hash))
   .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(async ({ formData }, index) =>
     uploadRequest(formData).then(() => {
       // 将请求成功的请求剥离出requestList
       this.requestList.splice(index, 1)
     })
   );
   await Promise.all(this.requestList); // 等待全部切片上传完毕
   // 合并之前添加一层验证 验证全部切片传送完毕
   // uploadSuccessList--上传成功的切片 this.requestList--未上传成功的切片 this.data--全部切片
++ if(uploadSuccessList.length + this.requestList.length == this.data.length){
      await merge(this.container.file.name) // 发送请求合并文件
++ }
},
    
// 暂停上传   
handlePause() {
   this.requestList.forEach((req) => {
       // 为每个请求新建一个AbortController实例
       const controller = new AbortController();
       req.signal = controller.signal
       controller.abort()
   })
}

// 恢复上传
++ async handleRecovery() {
++    //获取已上传切片列表 (verify为后端验证接口请求)
++    const { uploadSuccessList } = await verify(this.container.hash)
++    await uploadChunks(uploadSuccessList)
++ }

添加功能总结

1.文件秒传其实就是一个简单的验证,把文件的hash发送给后端,后端验证是否存在该文件后将结果返回,如果存在则提示文件秒传成功

2.断点传送分为两步,暂停上传恢复上传暂停上传是通过获取到未上传完毕切片列表(完整切片列表剥离请求已完成的切片后形成),对列表请求进行请求中断实现的。恢复上传是在上传文件之前,将文件的hash发送给后端,后端返回已经上传完毕的切片列表,然后根据切片hash将后端返回的切片列表中的切片过滤出去,只上传未上传完成的切片。

文章参考

# 请你实现一个大文件上传和断点续传