位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

原 作 者:yeyan1996

原文連結:https://url。cn/5h66afn

前言

這段時間面試官都挺忙的,頻頻出現在部落格文章標題,雖然我不是特別想蹭熱度,但是實在想不到好的標題了-。-,蹭蹭就蹭蹭 :)

事實上我在面試的時候確實被問到了這個問題,而且是一道線上 coding 的程式設計題,當時雖然思路正確,可惜最終也並不算完全答對。

結束後花了一段時間整理了下思路,那麼究竟該如何實現一個大檔案上傳,以及在上傳中如何實現斷點續傳的功能呢?

本文將從零搭建前端和服務端,實現一個大檔案上傳和斷點續傳的 demo:

前端:vue element-ui

服務端:nodejs

文章有誤解的地方,歡迎指出,將在第一時間改正,有更好的實現方式希望留下你的評論。

大檔案上傳

前端

前端大檔案上傳網上的大部分文章已經給出瞭解決方案,核心是利用 Blob。prototype。slice 方法,此方法和陣列的 slice 方法相似,呼叫的 slice 方法可以返回

原檔案的某個切片

這樣我們就可以根據預先設定好的切片最大數量將檔案切分為一個個切片,然後藉助 http 的可併發性,同時上傳多個切片,這樣從原本傳一個大檔案,變成了

同時

傳多個小的檔案切片,可以大大減少上傳時間。

另外由於是併發,傳輸到服務端的順序可能會發生變化,所以我們還需要給每個切片記錄順序。

服務端

服務端需要負責接受這些切片,並在接收到所有切片後

合併

切片。

這裡又引伸出兩個問題:

何時合併切片,即切片什麼時候傳輸完成?

如何合併切片?

第一個問題需要前端進行配合,前端在每個切片中都攜帶切片最大數量的資訊,當服務端接收到這個數量的切片時自動合併,也可以額外發一個請求主動通知服務端進行切片的合併。

第二個問題,具體如何合併切片呢?這裡可以使用 NodeJS 的 API fs。appendFileSync,它可以同步地將資料追加到指定檔案,也就是說,當服務端接收完所有切片後,可以先建立一個空檔案,然後將所有切片逐步合併到這個檔案中。

so,talk is cheap, show me the code

,接著讓我們用程式碼實現上面的思路吧。

前端部分

前端使用

Vue

作為開發框架,對介面沒有太大要求,原生也可以,考慮到美觀使用

Element-UI

作為

UI

框架。

上傳控制元件

首先建立選擇檔案的控制元件,監聽 change 事件以及上傳按鈕:

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

請求邏輯

考慮到通用性,這裡沒有用第三方的請求庫,而是用原生 XMLHttpRequest 做一層簡單的封裝來發請求:

request({ url, method = “post”, data, headers = {}, requestList}) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr。open(method, url); Object。keys(headers)。forEach(key => xhr。setRequestHeader(key, headers[key]) ); xhr。send(data); xhr。onload = e => { resolve({ data: e。target。response }); }; });}

上傳切片

接著實現比較重要的上傳功能,上傳需要做兩件事:

對檔案進行切片

將切片傳輸給服務端

當點選上傳按鈕時,呼叫 createFileChunk 將檔案切片,切片數量透過一個常量 Length 控制,這裡設定為 10,即將檔案分成 10 個切片上傳。

createFileChunk 內使用 while 迴圈和 slice 方法將切片放入 fileChunkList 陣列中返回。

在生成檔案切片時,需要給每個切片一個標識作為 hash,這裡暫時使用

檔名 + 下標

,這樣後端可以知道當前切片是第幾個切片,用於之後的合併切片。

隨後呼叫 uploadChunks 上傳所有的檔案切片,將檔案切片,切片 hash,以及檔名放入 FormData 中,再呼叫上一步的 request 函式返回一個 proimise,最後呼叫 Promise。all 併發上傳所有的切片。

傳送合併請求

這裡使用整體思路中提到的第二種合併切片的方式,即前端主動通知服務端進行合併,所以前端還需要額外發請求,服務端接受到這個請求時主動合併切片

服務端部分

簡單使用 HTTP 模組搭建服務端:

const http = require(“http”);const server = http。createServer();server。on(“request”, async (req, res) => { res。setHeader(“Access-Control-Allow-Origin”, “*”); res。setHeader(“Access-Control-Allow-Headers”, “*”); if (req。method === “OPTIONS”) { res。status = 200; res。end(); return; }});server。listen(3000, () => console。log(“正在監聽 3000 埠”));

接受切片

使用 multiparty 包處理前端傳來的 FormData,在 multiparty。parse 的回撥中,files 引數儲存了 FormData 中檔案,fields 引數儲存了 FormData 中非檔案的欄位:

const http = require(“http”);const path = require(“path”);const fse = require(“fs-extra”);const multiparty = require(“multiparty”);const server = http。createServer();+ const UPLOAD_DIR = path。resolve(__dirname, “。。”, “target”); // 大檔案儲存目錄server。on(“request”, async (req, res) => { res。setHeader(“Access-Control-Allow-Origin”, “*”); res。setHeader(“Access-Control-Allow-Headers”, “*”); if (req。method === “OPTIONS”) { res。status = 200; res。end(); return; }+ const multipart = new multiparty。Form();+ multipart。parse(req, async (err, fields, files) => {+ if (err) {+ return;+ }+ const [chunk] = files。chunk;+ const [hash] = fields。hash;+ const [filename] = fields。filename;+ const chunkDir = `${UPLOAD_DIR}/${filename}`;+ // 切片目錄不存在,建立切片目錄+ if (!fse。existsSync(chunkDir)) {+ await fse。mkdirs(chunkDir);+ }+ // fs-extra 專用方法,類似 fs。rename 並且跨平臺+ // fs-extra 的 rename 方法 windows 平臺會有許可權問題+ // https://github。com/meteor/meteor/issues/7852#issuecomment-255767835+ await fse。move(chunk。path, `${chunkDir}/${hash}`);+ res。end(“received file chunk”);+ });});server。listen(3000, () => console。log(“正在監聽 3000 埠”));

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

檢視 multiparty 處理後的 chunk 物件,path 是儲存臨時檔案的路徑,size 是臨時檔案大小,在 multiparty 文件中提到可以使用 fs。rename(由於我用的是 fs-extra,其 rename 方法在 Windows 系統上存在許可權問題,所以換成了 fse。move) 重新命名的方式移動臨時檔案,也就是檔案切片。

在接受檔案切片時,需要先建立儲存切片的資料夾,由於前端在傳送每個切片時額外攜帶了唯一值 hash,所以以 hash 作為檔名,將切片從臨時路徑移動切片資料夾中,最後的結果如下

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

合併切片

在接收到前端傳送的合併請求後,服務端將資料夾下的所有切片進行合併

const http = require(“http”);const path = require(“path”);const fse = require(“fs-extra”);const server = http。createServer();const UPLOAD_DIR = path。resolve(__dirname, “。。”, “target”); // 大檔案儲存目錄+ const resolvePost = req =>+ new Promise(resolve => {+ let chunk = “”;+ req。on(“data”, data => {+ chunk += data;+ });+ req。on(“end”, () => {+ resolve(JSON。parse(chunk));+ });+ });+ // 合併切片+ const mergeFileChunk = async (filePath, filename) => {+ const chunkDir = `${UPLOAD_DIR}/${filename}`;+ const chunkPaths = await fse。readdir(chunkDir);+ await fse。writeFile(filePath, “”);+ chunkPaths。forEach(chunkPath => {+ fse。appendFileSync(filePath, fse。readFileSync(`${chunkDir}/${chunkPath}`));+ fse。unlinkSync(`${chunkDir}/${chunkPath}`);+ });+ fse。rmdirSync(chunkDir); // 合併後刪除儲存切片的目錄+ };server。on(“request”, async (req, res) => { res。setHeader(“Access-Control-Allow-Origin”, “*”); res。setHeader(“Access-Control-Allow-Headers”, “*”); if (req。method === “OPTIONS”) { res。status = 200; res。end(); return; }+ if (req。url === “/merge”) {+ const data = await resolvePost(req);+ const { filename } = data;+ const filePath = `${UPLOAD_DIR}/${filename}`;+ await mergeFileChunk(filePath, filename);+ res。end(+ JSON。stringify({+ code: 0,+ message: “file merged success”+ })+ );+ }});server。listen(3000, () => console。log(“正在監聽 3000 埠”));

由於前端在傳送合併請求時會攜帶檔名,服務端根據檔名可以找到上一步建立的切片資料夾。

接著使用 fs。writeFileSync 先建立一個空檔案,這個空檔案的檔名就是

切片資料夾名 + 字尾名

組合而成,隨後透過 fs。appendFileSync 從切片資料夾中不斷將切片合併到空檔案中,每次合併完成後刪除這個切片,等所有切片都合併完畢後最後刪除切片資料夾。

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

至此一個簡單的大檔案上傳就完成了,接下來我們再此基礎上擴充套件一些額外的功能。

顯示上傳進度條

上傳進度分兩種,一個是每個切片的上傳進度,另一個是整個檔案的上傳進度,而整個檔案的上傳進度是基於每個切片上傳進度計算而來,所以我們需要先實現切片的上傳進度。

切片進度條

XMLHttpRequest 原生支援上傳進度的監聽,只需要監聽 upload。onprogress 即可,我們在原來的 request 基礎上傳入 onProgress 引數,給 XMLHttpRequest 註冊監聽事件:

// xhr request({ url, method = “post”, data, headers = {},+ onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest();+ xhr。upload。onprogress = onProgress; xhr。open(method, url); Object。keys(headers)。forEach(key => xhr。setRequestHeader(key, headers[key]) ); xhr。send(data); xhr。onload = e => { resolve({ data: e。target。response }); }; }); }

由於每個切片都需要觸發獨立的監聽事件,所以還需要一個工廠函式,根據傳入的切片返回不同的監聽函式。

在原先的前端上傳邏輯中新增監聽函式部分:

// 上傳切片,同時過濾已上傳的切片 async uploadChunks(uploadedList = []) { const requestList = this。data 。map(({ chunk }) => { const formData = new FormData(); formData。append(“chunk”, chunk); formData。append(“filename”, this。container。file。name); return { formData }; }) 。map(async ({ formData }) => this。request({ url: “http://localhost:3000”, data: formData,+ onProgress: this。createProgressHandler(this。data[index]), }) ); await Promise。all(requestList); // 合併切片 await this。mergeRequest(); }, async handleUpload() { if (!this。container。file) return; const fileChunkList = this。createFileChunk(this。container。file); this。data = fileChunkList。map(({ file },index) => ({ chunk: file,+ index, hash: this。container。file。name + “-” + index+ percentage:0 })); await this。uploadChunks(); }+ createProgressHandler(item) {+ return e => {+ item。percentage = parseInt(String((e。loaded / e。total) * 100));+ };+ }

每個切片在上傳時都會透過監聽函式更新 data 陣列對應元素的 percentage 屬性,之後把將 data 陣列放到檢視中展示即可。

檔案進度條

將每個切片已上傳的部分累加,除以整個檔案的大小,就能得出當前檔案的上傳進度,所以這裡使用 Vue 計算屬性:

computed: { uploadPercentage() { if (!this。container。file || !this。data。length) return 0; const loaded = this。data 。map(item => item。size * item。percentage) 。reduce((acc, cur) => acc + cur); return parseInt((loaded / this。container。file。size)。toFixed(2)); }}

最終效果如下:

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

斷點續傳

斷點續傳的原理在於前端/服務端需要

記住

已上傳的切片,這樣下次上傳就可以跳過之前已上傳的部分,有兩種方案實現記憶的功能:

前端使用 localStorage 記錄已上傳的切片 hash。

服務端儲存已上傳的切片 hash,前端每次上傳前向服務端獲取已上傳的切片。

第一種是前端的解決方案,第二種是服務端,而前端方案有一個缺陷,如果換了個瀏覽器就失去了記憶的效果,所以這裡選取後者。

生成 hash

無論是前端還是服務端,都必須要生成檔案和切片的 hash,

之前我們使用檔名 + 切片下標作為切片 hash

,這樣做檔名一旦修改就失去了效果,而事實上只要檔案內容不變,hash 就不應該變化,所以正確的做法是

根據檔案內容生成 hash

,所以我們需要修改一下 hash 的生成規則。

這裡用到另一個庫 spark-md5,它可以根據檔案內容計算出檔案的 hash 值,另外考慮到如果上傳一個超大檔案,讀取檔案內容計算 hash 是非常耗費時間的,並且會

引起 UI 的阻塞

,導致頁面假死狀態,所以我們使用

web-worker

worker

執行緒計算 hash,這樣使用者仍可以在主介面正常的互動。

由於例項化

web-worker

時,引數是一個 JavaScript 檔案路徑,且不能跨域。所以我們單獨建立一個

hash.js

檔案放在

public

目錄下,另外在

worker

中也是不允許訪問

DOM

的,但它提供了importScripts 函式用於匯入外部指令碼,透過它匯入 spark-md5。

// /public/hash。jsself。importScripts(“/spark-md5。min。js”); // 匯入指令碼// 生成檔案 hashself。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);};

worker

執行緒中,接受檔案切片 fileChunkList,利用 FileReader 讀取每個切片的 ArrayBuffer 並不斷傳入 spark-md5 中,每計算完一個切片透過 postMessage 向主執行緒傳送一個進度事件,全部完成後將最終的 hash 傳送給主執行緒。

spark-md5 需要根據所有切片才能算出一個 hash 值,不能直接將整個檔案放入計算,否則即使不同檔案也會有相同的 hash,具體可以看官方文件。

spark-md5[1]

接著編寫主執行緒與

worker

執行緒通訊的邏輯

+ // 生成檔案 hash(web-worker)+ calculateHash(fileChunkList) {+ return new Promise(resolve => {+ // 新增 worker 屬性+ 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;+ if (hash) {+ resolve(hash);+ }+ };+ }); }, async handleUpload() { if (!this。container。file) return; const fileChunkList = this。createFileChunk(this。container。file);+ this。container。hash = await this。calculateHash(fileChunkList); this。data = fileChunkList。map(({ file },index) => ({+ fileHash: this。container。hash, chunk: file, hash: this。container。file。name + “-” + index, // 檔名 + 陣列下標 percentage:0 })); await this。uploadChunks(); }

主執行緒使用 postMessage 給

worker

執行緒傳入所有切片 fileChunkList,並監聽

worker

執行緒發出的 postMessage 事件拿到檔案 hash。

加上顯示計算 hash 的進度條,看起來像這樣

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

至此前端需要將之前用檔名作為 hash 的地方改寫為

workder

返回的這個 hash。

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

服務端則使用 hash 作為切片資料夾名,hash + 下標作為切片名,hash + 副檔名作為檔名,沒有新增的邏輯。

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

檔案秒傳

在實現斷點續傳前先簡單介紹一下檔案秒傳。

所謂的檔案秒傳,即在服務端已經存在了上傳的資源,所以當用戶再次上傳時會直接提示上傳成功

檔案秒傳需要依賴上一步生成的 hash,即在

上傳前

,先計算出檔案 hash,並把 hash 傳送給服務端進行驗證,由於 hash 的唯一性,所以一旦服務端能找到 hash 相同的檔案,則直接返回上傳成功的資訊即可。

+ async verifyUpload(filename, fileHash) {+ const { data } = await this。request({+ url: “http://localhost:3000/verify”,+ headers: {+ “content-type”: “application/json”+ },+ data: JSON。stringify({+ filename,+ fileHash+ })+ });+ return JSON。parse(data);+ }, async handleUpload() { if (!this。container。file) return; const fileChunkList = this。createFileChunk(this。container。file); this。container。hash = await this。calculateHash(fileChunkList);+ const { shouldUpload } = await this。verifyUpload(+ this。container。file。name,+ this。container。hash+ );+ if (!shouldUpload) {+ this。$message。success(“秒傳:上傳成功”);+ return;+ } this。data = fileChunkList。map(({ file }, index) => ({ fileHash: this。container。hash, index, hash: this。container。hash + “-” + index, chunk: file, percentage: 0 })); await this。uploadChunks(); }

秒傳其實就是給使用者看的障眼法,實質上根本沒有上傳。就像下面這行程式碼 :)

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

服務端的邏輯非常簡單,新增一個驗證介面,驗證檔案是否存在即可。

+ const extractExt = filename =>+ filename。slice(filename。lastIndexOf(“。”), filename。length); // 提取字尾名const UPLOAD_DIR = path。resolve(__dirname, “。。”, “target”); // 大檔案儲存目錄const resolvePost = req => new Promise(resolve => { let chunk = “”; req。on(“data”, data => { chunk += data; }); req。on(“end”, () => { resolve(JSON。parse(chunk)); }); });server。on(“request”, async (req, res) => { if (req。url === “/verify”) {+ const data = await resolvePost(req);+ const { fileHash, filename } = data;+ const ext = extractExt(filename);+ const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;+ if (fse。existsSync(filePath)) {+ res。end(+ JSON。stringify({+ shouldUpload: false+ })+ );+ } else {+ res。end(+ JSON。stringify({+ shouldUpload: true+ })+ );+ } }});server。listen(3000, () => console。log(“正在監聽 3000 埠”));

暫停上傳

講完了生成 hash 和檔案秒傳,回到斷點續傳。

斷點續傳顧名思義即斷點 + 續傳,所以我們第一步先實現“斷點”,也就是暫停上傳。

原理是使用 XMLHttpRequest 的 abort 方法,可以取消一個 xhr 請求的傳送,為此我們需要將上傳每個切片的 xhr 物件儲存起來,我們再改造一下 request 方法。

request({ url, method = “post”, data, headers = {}, onProgress = e => e,+ requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr。upload。onprogress = onProgress; xhr。open(method, url); Object。keys(headers)。forEach(key => xhr。setRequestHeader(key, headers[key]) ); xhr。send(data); xhr。onload = e => {+ // 將請求成功的 xhr 從列表中刪除+ if (requestList) {+ const xhrIndex = requestList。findIndex(item => item === xhr);+ requestList。splice(xhrIndex, 1);+ } resolve({ data: e。target。response }); };+ // 暴露當前 xhr 給外部+ requestList?。push(xhr); }); },

這樣在上傳切片時傳入 requestList 陣列作為引數,request 方法就會將所有的 xhr 儲存在陣列中了。

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

每當一個切片上傳成功時,將對應的 xhr 從 requestList 中刪除,所以 requestList 中只儲存正在上傳切片的 xhr。

之後新建一個暫停按鈕,當點選按鈕時,呼叫儲存在 requestList 中 xhr 的 abort 方法,即取消並清空所有正在上傳的切片。

handlePause() { this。requestList。forEach(xhr => xhr?。abort()); this。requestList = [];}

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

點選暫停按鈕可以看到 xhr 都被取消了。

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

恢復上傳

之前在介紹斷點續傳的時提到使用第二種服務端儲存的方式實現續傳

由於當檔案切片上傳後,服務端會建立一個資料夾儲存所有上傳的切片,所以每次前端上傳前可以呼叫一個介面,服務端將已上傳的切片的切片名返回,前端再跳過這些已經上傳切片,這樣就實現了“

續傳

”的效果

而這個介面可以和之前秒傳的驗證介面合併,前端每次上傳前傳送一個驗證的請求,返回兩種結果:

服務端已存在該檔案,不需要再次上傳。

服務端不存在該檔案或者已上傳部分檔案切片,通知前端進行上傳,並把

已上傳

的檔案切片返回給前端。

所以我們改造一下之前檔案秒傳的服務端驗證介面:

const extractExt = filename => filename。slice(filename。lastIndexOf(“。”), filename。length); // 提取字尾名const UPLOAD_DIR = path。resolve(__dirname, “。。”, “target”); // 大檔案儲存目錄const resolvePost = req => new Promise(resolve => { let chunk = “”; req。on(“data”, data => { chunk += data; }); req。on(“end”, () => { resolve(JSON。parse(chunk)); }); });+ // 返回已經上傳切片名列表+ const createUploadedList = async fileHash =>+ fse。existsSync(`${UPLOAD_DIR}/${fileHash}`)+ ? await fse。readdir(`${UPLOAD_DIR}/${fileHash}`)+ : [];server。on(“request”, async (req, res) => { if (req。url === “/verify”) { const data = await resolvePost(req); const { fileHash, filename } = data; const ext = extractExt(filename); const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`; if (fse。existsSync(filePath)) { res。end( JSON。stringify({ shouldUpload: false }) ); } else { res。end( JSON。stringify({ shouldUpload: true,+ uploadedList: await createUploadedList(fileHash) }) ); } }});server。listen(3000, () => console。log(“正在監聽 3000 埠”));

接著回到前端,前端有兩個地方需要呼叫驗證的介面:

點選上傳時,檢查是否需要上傳和已上傳的切片。

點選暫停後的恢復上傳,返回已上傳的切片。

新增恢復按鈕並改造原來上傳切片的邏輯:

+ async handleResume() {+ const { uploadedList } = await this。verifyUpload(+ this。container。file。name,+ this。container。hash+ );+ await this。uploadChunks(uploadedList); }, async handleUpload() { if (!this。container。file) return; const fileChunkList = this。createFileChunk(this。container。file); this。container。hash = await this。calculateHash(fileChunkList);+ const { shouldUpload, uploadedList } = await this。verifyUpload( this。container。file。name, this。container。hash ); if (!shouldUpload) { this。$message。success(“秒傳:上傳成功”); return; } this。data = fileChunkList。map(({ file }, index) => ({ fileHash: this。container。hash, index, hash: this。container。hash + “-” + index, chunk: file, percentage: 0 }));+ await this。uploadChunks(uploadedList); }, // 上傳切片,同時過濾已上傳的切片+ async uploadChunks(uploadedList = []) { const requestList = this。data+ 。filter(({ hash }) => !uploadedList。includes(hash)) 。map(({ chunk, hash, index }) => { const formData = new FormData(); formData。append(“chunk”, chunk); formData。append(“hash”, hash); formData。append(“filename”, this。container。file。name); formData。append(“fileHash”, this。container。hash); return { formData, index }; }) 。map(async ({ formData, index }) => this。request({ url: “http://localhost:3000”, data: formData, onProgress: this。createProgressHandler(this。data[index]), requestList: this。requestList }) ); await Promise。all(requestList); // 之前上傳的切片數量 + 本次上傳的切片數量 = 所有切片數量時 // 合併切片+ if (uploadedList。length + requestList。length === this。data。length) { await this。mergeRequest();+ } }

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

這裡給原來上傳切片的函式新增 uploadedList 引數,即上圖中服務端返回的切片名列表,透過 filter 過濾掉已上傳的切片,並且由於新增了已上傳的部分,所以之前合併介面的觸發條件做了一些改動。

到這裡斷點續傳的功能基本完成了。

進度條改進

雖然實現了斷點續傳,但還需要修改一下進度條的顯示規則,否則在暫停上傳/接收到已上傳切片時的進度條會出現偏差。

切片進度條

由於在點選上傳/恢復上傳時,會呼叫驗證介面返回已上傳的切片,所以需要將已上傳切片的進度變成 100%。

async handleUpload() { if (!this。container。file) return; const fileChunkList = this。createFileChunk(this。container。file); this。container。hash = await this。calculateHash(fileChunkList); const { shouldUpload, uploadedList } = await this。verifyUpload( this。container。file。name, this。container。hash ); if (!shouldUpload) { this。$message。success(“秒傳:上傳成功”); return; } this。data = fileChunkList。map(({ file }, index) => ({ fileHash: this。container。hash, index, hash: this。container。hash + “-” + index, chunk: file,+ percentage: uploadedList。includes(index) ? 100 : 0 })); await this。uploadChunks(uploadedList); },

uploadedList 會返回已上傳的切片,在遍歷所有切片時判斷當前切片是否在已上傳列表裡即可。

檔案進度條

之前說到檔案進度條是一個計算屬性,根據所有切片的上傳進度計算而來,這就遇到了一個問題:

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

點選暫停會取消並清空切片的 xhr 請求,此時如果已經上傳了一部分,就會發現檔案進度條有

倒退

的現象:

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

當點選恢復時,由於重新建立了 xhr 導致切片進度清零,所以總進度條就會倒退。

解決方案是建立一個“

”的進度條,這個假進度條基於檔案進度條,但只會停止和增加,然後給使用者展示這個假的進度條

這裡我們使用 Vue 的監聽屬性:

data: () => ({+ fakeUploadPercentage: 0 }), computed: { uploadPercentage() { if (!this。container。file || !this。data。length) return 0; const loaded = this。data 。map(item => item。size * item。percentage) 。reduce((acc, cur) => acc + cur); return parseInt((loaded / this。container。file。size)。toFixed(2)); } }, watch: {+ uploadPercentage(now) {+ if (now > this。fakeUploadPercentage) {+ this。fakeUploadPercentage = now;+ } } },

當 uploadPercentage 即真的檔案進度條增加時,fakeUploadPercentage 也增加,一旦檔案進度條後退,假的進度條只需停止即可。

至此一個

大檔案上傳

+

斷點續傳

的解決方案就完成了

總結

大檔案上傳:

前端上傳大檔案時使用 Blob。prototype。slice 將檔案切片,併發上傳多個切片,最後傳送一個合併的請求通知服務端合併切片。

服務端接收切片並存儲,收到合併請求後使用 fs。appendFileSync 對多個切片進行合併。

原生 XMLHttpRequest 的 upload。onprogress 對切片上傳進度的監聽。

使用 Vue 計算屬性根據每個切片的進度算出整個檔案的上傳進度。

斷點續傳:

使用 spart-md5 根據檔案內容算出檔案 hash。

透過 hash 可以判斷服務端是否已經上傳該檔案,從而直接提示使用者上傳成功(秒傳)。

透過 XMLHttpRequest 的 abort 方法暫停切片的上傳。

上傳前服務端返回已經上傳的切片名,前端跳過這些切片的上傳。

原始碼

原始碼增加了一些按鈕的狀態,互動更加友好,文章表達比較晦澀的地方可以跳轉到原始碼檢視

file-upload[2]

位元組跳動面試官:請你實現一個大檔案上傳和斷點續傳

參考資料

寫給新手前端的各種檔案上傳攻略,從小圖片到大檔案斷點續傳[3]

Blob.slice[4]

關注我

大家好,這裡是 FEHub,每天早上 9 點更新,為你嚴選優質文章,與你一起進步。

如果喜歡這篇文章,記得點贊,轉發。讓你的好基友和你一樣優秀。

感謝大家的支援

吃飯時加個雞腿

咱們明天見  :)

歡迎關注 「FEHub」,每天進步一點點

推薦閱讀

種草 ES2020 新特性,以後再也不用寫又臭又長的程式碼了

萬字長文:2019 年 京東 PLUS 會員前端重構之路

身份校驗:傻傻分不清之 Cookie、Session、Token、JWT

參考資料

[1]

spark-md5: https://www。npmjs。com/package/spark-md5

[2]

file-upload: https://github。com/yeyan1996/file-upload

[3]

寫給新手前端的各種檔案上傳攻略,從小圖片到大檔案斷點續傳: https://juejin。im/post/5da14778f265da5bb628e590

[4]

Blob。slice: https://developer。mozilla。org/zh-CN/docs/Web/API/Blob/slice