JavaScript 中如何實(shí)現(xiàn)大文件并發(fā)上傳?
在 JavaScript 中如何實(shí)現(xiàn)并發(fā)控制? 這篇文章中,阿寶哥詳細(xì)分析了 async-pool 這個(gè)庫如何利用 Promise.all 和 Promise.race 函數(shù)實(shí)現(xiàn)異步任務(wù)的并發(fā)控制。之后,阿寶哥通過 JavaScript 中如何實(shí)現(xiàn)大文件并行下載? 這篇文章介紹了 async-pool 這個(gè)庫的實(shí)際應(yīng)用。
本文將介紹如何利用 async-pool 這個(gè)庫提供的 asyncPool 函數(shù)來實(shí)現(xiàn)大文件的并發(fā)上傳。相信有些小伙伴已經(jīng)了解大文件上傳的解決方案,在上傳大文件時(shí),為了提高上傳的效率,我們一般會(huì)使用 Blob.slice 方法對大文件按照指定的大小進(jìn)行切割,然后通過多線程進(jìn)行分塊上傳,等所有分塊都成功上傳后,再通知服務(wù)端進(jìn)行分塊合并。
看完上圖相信你對大文件上傳的方案,已經(jīng)有了一定的了解。接下來,我們先來介紹 Blob 和 File 對象。
一、Blob 和 File 對象
1.1 Blob 對象
Blob(Binary Large Object)表示二進(jìn)制類型的大對象。在數(shù)據(jù)庫管理系統(tǒng)中,將二進(jìn)制數(shù)據(jù)存儲(chǔ)為一個(gè)單一個(gè)體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示不可變的類似文件對象的原始數(shù)據(jù)。 為了更直觀的感受 Blob 對象,我們先來使用 Blob 構(gòu)造函數(shù),創(chuàng)建一個(gè) myBlob 對象,具體如下圖所示:
如你所見,myBlob 對象含有兩個(gè)屬性:size 和 type。其中 size 屬性用于表示數(shù)據(jù)的大小(以字節(jié)為單位),type 是 MIME 類型的字符串。Blob 由一個(gè)可選的字符串 type(通常是 MIME 類型)和 blobParts 組成:
Blob 表示的不一定是 JavaScript 原生格式的數(shù)據(jù)。比如 File 接口基于 Blob,繼承了 Blob 的功能并將其擴(kuò)展使其支持用戶系統(tǒng)上的文件。
1.2 File 對象
通常情況下, File 對象是來自用戶在一個(gè) 元素上選擇文件后返回的 FileList 對象,也可以是來自由拖放操作生成的 DataTransfer 對象,或者來自 HTMLCanvasElement 上的 mozGetAsFile() API。
File 對象是特殊類型的 Blob,且可以用在任意的 Blob 類型的上下文中。比如說 FileReader、URL.createObjectURL() 及 XMLHttpRequest.send() 都能處理 Blob 和 File。在大文件上傳的場景中,我們將使用 Blob.slice 方法對大文件按照指定的大小進(jìn)行切割,然后對分塊進(jìn)行并行上傳。接下來,我們來看一下具體如何實(shí)現(xiàn)大文件上傳。
二、如何實(shí)現(xiàn)大文件上傳
為了讓大家能夠更好地理解后面的內(nèi)容,我們先來看一下整體的流程圖:
了解完大文件上傳的流程之后,我們先來定義上述流程中涉及的一些輔助函數(shù)。
2.1 定義輔助函數(shù)
2.1.1 定義 calcFileMD5 函數(shù)
顧名思義 calcFileMD5 函數(shù),用于計(jì)算文件的 MD5 值(數(shù)字指紋)。在該函數(shù)中,我們使用 FileReader API 分塊讀取文件的內(nèi)容,然后通過 spark-md5 這個(gè)庫提供的方法來計(jì)算文件的 MD5 值。
- function calcFileMD5(file) {
- return new Promise((resolve, reject) => {
- let chunkSize = 2097152, // 2M
- chunks = Math.ceil(file.size / chunkSize),
- currentChunk = 0,
- spark = new SparkMD5.ArrayBuffer(),
- fileReader = new FileReader();
- fileReader.onload = (e) => {
- spark.append(e.target.result);
- currentChunk++;
- if (currentChunk < chunks) {
- loadNext();
- } else {
- resolve(spark.end());
- }
- };
- fileReader.onerror = (e) => {
- reject(fileReader.error);
- reader.abort();
- };
- function loadNext() {
- let start = currentChunk * chunkSize,
- end = start + chunkSize >= file.size ? file.size : start + chunkSize;
- fileReader.readAsArrayBuffer(file.slice(start, end));
- }
- loadNext();
- });
- }
2.1.2 定義 asyncPool 函數(shù)
在 JavaScript 中如何實(shí)現(xiàn)并發(fā)控制? 這篇文章中,我們介紹了 asyncPool 函數(shù),它用于實(shí)現(xiàn)異步任務(wù)的并發(fā)控制。該函數(shù)接收 3 個(gè)參數(shù):
- poolLimit(數(shù)字類型):表示限制的并發(fā)數(shù);
- array(數(shù)組類型):表示任務(wù)數(shù)組;
- iteratorFn(函數(shù)類型):表示迭代函數(shù),用于實(shí)現(xiàn)對每個(gè)任務(wù)項(xiàng)進(jìn)行處理,該函數(shù)會(huì)返回一個(gè) Promise 對象或異步函數(shù)。
- async function asyncPool(poolLimit, array, iteratorFn) {
- const ret = []; // 存儲(chǔ)所有的異步任務(wù)
- const executing = []; // 存儲(chǔ)正在執(zhí)行的異步任務(wù)
- for (const item of array) {
- // 調(diào)用iteratorFn函數(shù)創(chuàng)建異步任務(wù)
- const p = Promise.resolve().then(() => iteratorFn(item, array));
- ret.push(p); // 保存新的異步任務(wù)
- // 當(dāng)poolLimit值小于或等于總?cè)蝿?wù)個(gè)數(shù)時(shí),進(jìn)行并發(fā)控制
- if (poolLimit <= array.length) {
- // 當(dāng)任務(wù)完成后,從正在執(zhí)行的任務(wù)數(shù)組中移除已完成的任務(wù)
- const e = p.then(() => executing.splice(executing.indexOf(e), 1));
- executing.push(e); // 保存正在執(zhí)行的異步任務(wù)
- if (executing.length >= poolLimit) {
- await Promise.race(executing); // 等待較快的任務(wù)執(zhí)行完成
- }
- }
- }
- return Promise.all(ret);
- }
2.1.3 定義 checkFileExist 函數(shù)
checkFileExist 函數(shù)用于檢測文件是否已經(jīng)上傳過了,如果已存在則秒傳,否則返回已上傳的分塊 ID 列表:
- function checkFileExist(url, name, md5) {
- return request.get(url, {
- params: {
- name,
- md5,
- },
- }).then((response) => response.data);
- }
在 checkFileExist 函數(shù)中使用到的 request 對象是 Axios 實(shí)例,通過 axios.create方法來創(chuàng)建:
- const request = axios.create({
- baseURL: "http://localhost:3000/upload",
- timeout: 10000,
- });
有了 request 對象之后,我們就可以輕易地發(fā)送 HTTP 請求。在 checkFileExist 函數(shù)內(nèi)部,我們會(huì)發(fā)起一個(gè) GET 請求,同時(shí)攜帶的查詢參數(shù)是文件名(name)和文件的 MD5 值。
2.1.4 定義 upload 函數(shù)
當(dāng)調(diào)用 checkFileExist 函數(shù)之后,如果發(fā)現(xiàn)文件尚未上傳或者只上傳完部分分塊的話,就會(huì)繼續(xù)調(diào)用 upload 函數(shù)來執(zhí)行上傳任務(wù)。在 upload 函數(shù)內(nèi),我們使用了前面介紹的 asyncPool 函數(shù)來實(shí)現(xiàn)異步任務(wù)的并發(fā)控制,具體如下所示:
- function upload({
- url, file, fileMd5,
- fileSize, chunkSize, chunkIds,
- poolLimit = 1,
- }) {
- const chunks = typeof chunkSize === "number" ? Math.ceil(fileSize / chunkSize) : 1;
- return asyncPool(poolLimit, [...new Array(chunks).keys()], (i) => {
- if (chunkIds.indexOf(i + "") !== -1) { // 已上傳的分塊直接跳過
- return Promise.resolve();
- }
- let start = i * chunkSize;
- let end = i + 1 == chunks ? fileSize : (i + 1) * chunkSize;
- const chunk = file.slice(start, end); // 對文件進(jìn)行切割
- return uploadChunk({
- url,
- chunk,
- chunkIndex: i,
- fileMd5,
- fileName: file.name,
- });
- });
- }
對于切割完的文件塊,會(huì)通過 uploadChunk 函數(shù),來執(zhí)行實(shí)際的上傳操作:
- function uploadChunk({ url, chunk, chunkIndex, fileMd5, fileName }) {
- let formData = new FormData();
- formData.set("file", chunk, fileMd5 + "-" + chunkIndex);
- formData.set("name", fileName);
- formData.set("timestamp", Date.now());
- return request.post(url, formData);
- }
2.1.5 定義 concatFiles 函數(shù)
當(dāng)所有分塊都上傳完成之后,我們需要通知服務(wù)端執(zhí)行分塊合并操作,這里我們定義了 concatFiles 函數(shù)來實(shí)現(xiàn)該功能:
- function concatFiles(url, name, md5) {
- return request.get(url, {
- params: {
- name,
- md5,
- },
- });
- }
2.1.6 定義 uploadFile 函數(shù)
在前面已定義輔助函數(shù)的基礎(chǔ)上,我們就可以根據(jù)大文件上傳的整體流程圖來實(shí)現(xiàn)一個(gè) uploadFile 函數(shù):
- async function uploadFile() {
- if (!uploadFileEle.files.length) return;
- const file = uploadFileEle.files[0]; // 獲取待上傳的文件
- const fileMd5 = await calcFileMD5(file); // 計(jì)算文件的MD5
- const fileStatus = await checkFileExist( // 判斷文件是否已存在
- "/exists",
- file.name, fileMd5
- );
- if (fileStatus.data && fileStatus.data.isExists) {
- alert("文件已上傳[秒傳]");
- return;
- } else {
- await upload({
- url: "/single",
- file, // 文件對象
- fileMd5, // 文件MD5值
- fileSize: file.size, // 文件大小
- chunkSize: 1 * 1024 * 1024, // 分塊大小
- chunkIds: fileStatus.data.chunkIds, // 已上傳的分塊列表
- poolLimit: 3, // 限制的并發(fā)數(shù)
- });
- }
- await concatFiles("/concatFiles", file.name, fileMd5);
- }
2.2 大文件并發(fā)上傳示例
定義完 uploadFile 函數(shù),要實(shí)現(xiàn)大文件并發(fā)上傳的功能就很簡單了,具體代碼如下所示:
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <meta http-equiv="X-UA-Compatible" content="ie=edge" />
- <title>大文件并發(fā)上傳示例(阿寶哥)</title>
- <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.1/axios.min.js"></script>
- <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.0/spark-md5.min.js"></script>
- </head>
- <body>
- <input type="file" id="uploadFile" />
- <button id="submit" onclick="uploadFile()">上傳文件</button>
- <script>
- const uploadFileEle = document.querySelector("#uploadFile");
- const request = axios.create({
- baseURL: "http://localhost:3000/upload",
- timeout: 10000,
- });
- async function uploadFile() {
- if (!uploadFileEle.files.length) return;
- const file = uploadFileEle.files[0]; // 獲取待上傳的文件
- const fileMd5 = await calcFileMD5(file); // 計(jì)算文件的MD5
- // ...
- }
- // 省略其他函數(shù)
- </script>
- </body>
- </html>
由于完整的示例代碼內(nèi)容比較多,阿寶哥就不放具體的代碼了。感興趣的小伙伴,可以訪問以下地址瀏覽客戶端和服務(wù)器端代碼。
- 完整的示例代碼(代碼僅供參考,可根據(jù)實(shí)際情況進(jìn)行調(diào)整):
- https://gist.github.com/semlinker/b211c0b148ac9be0ac286b387757e692
最后我們來看一下大文件并發(fā)上傳示例的運(yùn)行結(jié)果:
三、總結(jié)
本文介紹了在 JavaScript 中如何利用 async-pool 這個(gè)庫提供的 asyncPool 函數(shù),來實(shí)現(xiàn)大文件的并發(fā)上傳。此外,文中我們也使用了 spark-md5 這個(gè)庫來計(jì)算文件的數(shù)字指紋,如果你數(shù)字指紋感興趣的話,可以閱讀 數(shù)字指紋有什么用?趕緊來了解一下 這篇文章。
由于篇幅有限,阿寶哥并未介紹服務(wù)端的具體代碼。其實(shí)在做文件分塊合并時(shí),阿寶哥是以流的形式進(jìn)行合并,感興趣的小伙伴可以自行閱讀一下相關(guān)代碼。如果有遇到不清楚的地方,歡迎隨時(shí)跟阿寶哥交流喲。
四、參考資源
- 你不知道的 Blob
- MDN - File
- MDN - ArrayBuffer
- MDN - HTTP請求范圍
- JavaScript 中如何實(shí)現(xiàn)并發(fā)控制?