前言
Element Plus 中已提供了一個 upload 元件來實現普通檔案上傳的需求,但是遇到大檔案上傳的時候,就有點顯得力不從心了:
由於檔案大,上傳一般都需要數分鐘時間,使用者一般都無法忍受乾等這麼長時間
若中途斷網或瀏覽器崩潰,只能重新從頭開始再上傳一次,導致使用者心態崩潰
本文主要就基於 Element plus 的 upload 元件來封裝實現一個支援分片上傳的上傳控制元件。
原理解析
分片上傳
其原理其實就是在客戶端將檔案分割成多個小的分片,然後再將這些分片一片一片的上傳給服務端,服務端拿到所有分片後再將這些分片合併起來還原成原來的檔案。
那服務端怎麼知道我合併出來的檔案是否和服務端上傳的檔案完全一樣呢?這就需要用到檔案的MD5值了。檔案的MD5值就相當於是這個檔案的“數字指紋”,只有當兩個檔案內容完全一樣時,他們的MD5值纔會一樣。所以在上傳檔案前,客戶端需要先計算出檔案的MD5值,並且把這MD5值傳遞給服務端。服務端在合併出文件後,在計算合併出的檔案的MD5值,與客戶端傳遞過來的進行比較,如果一致,則說明上傳成功,若不一致,則說明上傳過程中可能出現了丟包,上傳失敗。
斷點續傳
斷點續傳其實是利用分片上傳的特性,上次上傳中斷時,已經有部分分片已上傳到服務端,這部分就可以不用重複上傳了。
檔案秒傳
檔案秒傳其實是利用檔案的MD5值作為檔案的身份標識,服務端發現要上傳的檔案的MD5與附件庫中的某個檔案的MD5值完全一樣,則要上傳的檔案已在附件庫中,不用再重複上傳。
程式碼實現
計算檔案 MD5
計算檔案的 MD5 值,我們直接用現成的三方外掛 SparkMD5 即可。
由於計算 MD5 是一個比較耗時的操作,我們用 Web Workers 把它放到後臺去執行。
// file-md5-worker.ts import { UploadRawFile } from 'element-plus'; import SparkMD5 from 'spark-md5'; type MD5MessageType = { file?: UploadRawFile; uid: number; cancel?: boolean; }; // 正在處理的檔案記錄,當值為 false 時,表示被終止了。 const progressingFilesMap = new Map<number, boolean>(); // 用 web worker 來處理檔案 MD5 的計算 self.addEventListener('message', (e: MessageEvent) => { const { file, uid, cancel } = e.data as MD5MessageType; if (cancel && progressingFilesMap.has(uid)) { // 將正在處理的檔案標識設定成 false,以備在 getFileMd5 方法中進行終止 progressingFilesMap.set(uid, false); } else if (file) { // 開始計算 DM5 getFileMd5(file, uid) .then((md5) => { // 計算完成,傳送通知 self.postMessage({ status: 'success', uid, md5, }); }) .catch((error) => { self.postMessage({ status: 'failed', uid, error, }); }); } }); /** * 獲取檔案MD5,採用分片的模式讀取檔案,最後合併生成 MD5 * @param file * @returns {Promise<unknown>} */ const getFileMd5 = (file: UploadRawFile, uid: number) => { const fileReader = new FileReader(); const chunkSize = 1024 * 1024; // 分片大小 const chunks = Math.ceil(file.size / chunkSize); // 總分片數 const updateProgress = getProgress(chunks, uid); let currentChunk = 0; const spark = new SparkMD5(); progressingFilesMap.set(uid, true); return new Promise((resolve, reject) => { fileReader.onload = (e) => { spark.appendBinary(e.target!.result as string); // append binary string updateProgress(++currentChunk); if (progressingFilesMap.get(uid) === false) { // 被終止,則移除 progressingFilesMap.delete(uid); reject('Be cancelled'); } else if (currentChunk < chunks) { // 未結束,則處理下一個分片 loadNext(); } else { // 處理完成 progressingFilesMap.delete(uid); resolve(spark.end()); } }; fileReader.onerror = (e) => { reject(e); }; const loadNext = () => { const start = currentChunk * chunkSize; const end = start + chunkSize >= file.size ? file.size : start + chunkSize; fileReader.readAsBinaryString(file.slice(start, end)); }; loadNext(); }); }; /** * 生成進度 * @param totalChunk 總片數 * @param uid 檔案的 uid * @returns 更新進度,引數 processedChunk 為已處理的分片數 */ const getProgress = (totalChunk: number, uid: number) => { let preTime = 0, percent = 0; return (processedChunk: number) => { const now = Date.now(); // 控制觸發間隔大於 500 毫秒 // 當已經結束,就馬上觸發 if (now - preTime > 500 || processedChunk >= totalChunk) { percent = Math.min(1, processedChunk / totalChunk) * 100; percent = parseFloat(percent.toFixed(1)); // 傳送進度通知 self.postMessage({ status: 'progress', uid, percent, }); preTime = now; } }; };
在主程式中,我們使用方式如下:
const worker = new Worker( new URL('./file-md5.worker', import.meta.url), { type: 'module' } ); // 監聽 worker 的訊息 worker.addEventListener('message', async (e: MessageEvent) => { const data = e.data; if (data.status === 'success') { // 計算成功,開始上傳 } else if (data.status === 'progress') { // 計算過程中,更新進度 } }); // 開始計算 worker.postMessage({ file, uid: file.uid, }); // 終止計算 worker.postMessage({ uid, cancel: true, });
分片上傳
透過分析 Element Plus 的 upload 元件,我們可以透過個性化它的上傳請求屬性 http-request
,在裡面來實現整個分片的請求過程。
根據上面的原理分析,整個分片上傳的過程應該是:
計算檔案 MD5
傳送查詢檔案上傳狀態請求
根據響應,上傳還未上傳的分片
所有分片上傳結束後,傳送一個結束上傳的請求,告知服務端
upload 控制元件的配置如下:
<template> <el-upload :action="action" v-model:file-list="fileList" :http-request="handleRequest" > </el-upload> </template>
handleRequest
方法的實現如下:
const doUploadMap = new Map<number, (md5: string) => void>(); // 記錄具體某個檔案的上傳執行方法 const md5Progress = ref<Record<number, number>>({}); // 記錄檔案的上傳進度 const md5Map = new Map<number, string>(); // 記錄檔案的 MD5 值 const handleRequest = (options: UploadRequestOptions) => { const file = options.file as UploadRawFile; // 上傳檔案方法 const doUploadFn = async (md5: string) => { // 將 MD5 值放到請求引數裡 options.data.fileMD5 = md5; const uploadFile = fileList.value.find((f) => f.uid === file.uid)!; // 查詢檔案上狀態 const { fileFinished, existFileParts, attachGuid, uploadDate, downloadUrl, } = ( await queryFileStatus(options, { fileMD5: md5, fileName: file.name, fileSize: file.size, lastModifiedDate: new Date(file.lastModified).toISOString(), uploadGuid: file.uid, }) ).result; if (fileFinished) { // 已上傳過,直接結束(秒傳) uploadFile.percentage = 100; updateFileStatusToSuccess(uploadFile, { attachGuid, downloadUrl, uploadDate, }); } else { // 上傳分片 await uploadChunks(options, existFileParts.split(',')); // 傳送結束上傳請求 const { uploadFailed, failedMsg, result } = await finishedChunkUpload( options, { fileMD5: md5, fileName: file.name, fileSize: file.size, lastModifiedDate: new Date(file.lastModified).toISOString(), uploadGuid: file.uid, contentType: file.type, } ); const { attachGuid, uploadDate, downloadUrl } = result; if (uploadFailed) { uploadFile.status = 'fail'; } else { updateFileStatusToSuccess(uploadFile, { attachGuid, downloadUrl, uploadDate, }); } } }; // 獲取檔案 MD5 const md5 = md5Map.get(file.uid); if (md5) { // MD5 已計算過,直接用 md5Progress.value[file.uid] = 100; doUploadFn(md5); } else { // 通知 worker 計算 MD5 worker.postMessage({ file, uid: file.uid, }); // 將上傳方法快取起來,以備 MD5 計算完成後呼叫 doUploadMap.set(file.uid, doUploadFn); } }; // worker 的監聽 worker.addEventListener('message', async (e: MessageEvent) => { const data = e.data; if (data.status === 'success') { // MD5 計算完成, 開始上傳 md5Map.set(data.uid, data.md5); await doUploadMap.get(data.uid)?.(data.md5); doUploadMap.delete(data.uid); } else if (data.status === 'progress') { // 計算中,更新進度 md5Progress.value[data.uid] = data.percent; } });
其中查詢檔案上傳狀態方法 queryFileStatus
定義如下:
const queryFileStatus = ( options: UploadRequestOptions, params: Record<string, any> ) => { const clonedOptions = cloneDeep(options); Object.assign(clonedOptions.data, params); clonedOptions.data.action = 'queryFileStatus'; // 傳送請求 return ajax<{ result: { fileFinished: boolean; existFileParts: string; } }>(clonedOptions); };
結束上傳方法 finishedChunkUpload
的定義如下:
const finishedChunkUpload = ( options: UploadRequestOptions, params: Record<string, any> ) => { const clonedOptions = cloneDeep(options); Object.assign(clonedOptions.data, params); clonedOptions.data.action = 'finishUpload'; // 傳送請求 return ajax<{ uploadFailed: boolean; failedMsg?: string; result: { attachGuid: string; uploadDate: string; downloadUrl: string; }; }>(clonedOptions); };
上傳分片方法 uploadChunks
定義如下:
const uploadChunks = ( options: UploadRequestOptions, uploadedList: string[] = [] ) => { const rawFile = options.file as UploadRawFile; // 建立分片列表 const chunkList = createFileChunk(rawFile, realChunkSize.value); const totalChunkNum = chunkList.length; const uploadFile = fileList.value.find((f) => f.uid === file.uid)!; uploadFile.status = 'uploading'; uploadFile.percentage = (uploadedList.length / totalChunkNum) * 100; return new Promise((resolve) => { // 構建分片的請求列表 const requestList = chunkList .filter(({ index }) => !uploadedList.includes(index)) // 過濾掉已上傳的分片(斷點續傳) .map(({ file, index }) => { const clonedOptions = cloneDeep(options); // 重寫上傳進度的計算實現 clonedOptions.onProgress = (evt: UploadProgressEvent) => { const percent = evt.percent; const prePercent = chunkProgressMap.get(Number.parseInt(index, 10)) ?? 0; chunkProgressMap.set(Number.parseInt(index, 10), percent); uploadFile.percentage = Math.min( 100, Number.parseFloat( ( (percent - prePercent) / totalChunkNum + uploadFile.percentage! ).toFixed(1) ) ); }; clonedOptions.onSuccess = () => {}; clonedOptions.file = file as File; clonedOptions.data.chunk = index; clonedOptions.data.chunks = String(totalChunkNum); clonedOptions.data.chunkSize = String(realChunkSize.value); clonedOptions.data.action = 'chunk'; // 返回請求處理方法 return (index: number) => ((ajax(clonedOptions) as XMLHttpRequest).onloadend = () => { notFinishedChunkSet.delete(index); chunkProgressMap.set(index, 100); // 完成後繼續下一片的請求 uploadNext(); }); }); let curIndex = 0; // 控制最多同時只能上傳 3 個分片 const l = Math.min(requestList.length, 3); const notFinishedChunkSet = new Set<number>(); const chunkProgressMap = new Map<number, number>(); for (let i = 0; i < requestList.length; i++) { notFinishedChunkSet.add(i); } // 繼續下一片的請求 const uploadNext = () => { if (notFinishedChunkSet.size === 0) { resolve('finished'); } else if (curIndex < requestList.length) { requestList[curIndex](curIndex); curIndex++; } }; // 開始傳送請求,可最多同時傳送 3 個請求 for (; curIndex < l; curIndex++) { requestList[curIndex](curIndex); } }); }; /** * 生成檔案切片 * @param {*} file 上傳的檔案 * @param {*} size 分片大小 * @return {*} */ const createFileChunk = (file: UploadRawFile, size: number) => { const fileChunkList: { file: Blob; index: string; }[] = []; let cur = 0, index = 0; while (cur < file.size) { fileChunkList.push({ file: Object.assign(file.slice(cur, cur + size), { name: file.name, type: file.type, lastModified: file.lastModified, }), index: String(index), }); cur += size; index++; } return fileChunkList; };
總結
本文以 Element Plus 的 upload 元件為基礎,介紹了大檔案的分片上傳、斷點繼續、秒傳設計思路和實現程式碼。