问题分析

如果我们将这个文件拆分,将一次性上传大文件拆分成多个上传小文件的请求,因为请求是可以并发的,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始,这样不就可以解决大文件上传的问题了!

大文件上传需要实现下面几个需求:

1.文件切片    2.断点续传   3.支持显示上传进度和暂停上传

一、文件切片

在 JavaScript 中,文件 File 对象是 Blob 对象的子类,Blob 对象包含一个重要的方法 slice,通过这个方法,我们就可以对二进制文件进行拆分。

createFileChunk(file, size = chunkSize) {
      const fileChunkList = [];
      let cur = 0;
      while (cur < file.size) {
        fileChunkList.push({
          file: file.slice(cur, cur + size),
        });
        cur += size;
      }
      return fileChunkList;
    },

// 两个参数 源文件 切片大小  用slice进行切片  
// cur是切片初始化大小0  cur += size 和 file.slice(cur,cur+size) 
// 进行切片,最后把所有切片放到一个数组里面

二、计算文件hash值

这里使用 spark-md5.min.js和web-worker在worker线程计算hash值,主线程进行上传切片的操作。

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

(1)web-worker计算hash值
self.importScripts("./spark-md5.min.js"); 
// 利用importScripts引进spark-md5.min.js

self.onmessage = (e) => {
  // 当web-work接受消息时,执行这个函数
  const { fileChunkList } = e.data; // 解构
  // 创建一个名为spark的新SparkMD5.ArrayBuffer对象。
  // ArrayBuffer是JavaScript的一个对象,用于表示一组字节。
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0;
  let count = 0;  // 切片数量
  const loadNext = (index) => {
    // 创建了一个FileReader对象,读取文件
    const reader = new FileReader();
    // 将每一个切片文件读成ArrayBuffer对象
    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(),
        });  // 将进度条和总hash值发送给主线程
        self.close();  // 关闭web-worker线程
      } else {
        // 如果不相等,继续处理下一个模块
        percentage += 100 / fileChunkList.length;
        self.postMessage({
          percentage,
        });
        loadNext(count);
      }
    };
  };
  loadNext(count);
};
 (2)requestIdleCallback方式

借鉴react的fiber架构(利用浏览器帧与帧之间的空闲时间)计算hash
但是这块可能会阻塞到其他函数内部回调函数的执行,打乱执行顺序

import sparkMD5 from 'spark-md5'
async calculateHashIdle(){
  const chunks = this.chunks
  return new Promise(resolve => {
    const spark = new sparkMD5.ArrayBuffer()
    let count = 0
    const appendToSpark = async file=>{
      return new Promise(resolve=>{
        const reader = new FileReader()
        reader.readAsArrayBuffer(file)
        reader.onload = e=>{
          spark.append(e.target.result)
          resolve()
        }
      })
    }
    const workLoop = async deadline=>{
      //timeRemaining获取当前帧的剩余时间
      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)
  })
}
(3) 抽样Hash

首尾全要,中间取部分去计算hash。这儿遇到的问题是有可能不同文件hash相同,比如有些图片仅有几像素内容不同

import sparkMD5 from 'spark-md5'
async calculateHashSample(){
  return new Promise(resolve=>{
    const spark = new sparkMD5.ArrayBuffer()
    const reader = new FileReader()
    const file = this.file
    const size = file.size
    const offset = 2*1024*1024
    //第一个2M,最后一个区数据块全要
    const chunks = [file.slice(0,offset)]
    let cur = offset
    while(cur<size){
      if(cur+offset>=size){
        //最后一个区块
        chunks.push(file.slice(cur,cur+offset))
      }else{
        //中间区块,取前中后各2个字节
        const mid = cur+offset/2
        const 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
    }
    reader.readAsArrayBuffer(new Blob(chunks))
    reader.onload = e=>{
      spark.append(e.target.result)
      this.hashProgress = 100
      resolve(spark.end())
    }
  })
}
 (4)传递接受函数
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.toFixed(0);
          if (hash) {
            resolve(hash);
          }
        };
      });
    },

spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash

三、切片上传

(1)验证文件是否已经存在,存在相当于秒传
/**
 * 返回值说明
 * shouldUpload:标识这个文件是否还需要上传
 * uploadedList: 服务端存在该文件的切片List
 */
const { shouldUpload, uploadedList } = await verifyUpload(
  container.file.name,
  container.hash
)

如果 shouldUpload 为 false,则表明这个文件不需要上传,提示:秒传成功。

(2)上传除了 uploadedList 之外的文件切片,断点续传
 /**
* 上传切片,同时过滤已上传的切片
* uploadedList:已经上传了的切片,这次不用上传了
*/
async function uploadChunks(uploadedList = []) {
  console.log(uploadedList, 'uploadedList')
  const requestList = data.value
    .filter(({ hash }) => !uploadedList.includes(hash))
    .map(({ chunk, hash, index }) => {
      const formData = new FormData()
      // 切片文件
      formData.append('chunk', chunk)
      // 切片文件hash
      formData.append('hash', hash)
      // 大文件的文件名
      formData.append('filename', container.file.name)
      // 大文件hash
      formData.append('fileHash', container.hash)
      return { formData, index }
    })
    .map(async ({ formData, index }) =>
      request({
        url: 'http://localhost:9999',
        data: formData,
        onProgress: createProgressHandler(index, data.value[index]),
        requestList: requestListArr.value,
      })
    )
  // 并发切片
  await Promise.all(requestList)
  // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时
  // 切片并发上传完以后,发个请求告诉后端:合并切片
  if (uploadedList.length + requestList.length === data.value.length) {
    // ok,都上传完了,请求合并文件
    mergeRequest()
  }
}

四、文件合并(3种方案)

1、前端发送切片完成后,发送一个合并请求,后端收到请求后,将之前上传的切片文件合并。
2、后台记录切片文件上传数据,当后台检测到切片上传完成后,自动完成合并。
3、创建一个和源文件大小相同的文件,根据切片文件的起止位置直接将切片写入对应位置。

五、异步任务并发控制

上面切片上传采用promise.all的方式将所有异步任务同时触发,请求过多可能会造成页面假死、阻塞流程、报错等问题。
这儿限定每次请求只能进行固定次数的异步任务,每请求成功一个则新增一个,确保并发的数量固定。此外,针对可能的报错失败重试三次的操作,若重试三次都报错,则流程终止。

//上传切片
async uploadChunks(){
  const requests = this.chunks
    .map((chunk,index)=>{
      const form = new FormData()
      form.append('chunk',chunk.chunk)
      form.append('hash',chunk.hash)
      form.append('name',chunk.name)
      return {form,index:chunk.index,error:0}
    })
  //发起批量请求
  await sendRequest(requests)
  //所有切片上传完毕,通知后台进行切片内容合并,生成完整图片
  await this.mergeRequest()
}
async sendRequest(chunks){
 return new Promise((resolve,reject)=>{
   const len = chunks.length
   //限制每次最多只能同时发起4次请求
   let limit = len > 4 ? 4 : len
   let counter = 0
   let isStop = false
   const start = async () => {
     if(isStop) return
     const task = chunks.shift()
     if(task){
       const {form,index} = task
       try{
         await http.post('/uploadFile',form,{
           onUploadProgress:progress=>{
             this.chunks[index].progress = (((progress.loaded/progress.total)*100).toFixed(2))
           }
         })
         if(counter==len-1){
           resolve()
         }else{
           counter++
           //启动下一个任务
           start()
         }
       }catch(e){
         this.chunks[index].progress = -1
         if(task.error<3){
           task.error++
           chunks.unshift(task)
           start()
         }else{
           //错误三次
           isStop = true
           reject()
         }
       }
     }
   }
   while(limit>0){
     //启动limit个任务
     //模拟下延迟任务
     setTimeout(()=>{
       start()
     },Math.random()*2000)
     limit-=1
   }
 })
}

六、FileReader讲解

const reader = new FileReader();
方法 (参数:File或Blob对象)

reader.readAsArrayBuffer(file) 异步按字节读取文件内容,结果用ArrayBuffer对象表示
reader.readAsBinaryString(file) 异步按字节读取文件内容,结果为文件的二进制串
reader.readAsDataURL(file) 异步读取文件内容,结果用data:url(即Base64格式)的字符串形式表示
reader.readAsText(file, encoding) 异步按字符读取文件内容,结果用字符串形式表示
reader.abort() 终止文件读取操作

属性

reader.error 一个DOMException,表示在读取文件时发生的错误
reader.readyState 表示FileReader状态的数字(0: 还没有加载数据、1:正在加载、 2:已完成全部的读取请求)
reader.result 文件的内容。该属性仅在读取操作完成后才有效,数据的格式取决于使用哪个方法来启动读取操作。

事件名称

onabort 当读取操作被中止时调用

onerror 当读取操作发生错误时调用

onload 当读取操作成功完成时调用

onloadend 当读取操作完成时调用,不管是成功还是失败

onloadstart 当读取操作将要开始之前调用

onprogress 在读取数据过程中周期性调用

七、FormData讲解

FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 “multipart/form-data”,它会使用和表单一样的格式。
FormData的最大优点就是,比起普通的ajax, 使用FormData我们可以异步上传一个二进制文件,而这个二进制文件,就是我们上面讲的Blob对象。
 

const formData = new FormData()
方法

formData.append() 向 FormData 中添加新的属性值,FormData 对应的属性值存在也不会覆盖原值,而是新增一个值,如果属性不存在则新增一项属性值。
formData.delete() 从 FormData 对象里面删除一个键值对。
formData.entries() 返回一个包含所有键值对的iterator对象。
formData.get() 返回在 FormData 对象中与给定键关联的第一个值。

八、类型转换

1、File文件转换为base64
const fileToBase64 = (file, callback) => {
            const reader = new FileReader();
            reader.onload = function(e) {
                if(typeof callback === 'function'){
                    callback(e.target.result);
                }else{
                    console.log(e.target.result);
                }
            }
            reader.readAsDataURL(file);
        };
2、File转Blob
const fileToBlob = (file, callback) => {
            const type = file.type;
            const reader = new FileReader();
            reader.onload = function(evt) {
                const blob = new Blob([evt.target.result], {type});
                if(typeof callback === 'function') {
                    callback(blob)
                } else {
                    console.log("我是 blob:", blob);
                }
            };
            reader.readAsDataURL(file);
        };
3、Base64 转 File
const base64ToFile = (base64, fileName) => {
        let arr = base64.split(","),
          type = arr[0].match(/:(.*?);/)[1],
          bstr = atob(arr[1]),
          n = bstr.length,
          u8arr = new Uint8Array(n);
        while (n--) {
          u8arr[n] = bstr.charCodeAt(n);
        }
        return new File([u8arr], fileName, { type });
      };
4、Base64 转 Blob
const base64ToBlob = (base64) => {
        let arr = base64.split(","),
          type = arr[0].match(/:(.*?);/)[1],
          bstr = atob(arr[1]),
          n = bstr.length,
          u8arr = new Uint8Array(n);
        while (n--) {
          u8arr[n] = bstr.charCodeAt(n);
        }
        return new Blob([u8arr], { type });
      };
5、Blob 转 File
 // 文件类型转Blob 
const fileToBlob = (file, callback) => {
            const type = file.type;
            const reader = new FileReader();
            reader.onload = function(evt) {
                const blob = new Blob([evt.target.result], {type});
                if(typeof callback === 'function') {
                  callback(blob)
               } else {
                 console.log("我是 blob:", blob);
            }
        };
      reader.readAsDataURL(file);
   };

 // Blob 转 File
  const blobToFile = (blob, fileName) => {
      const file = new File([blob], fileName, {type: blob.type});
      return file;
  }

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐