一個多文件斷點續傳、分片上傳、秒傳、重試機制的組件

本文爲:多文件斷點續傳、分片上傳、秒傳、重試機制 的更新版,若想看初始版本的實現,請查看該文章。前端

凡是要知其然知其因此然

文件上傳相信不少朋友都有遇到過,那或許你也遇到過當上傳大文件時,上傳時間較長,且常常失敗的困擾,而且失敗後,又得從新上傳非常煩人。那咱們先了解下失敗的緣由吧!vue

據我瞭解大概有如下緣由:ios

  1. 服務器配置:例如在PHP中默認的文件上傳大小爲8M【post_max_size = 8m】,若你在一個請求體中放入8M以上的內容時,便會出現異常
  2. 請求超時:當你設置了接口的超時時間爲10s,那麼上傳大文件時,一個接口響應時間超過10s,那麼便會被Faild掉。
  3. 網絡波動:這個就屬於不可控因素,也是較常見的問題。

基於以上緣由,聰明的人們就想到了,將文件拆分多個小文件,依次上傳,不就解決以上1,2問題嘛,這即是分片上傳。 網絡波動這個實在不可控,也許一陣大風颳來,就斷網了呢。那這樣好了,既然斷網沒法控制,那我能夠控制只上傳已經上傳的文件內容,不就行了,這樣大大加快了從新上傳的速度。因此便有了「斷點續傳」一說。此時,人羣中有人插了一嘴,有些文件我已經上傳一遍了,爲啥還要在上傳,能不能不浪費我流量和時間。喔...這個嘛,簡單,每次上傳時判斷下是否存在這個文件,若存在就不從新上傳即可,因而又有了「秒傳」一說。今後這"三兄弟" 便自行CP,統治了整個文件界。」git


分片上傳

HTML

原生INPUT樣式較醜,這裏經過樣式疊加的方式,放一個Button.github

<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就出來了。web

選擇文件

選擇文件過程當中,須要對外暴露出幾個鉤子,熟悉elementUi的同窗應該很眼熟,這幾個鉤子基本與其一致。onExceed:文件超出個數限制時的鉤子、beforeUpload:文件上傳以前axios

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模擬隊列的效果,來上傳切片。api

    • 重試: retryArr 數組存儲每一個切片文件請求的重試次數,作累加。好比[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次。爲保證能與文件作對應,const index = formInfo.index; 咱們直接從數據中拿以前定義好的index。 若失敗後,將失敗的請求從新加入隊列便可。數組

    • 關於併發及重試我寫了一個小Demo,若不理解能夠本身在研究下,文件地址: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
正在上傳文件
正在上傳文件
// 生成文件 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版

請前往 github.com/pseudo-god/… 查看

JAVA版

下週應該會更新處理

PHP版

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

待完善

  • 切片的大小:這個後面會作出動態計算的。須要根據當前所上傳文件的大小,自動計算合適的切片大小。避免出現切片過多的狀況。

  • 文件追加:目前上傳文件過程當中,不能繼續選擇文件加入隊列。(這個沒想好應該怎麼處理。)

封裝組件

寫了一大堆,其實以上代碼你直接複製也沒法使用,這裏我將此封裝了一個組件。你們能夠去github下載文件,裏面有使用案例 ,如有用記得隨手給個star,謝謝!

偷個懶,具體封裝組件的代碼就不列出來了,你們直接去下載文件查看,如有不明白的,可留言。

組件文檔

Attribute

參數 類型 說明 默認 備註
headers Object 設置請求頭
before-upload Function 上傳文件前的鉤子,返回false則中止上傳
accept String 接受上傳的文件類型
upload-arguments Object 上傳文件時攜帶的參數
with-credentials Boolean 是否傳遞Cookie false
limit Number 最大容許上傳個數 0 0爲不限制
on-exceed Function 文件超出個數限制時的鉤子
multiple Boolean 是否爲多選模式 true
base-url String 因爲本組件爲內置的AXIOS,若你須要走代理,能夠直接在這裏配置你的基礎路徑
chunk-size Number 每一個切片的大小 10M
threads Number 請求的併發數 3
chunk-retry Number 錯誤重試次數 3

Slot

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

後端接口文檔:按文檔實現便可

代碼地址:github.com/pseudo-god/…

接口文檔地址 docs.apipost.cn/view/0e19f1…

本文使用 mdnice 排版

相關文章
相關標籤/搜索