多文件斷點續傳、分片上傳、秒傳、重試機制-持續更新

在掘金白嫖許久,想來應該回饋下,爲了能夠繼續白嫖😁前端

背景

很早以前就在掘金看到過關於實現斷點續傳的文章,但不曾實踐過,正好最近項目中也遇到了此場景,便去重溫了一遍,從頭到底作了實現。ios

技術棧:VUE+Elementui+localstorage+Workerweb

寄語:本人並不是"巨人",只是有幸站在了巨人的肩膀axios

請先閱讀如下博文:數組

juejin.im/post/5dff8a… juejin.im/post/5e367f…瀏覽器

總結

先寫寫總結,我的認爲,本項目主要難點在於處理多個文件上傳時,如何將每一個文件的狀態及進度對應到相關的界面展現中。繞了不少坑。緩存

作到重試時,卡了半天,實在想不明白,最後去廁所呆了會,而後就很快寫出來了。bash

注意:如下代碼算是僞代碼,不建議直接使用到項目中,只是方便講解邏輯。正式代碼,我須要再次整理下,本週會更新的。爲了漲粉,先發布文章吧,哈哈。

思路

  • 文件上傳邏輯:遍歷所選文件-》建立切片-》計算HASH-》請求文件校驗接口,是否已存在該文件-》若存在-》結束-》若不存在,進行切片上傳-》-請求合併接口-》結束
  • 斷點續傳邏輯:斷點續傳通常有兩種方式,一爲服務器存儲切片數,優勢是用戶換個瀏覽器也能續傳。二爲瀏覽器端存儲:彷佛沒啥特別的優勢,實現起來簡單些吧,缺點卻是挺多,不巧的是本項目就是在瀏覽器端存儲切片。哈哈哈
    • 思路:存儲已上傳的切片到localstorage,以hash爲key值->每次運行前,先判斷緩存中是否存在,存在的話,跳過已上傳的便可。

詳細思路各位可參考上面2篇文章,再也不贅述!服務器

前端部分

//簡單粗暴
<input type="file" multiple @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
<el-button @click="handleResume">恢復</el-button>
<el-button @click="handlePause">暫停</el-button>

//js
const SIZE = 50 * 1024 * 1024; // 切片大小, 1M
var fileIndex = 0; // 當前正在被遍歷的文件下標

export default {
  name: 'SimpleUploaderContainer',
  data: () => ({
    container: {
      hashArr: [], // 存儲已計算完成的hash
      data: []
    },
    tempFilesArr: [], // 存儲files信息
    uploadMax: 3, // 上傳時最大切片的個數,
    cancels: [] // 存儲要取消的請求
  })
  }
複製代碼
  • handleFileChange方法實現,因fileList對象爲只讀屬性,因此須要須要拷貝一份filelist數據
handleFileChange(e) {
  const files = e.target.files;
  console.log('handleFileChange -> file', files);
  if (!files) return;
  Object.assign(this.$data, this.$options.data()); // 重置data全部數據
  fileIndex = 0; // 重置文件下標

  this.container.files = files;
  // 拷貝filelist 對象
  for (const key in this.container.files) {
    if (this.container.files.hasOwnProperty(key)) {
      const file = this.container.files[key];
      var obj = { statusStr: '正在上傳', chunkList: [], uploadProgress: 0, hashProgress: 0 };
      for (const k in file) {
        obj[k] = file[k];
      }
      this.tempFilesArr.push(obj);
    }
  }
}
複製代碼
  • 文件上傳

    建立切片-》-》計算HASH-》判斷是否爲秒傳-》上傳切片-》存儲已上傳的切片下標。 hash計算方式也是經過worker處理。併發

async handleUpload() {
     if (!this.container.files) return;
     const filesArr = this.container.files;
     var tempFilesArr = this.tempFilesArr;

     console.log('handleUpload -> filesArr', filesArr);
     for (let i = 0; i < filesArr.length; i++) {
       fileIndex = i;
       const fileChunkList = this.createFileChunk(filesArr[i]);
       // hash校驗,是否爲秒傳
       const hash = await this.calculateHash(fileChunkList, filesArr[i].name);
       console.log('handleUpload -> hash', hash);
       const verifyRes = await this.verifyUpload(filesArr[i].name, hash);
       if (!verifyRes.data.presence) {
         this.$message('秒傳');
         tempFilesArr[i].statusStr = '已秒傳';
         tempFilesArr[i].uploadProgress = 100;
       } else {
         console.log('開始上傳文件----》', filesArr[i].name);
         const getChunkStorage = this.getChunkStorage(hash);
         tempFilesArr[i].fileHash = hash; // 文件的hash,合併時使用
         tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
           fileHash: hash,
           fileName: filesArr[i].name,
           index,
           hash: hash + '-' + index,
           chunk: file,
           size: file.size,
           uploaded: getChunkStorage && getChunkStorage.includes(index), // 標識:是否已完成上傳
           progress: getChunkStorage && getChunkStorage.includes(index) ? 100 : 0,
           status: getChunkStorage && getChunkStorage.includes(index) ? 'success' : 'wait' // 上傳狀態,用做進度狀態顯示
         }));

         console.log('handleUpload -> this.chunkData', tempFilesArr[i]);

         await this.uploadChunks(this.tempFilesArr[i]);
       }
     }
   }
   
   // 建立文件切片
   createFileChunk(file, size = SIZE) {
     const fileChunkList = [];
     var count = 0;
     while (count < file.size) {
       fileChunkList.push({
         file: file.slice(count, count + size)
       });
       count += size;
     }
     return fileChunkList;
   }
   
   
     // 存儲已上傳完成的切片下標
   addChunkStorage(name, index) {
     const data = [index];
     const arr = getObjArr(name);
     if (arr) {
       saveObjArr(name, [...arr, ...data]);
     } else {
       saveObjArr(name, data);
     }
   },
   // 獲取已上傳完成的切片下標
   getChunkStorage(name) {
     return getObjArr(name);
   }
   
   // 生成文件 hash(web-worker)
   calculateHash(fileChunkList, name) {
     return new Promise((resolve) => {
       this.container.worker = new Worker('./hash/md5.js');
       this.container.worker.postMessage({ fileChunkList });
       this.container.worker.onmessage = (e) => {
         const { percentage, hash } = e.data;
         //當時想將每一個文件的hash放在同一個節目展現,因此就存在了一個數組裏
         this.tempFilesArr[fileIndex].hashProgress = percentage;
         if (hash) {
           resolve(hash);
         }
       };
     });
   }
   
複製代碼

uploadChunks 上傳切片方法,這個當時想了1天,才作出來

// 將切片傳輸給服務端
   async uploadChunks(data) {
     var chunkData = data.chunkList;
     const requestDataList = chunkData
       .filter(({ uploaded }) => !uploaded)
       .map(({ fileHash, chunk, fileName, index }) => {
         const formData = new FormData();
         formData.append('md5', fileHash);
         formData.append('file', chunk);
         formData.append('fileName', index);
         return { formData, index, fileName };
       });

     try {
       const ret = await this.sendRequest(requestDataList, chunkData);
       console.log('uploadChunks -> chunkData', chunkData);
       console.log('ret', ret);
       data.statusStr = '上傳成功';
     } catch (error) {
       // 上傳有被reject的
       data.statusStr = '上傳失敗,請重試';
       this.$message.error('親 上傳失敗了,考慮重試下呦');
       return;
     }

     // 合併切片
     const isUpload = chunkData.some((item) => item.uploaded === false);
     console.log('created -> isUpload', isUpload);
     if (isUpload) {
       alert('存在失敗的切片');
     } else {
       // 執行合併
       await this.mergeRequest(data);
     }
複製代碼

sendRequest 併發上傳切片+重試機制

重試機制參考的也是上面博文:

  • 請求出錯將失敗的任務放到隊列中
  • 數組存儲每一個文件hash請求的重試次數,作累加,好比[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次。超過3次中止上傳,拋出失敗

併發:經過for循環控制起始值,在函數體內進行遞歸調用,便達到了併發的效果。

// 併發處理
   sendRequest(forms, chunkData) {
     console.log('sendRequest -> forms', forms);
     console.log('sendRequest -> chunkData', chunkData);
     var finished = 0;
     const total = forms.length;
     const that = this;
     const retryArr = []; // 數組存儲每一個文件hash請求的重試次數,作累加 好比[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次

     return new Promise((resolve, reject) => {
       const handler = () => {
         console.log('handler -> forms', forms);
         if (forms.length) {
           // 出棧
           const formInfo = forms.shift();
           const formData = formInfo.formData;
           const index = formInfo.index;

           instance
             .post('fileChunk', formData, {
               onUploadProgress: that.createProgresshandler(chunkData[index]),
               cancelToken: new CancelToken((c) => this.cancels.push(c)),
               timeout: 0
             })
             .then((res) => {
               console.log('handler -> res', res);
               // 更改狀態
               chunkData[index].uploaded = true;
               chunkData[index].status = 'success';

               // 存儲已上傳的切片下標
               this.addChunkStorage(chunkData[index].fileHash, index);

               finished++;
               handler();
             })
             .catch((e) => {
               console.warn('出現錯誤', e);
               console.log('handler -> retryArr', retryArr);
               if (typeof retryArr[index] !== 'number') {
                 retryArr[index] = 0;
               }

               // 更新狀態
               chunkData[index].status = 'warning';

               // 累加錯誤次數
               retryArr[index]++;

               // 重試3次
               if (retryArr[index] >= 3) {
                 console.warn(' 重試失敗--- > handler -> retryArr', retryArr, chunkData[index].hash);
                 return reject('重試失敗', retryArr);
               }

               console.log('handler -> retryArr[finished]', `${chunkData[index].hash}--進行第 ${retryArr[index]} '次重試'`);
               console.log(retryArr);

               this.uploadMax++; // 釋放當前佔用的通道

               // 將失敗的從新加入隊列
               forms.push(formInfo);
               handler();
             });
         }

         console.log('handler -> total', total);
         console.log('handler -> finished', finished);

         if (finished >= total) {
           resolve('done');
         }
       };

       // 控制併發
       for (let i = 0; i < this.uploadMax; i++) {
         handler();
       }
     });
   }
複製代碼

進度處理

// 切片上傳進度
createProgresshandler(item) {
 return (p) => {
   item.progress = parseInt(String((p.loaded / p.total) * 100));
   this.fileProgress();
 };
}
   
// 文件總進度
fileProgress() {
//經過全局變量 fileIndex 定位當前正在傳輸的文件。
 const currentFile = this.tempFilesArr[fileIndex];
 const uploadProgress = currentFile.chunkList.map((item) => item.size * item.progress).reduce((acc, cur) => acc + cur);
 const currentFileProgress = parseInt((uploadProgress / currentFile.size).toFixed(2));
 currentFile.uploadProgress = currentFileProgress;
}
複製代碼

mergeRequest 合併切片

mergeRequest(data) {
     const obj = {
       md5: data.fileHash,
       fileName: data.name,
       fileChunkNum: data.chunkList.length
     };

     instance.post('fileChunk/merge', obj, 
       {
         timeout: 0
       })
       .then((res) => {
         // 清除storage
         clearLocalStorage(data.fileHash);
         this.$message.success('上傳成功');
       });
   }
複製代碼

handlePause 暫停

本項目使用的是axios,暫停的關鍵就是取消當前的請求,axios也提供了方法,咱們須要簡單處理下。

handlePause() {
     while (this.cancels.length > 0) {
       this.cancels.pop()('取消請求');
     }
   }
   
//在併發請求除,存儲了當前正在傳輸的請求,調用每一個請求的cancels方法便可

![](https://user-gold-cdn.xitu.io/2020/7/7/17329dbc5014e7df?w=1342&h=286&f=png&s=54256)
複製代碼
相關文章
相關標籤/搜索