VUE-多檔案斷點續傳、秒傳、分片上傳

Pseudo發表於2020-07-31
本文為:多檔案斷點續傳、分片上傳、秒傳、重試機制 的更新版,若想看初始版本的實現,請檢視該文章。

凡是要知其然知其所以然

檔案上傳相信很多朋友都有遇到過,那或許你也遇到過當上傳大檔案時,上傳時間較長,且經常失敗的困擾,並且失敗後,又得重新上傳很是煩人。那我們先了解下失敗的原因吧!

據我瞭解大概有以下原因:

  1. 伺服器配置:例如在PHP中預設的檔案上傳大小為8M【post_max_size = 8m】,若你在一個請求體中放入8M以上的內容時,便會出現異常
  2. 請求超時:當你設定了介面的超時時間為10s,那麼上傳大檔案時,一個介面響應時間超過10s,那麼便會被Faild掉。
  3. 網路波動:這個就屬於不可控因素,也是較常見的問題。
基於以上原因,聰明的人們就想到了,將檔案拆分多個小檔案,依次上傳,不就解決以上1,2問題嘛,這便是分片上傳。 網路波動這個實在不可控,也許一陣大風颳來,就斷網了呢。那這樣好了,既然斷網無法控制,那我可以控制只上傳已經上傳的檔案內容,不就好了,這樣大大加快了重新上傳的速度。所以便有了“斷點續傳”一說。此時,人群中有人插了一嘴,有些檔案我已經上傳一遍了,為啥還要在上傳,能不能不浪費我流量和時間。喔...這個嘛,簡單,每次上傳時判斷下是否存在這個檔案,若存在就不重新上傳便可,於是又有了“秒傳”一說。從此這"三兄弟" 便自行CP,統治了整個檔案界。”

注意文中的程式碼並非實際程式碼,請移步至github檢視最新程式碼
https://github.com/pseudo-god...


分片上傳

HTML

原生INPUT樣式較醜,這裡通過樣式疊加的方式,放一個Button.
  <div class="btns">
    <el-button-group>
      <el-button :disabled="changeDisabled">
        <i class="el-icon-upload2 el-icon--left" size="mini"></i>選擇檔案
        <input
          v-if="!changeDisabled"
          type="file"
          :multiple="multiple"
          class="select-file-input"
          :accept="accept"
          @change="handleFileChange"
        />
      </el-button>
      <el-button :disabled="uploadDisabled" @click="handleUpload()"><i class="el-icon-upload el-icon--left" size="mini"></i>上傳</el-button>
      <el-button :disabled="pauseDisabled" @click="handlePause"><i class="el-icon-video-pause el-icon--left" size="mini"></i>暫停</el-button>
      <el-button :disabled="resumeDisabled" @click="handleResume"><i class="el-icon-video-play el-icon--left" size="mini"></i>恢復</el-button>
      <el-button :disabled="clearDisabled" @click="clearFiles"><i class="el-icon-video-play el-icon--left" size="mini"></i>清空</el-button>
    </el-button-group>
    <slot 
    
 //data 資料
 
var chunkSize = 10 * 1024 * 1024; // 切片大小
var fileIndex = 0; // 當前正在被遍歷的檔案下標

 data: () => ({
    container: {
      files: null
    },
    tempFilesArr: [], // 儲存files資訊
    cancels: [], // 儲存要取消的請求
    tempThreads: 3,
    // 預設狀態
    status: Status.wait
  }),
    

一個稍微好看的UI就出來了。

選擇檔案

選擇檔案過程中,需要對外暴露出幾個鉤子,熟悉elementUi的同學應該很眼熟,這幾個鉤子基本與其一致。onExceed:檔案超出個數限制時的鉤子、beforeUpload:檔案上傳之前

fileIndex 這個很重要,因為是多檔案上傳,所以定位當前正在被上傳的檔案就很重要,基本都靠它

handleFileChange(e) {
  const files = e.target.files;
  if (!files) return;
  Object.assign(this.$data, this.$options.data()); // 重置data所有資料

  fileIndex = 0; // 重置檔案下標
  this.container.files = files;
  // 判斷檔案選擇的個數
  if (this.limit && this.container.files.length > this.limit) {
    this.onExceed && this.onExceed(files);
    return;
  }

  // 因filelist不可編輯,故拷貝filelist 物件
  var index = 0; // 所選檔案的下標,主要用於剔除檔案後,原檔案list與臨時檔案list不對應的情況
  for (const key in this.container.files) {
    if (this.container.files.hasOwnProperty(key)) {
      const file = this.container.files[key];

      if (this.beforeUpload) {
        const before = this.beforeUpload(file);
        if (before) {
          this.pushTempFile(file, index);
        }
      }

      if (!this.beforeUpload) {
        this.pushTempFile(file, index);
      }

      index++;
    }
  }
},
// 存入 tempFilesArr,為了上面的鉤子,所以將程式碼做了拆分
pushTempFile(file, index) {
  // 額外的初始值
  const obj = {
    status: fileStatus.wait,
    chunkList: [],
    uploadProgress: 0,
    hashProgress: 0,
    index
  };
  for (const k in file) {
    obj[k] = file[k];
  }
  console.log('pushTempFile -> obj', obj);
  this.tempFilesArr.push(obj);
}

分片上傳

  • 建立切片,迴圈分解檔案即可

      createFileChunk(file, size = chunkSize) {
        const fileChunkList = [];
        var count = 0;
        while (count < file.size) {
          fileChunkList.push({
            file: file.slice(count, count + size)
          });
          count += size;
        }
        return fileChunkList;
      }
  • 迴圈建立切片,既然我們們做的是多檔案,所以這裡就有迴圈去處理,依次建立檔案切片,及切片的上傳。
async handleUpload(resume) {
  if (!this.container.files) return;
  this.status = Status.uploading;
  const filesArr = this.container.files;
  var tempFilesArr = this.tempFilesArr;

  for (let i = 0; i < tempFilesArr.length; i++) {
    fileIndex = i;
    //建立切片
    const fileChunkList = this.createFileChunk(
      filesArr[tempFilesArr[i].index]
    );
      
    tempFilesArr[i].fileHash ='xxxx'; // 先不用看這個,後面會講,佔個位置
    tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
      fileHash: tempFilesArr[i].hash,
      fileName: tempFilesArr[i].name,
      index,
      hash: tempFilesArr[i].hash + '-' + index,
      chunk: file,
      size: file.size,
      uploaded: false,
      progress: 0, // 每個塊的上傳進度
      status: 'wait' // 上傳狀態,用作進度狀態顯示
    }));
    
    //上傳切片
    await this.uploadChunks(this.tempFilesArr[i]);
  }
}
  • 上傳切片,這個裡需要考慮的問題較多,也算是核心吧,uploadChunks方法只負責構造傳遞給後端的資料,核心上傳功能放到sendRequest方法中
 async uploadChunks(data) {
  var chunkData = data.chunkList;
  const requestDataList = chunkData
    .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 {
    await this.sendRequest(requestDataList, chunkData);
  } catch (error) {
    // 上傳有被reject的
    this.$message.error('親 上傳失敗了,考慮重試下呦' + error);
    return;
  }

  // 合併切片
  const isUpload = chunkData.some(item => item.uploaded === false);
  console.log('created -> isUpload', isUpload);
  if (isUpload) {
    alert('存在失敗的切片');
  } else {
    // 執行合併
    await this.mergeRequest(data);
  }
}
  • sendReques。上傳這是最重要的地方,也是容易失敗的地方,假設有10個分片,那我們若是直接發10個請求的話,很容易達到瀏覽器的瓶頸,所以需要對請求進行併發處理。

    • 併發處理:這裡我使用for迴圈控制併發的初始併發數,然後在 handler 函式裡呼叫自己,這樣就控制了併發。在handler中,通過陣列API.shift模擬佇列的效果,來上傳切片。
    • 重試: retryArr 陣列儲存每個切片檔案請求的重試次數,做累加。比如[1,0,2],就是第0個檔案切片報錯1次,第2個報錯2次。為保證能與檔案做對應,const index = formInfo.index; 我們直接從資料中拿之前定義好的index。 若失敗後,將失敗的請求重新加入佇列即可。

      • 關於併發及重試我寫了一個小Demo,若不理解可以自己在研究下,檔案地址:https://github.com/pseudo-god... , 重試程式碼好像被我弄丟了,大家要是有需求,我再補吧!
    // 併發處理
sendRequest(forms, 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 = () => {
      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';
          
          finished++;
          handler();
        })
          .catch(e => {
            // 若暫停,則禁止重試
            if (this.status === Status.pause) return;
            if (typeof retryArr[index] !== 'number') {
              retryArr[index] = 0;
            }

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

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

            // 重試3次
            if (retryArr[index] >= this.chunkRetry) {
              return reject('重試失敗', retryArr);
            }

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

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

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

    // 控制併發
    for (let i = 0; i < this.tempThreads; i++) {
      handler();
    }
  });
}
  • 切片的上傳進度,通過axios的onUploadProgress事件,結合createProgresshandler方法進行維護
// 切片上傳進度
createProgresshandler(item) {
  return p => {
    item.progress = parseInt(String((p.loaded / p.total) * 100));
    this.fileProgress();
  };
}

Hash計算

其實就是算一個檔案的MD5值,MD5在整個專案中用到的地方也就幾點。
  • 秒傳,需要通過MD5值判斷檔案是否已存在。
  • 續傳:需要用到MD5作為key值,當唯一值使用。
本專案主要使用worker處理,效能及速度都會有很大提升.
由於是多檔案,所以HASH的計算進度也要體現在每個檔案上,所以這裡使用全域性變數fileIndex來定位當前正在被上傳的檔案

執行計算hash

正在上傳檔案

// 生成檔案 hash(web-worker)
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;
      if (this.tempFilesArr[fileIndex]) {
        this.tempFilesArr[fileIndex].hashProgress = Number(
          percentage.toFixed(0)
        );
      }

      if (hash) {
        resolve(hash);
      }
    };
  });
}

因使用worker,所以我們不能直接使用NPM包方式使用MD5。需要單獨去下載spark-md5.js檔案,並引入

//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.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
        });
        loadNext(count);
      }
    };
  };
  loadNext(0);
};

檔案合併

當我們的切片全部上傳完畢後,就需要進行檔案的合併,這裡我們只需要請求介面即可
mergeRequest(data) {
   const obj = {
     md5: data.fileHash,
     fileName: data.name,
     fileChunkNum: data.chunkList.length
   };

   instance.post('fileChunk/merge', obj, 
     {
       timeout: 0
     })
     .then((res) => {
       this.$message.success('上傳成功');
     });
 }
Done: 至此一個分片上傳的功能便已完成

斷點續傳

顧名思義,就是從那斷的就從那開始,明確思路就很簡單了。一般有2種方式,一種為伺服器端返回,告知我從那開始,還有一種是瀏覽器端自行處理。2種方案各有優缺點。本專案使用第二種。

思路:已檔案HASH為key值,每個切片上傳成功後,記錄下來便可。若需要續傳時,直接跳過記錄中已存在的便可。本專案將使用Localstorage進行儲存,這裡我已提前封裝好addChunkStorage、getChunkStorage方法。

儲存在Stroage的資料

快取處理

在切片上傳的axios成功回撥中,儲存已上傳成功的切片

 instance.post('fileChunk', formData, )
  .then(res => {
    // 儲存已上傳的切片下標
+ this.addChunkStorage(chunkData[index].fileHash, index);
    handler();
  })

在切片上傳前,先看下localstorage中是否存在已上傳的切片,並修改uploaded

    async handleUpload(resume) {
+      const getChunkStorage = this.getChunkStorage(tempFilesArr[i].hash);
      tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
+        uploaded: getChunkStorage && getChunkStorage.includes(index), // 標識:是否已完成上傳
+        progress: getChunkStorage && getChunkStorage.includes(index) ? 100 : 0,
+        status: getChunkStorage && getChunkStorage.includes(index)? 'success'
+              : 'wait' // 上傳狀態,用作進度狀態顯示
      }));

    }

構造切片資料時,過濾掉uploaded為true的

 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 };
    })
}

垃圾檔案清理

隨著上傳檔案的增多,相應的垃圾檔案也會增多,比如有些時候上傳一半就不再繼續,或上傳失敗,碎片檔案就會增多。解決方案我目前想了2種
  • 前端在localstorage設定快取時間,超過時間就傳送請求通知後端清理碎片檔案,同時前端也要清理快取。
  • 前後端都約定好,每個快取從生成開始,只能儲存12小時,12小時後自動清理
以上2中方案似乎都有點問題,極有可能造成前後端因時間差,引發切片上傳異常的問題,後面想到合適的解決方案再來更新吧。

Done: 續傳到這裡也就完成了。


秒傳

這算是最簡單的,只是聽起來很厲害的樣子。原理:計算整個檔案的HASH,在執行上傳操作前,向服務端傳送請求,傳遞MD5值,後端進行檔案檢索。若伺服器中已存在該檔案,便不進行後續的任何操作,上傳也便直接結束。大家一看就明白
async handleUpload(resume) {
    if (!this.container.files) return;
    const filesArr = this.container.files;
    var tempFilesArr = this.tempFilesArr;

    for (let i = 0; i < tempFilesArr.length; i++) {
      const fileChunkList = this.createFileChunk(
        filesArr[tempFilesArr[i].index]
      );

      // hash校驗,是否為秒傳
+      tempFilesArr[i].hash = await this.calculateHash(fileChunkList);
+      const verifyRes = await this.verifyUpload(
+        tempFilesArr[i].name,
+        tempFilesArr[i].hash
+      );
+      if (verifyRes.data.presence) {
+       tempFilesArr[i].status = fileStatus.secondPass;
+       tempFilesArr[i].uploadProgress = 100;
+      } else {
        console.log('開始上傳切片檔案----》', tempFilesArr[i].name);
        await this.uploadChunks(this.tempFilesArr[i]);
      }
    }
  }
  // 檔案上傳之前的校驗: 校驗檔案是否已存在
  verifyUpload(fileName, fileHash) {
    return new Promise(resolve => {
      const obj = {
        md5: fileHash,
        fileName,
        ...this.uploadArguments //傳遞其他引數
      };
      instance
        .post('fileChunk/presence', obj)
        .then(res => {
          resolve(res.data);
        })
        .catch(err => {
          console.log('verifyUpload -> err', err);
        });
    });
  }
Done: 秒傳到這裡也就完成了。

後端處理

文章好像有點長了,具體程式碼邏輯就先不貼了,除非有人留言要求,嘻嘻,有時間再更新

Node版

請前往 https://github.com/pseudo-god... 檢視

JAVA版

下週應該會更新處理

PHP版

1年多沒寫PHP了,抽空我會慢慢補上來

待完善

  • 切片的大小:這個後面會做出動態計算的。需要根據當前所上傳檔案的大小,自動計算合適的切片大小。避免出現切片過多的情況。
  • 檔案追加:目前上傳檔案過程中,不能繼續選擇檔案加入佇列。(這個沒想好應該怎麼處理。)

更新記錄

元件已經執行一段時間了,期間也測試出幾個問題,本來以為沒BUG的,看起來BUG都挺嚴重

BUG-1:當同時上傳多個內容相同但是檔名稱不同的檔案時,出現上傳失敗的問題。

預期結果:第一個上傳成功後,後面相同的問檔案應該直接秒傳

實際結果:第一個上傳成功後,其餘相同的檔案都失敗,錯誤資訊,塊數不對。

原因:當第一個檔案塊上傳完畢後,便立即進行了下一個檔案的迴圈,導致無法及時獲取檔案是否已秒傳的狀態,從而導致失敗。

解決方案:在當前檔案分片上傳完畢並且請求合併介面完畢後,再進行下一次迴圈。

將子方法都改為同步方式,mergeRequest 和 uploadChunks 方法


BUG-2: 當每次選擇相同的檔案並觸發beforeUpload方法時,若第二次也選擇了相同的檔案,beforeUpload方法失效,從而導致整個流程失效。

原因:之前每次選擇檔案時,沒有清空上次所選input檔案的資料,相同資料的情況下,是不會觸發input的change事件。

解決方案:每次點選input時,清空資料即可。我順帶優化了下其他的程式碼,具體看提交記錄吧。

<input
  v-if="!changeDisabled"
  type="file"
  :multiple="multiple"
  class="select-file-input"
  :accept="accept"
+  οnclick="f.outerHTML=f.outerHTML"
  @change="handleFileChange"/>
重寫了暫停和恢復的功能,實際上,主要是增加了暫停和恢復的狀態

之前的處理邏輯太簡單粗暴,存在諸多問題。現在將狀態定位在每一個檔案之上,這樣恢復上傳時,直接跳過即可

封裝元件

寫了一大堆,其實以上程式碼你直接複製也無法使用,這裡我將此封裝了一個元件。大家可以去github下載檔案,裡面有使用案例 ,若有用記得隨手給個star,謝謝!

偷個懶,具體封裝元件的程式碼就不列出來了,大家直接去下載檔案檢視,若有不明白的,可留言。

元件文件

Attribute

引數型別說明預設備註
headersObject設定請求頭
before-uploadFunction上傳檔案前的鉤子,返回false則停止上傳
acceptString接受上傳的檔案型別
upload-argumentsObject上傳檔案時攜帶的引數
with-credentialsBoolean是否傳遞Cookiefalse
limitNumber最大允許上傳個數00為不限制
on-exceedFunction檔案超出個數限制時的鉤子
multipleBoolean是否為多選模式true
base-urlString由於本元件為內建的AXIOS,若你需要走代理,可以直接在這裡配置你的基礎路徑
chunk-sizeNumber每個切片的大小10M
threadsNumber請求的併發數3 併發數越高,對伺服器的效能要求越高,儘可能用預設值即可
chunk-retryNumber錯誤重試次數3 分片請求的錯誤重試次數

Slot

方法名說明引數備註
header按鈕區域
tip提示說明文字

後端介面文件:按文件實現即可



程式碼地址:https://github.com/pseudo-god...

介面文件地址 https://docs.apipost.cn/view/...

相關文章