JavaScript 中如何實現大文件併發上傳?

JavaScript 中如何實現併發控制? 這篇文章中,阿寶哥詳細分析了 async-pool 這個庫如何利用 Promise.allPromise.race 函數實現異步任務的併發控制。之後,阿寶哥通過 JavaScript 中如何實現大文件並行下載? 這篇文章介紹了 async-pool 這個庫的實際應用。

本文將介紹如何利用 async-pool 這個庫提供的 asyncPool 函數來實現大文件的併發上傳。相信有些小夥伴已經瞭解大文件上傳的解決方案,在上傳大文件時,爲了提高上傳的效率,我們一般會使用 Blob.slice 方法對大文件按照指定的大小進行切割,然後通過多線程進行分塊上傳,等所有分塊都成功上傳後,再通知服務端進行分塊合併。

看完上圖相信你對大文件上傳的方案,已經有了一定的瞭解。接下來,我們先來介紹 Blob 和 File 對象。

一、Blob 和 File 對象

1.1 Blob 對象

Blob(Binary Large Object)表示二進制類型的大對象。在數據庫管理系統中,將二進制數據存儲爲一個單一個體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示不可變的類似文件對象的原始數據。 爲了更直觀的感受 Blob 對象,我們先來使用 Blob 構造函數,創建一個 myBlob 對象,具體如下圖所示:

如你所見,myBlob 對象含有兩個屬性:size 和 type。其中 size 屬性用於表示數據的大小(以字節爲單位),type 是 MIME 類型的字符串。Blob 由一個可選的字符串 type(通常是 MIME 類型)和 blobParts 組成:

Blob 表示的不一定是 JavaScript 原生格式的數據。比如 File 接口基於 Blob,繼承了 Blob 的功能並將其擴展使其支持用戶系統上的文件。

1.2 File 對象

通常情況下, File 對象是來自用戶在一個 <input> 元素上選擇文件後返回的 FileList 對象,也可以是來自由拖放操作生成的 DataTransfer 對象,或者來自 HTMLCanvasElement 上的 mozGetAsFile() API。

File 對象是特殊類型的 Blob,且可以用在任意的 Blob 類型的上下文中。比如說 FileReader、URL.createObjectURL() 及 XMLHttpRequest.send() 都能處理 Blob 和 File。在大文件上傳的場景中,我們將使用 Blob.slice 方法對大文件按照指定的大小進行切割,然後對分塊進行並行上傳。接下來,我們來看一下具體如何實現大文件上傳。

二、如何實現大文件上傳

爲了讓大家能夠更好地理解後面的內容,我們先來看一下整體的流程圖:

瞭解完大文件上傳的流程之後,我們先來定義上述流程中涉及的一些輔助函數。

2.1 定義輔助函數

2.1.1 定義 calcFileMD5 函數

顧名思義 calcFileMD5 函數,用於計算文件的 MD5 值(數字指紋)。在該函數中,我們使用 FileReader API 分塊讀取文件的內容,然後通過 spark-md5 這個庫提供的方法來計算文件的 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 函數

JavaScript 中如何實現併發控制? 這篇文章中,我們介紹了 asyncPool 函數,它用於實現異步任務的併發控制。該函數接收 3 個參數:

async function asyncPool(poolLimit, array, iteratorFn) {
  const ret = []; // 存儲所有的異步任務
  const executing = []; // 存儲正在執行的異步任務
  for (const item of array) {
    // 調用iteratorFn函數創建異步任務
    const p = Promise.resolve().then(() => iteratorFn(item, array));
    ret.push(p); // 保存新的異步任務

    // 當poolLimit值小於或等於總任務個數時,進行併發控制
    if (poolLimit <= array.length) {
      // 當任務完成後,從正在執行的任務數組中移除已完成的任務
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e); // 保存正在執行的異步任務
      if (executing.length >= poolLimit) {
        await Promise.race(executing); // 等待較快的任務執行完成
      }
    }
  }
  return Promise.all(ret);
}
2.1.3 定義 checkFileExist 函數

checkFileExist 函數用於檢測文件是否已經上傳過了,如果已存在則秒傳,否則返回已上傳的分塊 ID 列表:

function checkFileExist(url, name, md5) {
  return request.get(url, {
    params: {
      name,
      md5,
    },
  }).then((response) => response.data);
}

checkFileExist 函數中使用到的 request 對象是 Axios 實例,通過 axios.create 方法來創建:

const request = axios.create({
  baseURL: "http://localhost:3000/upload",
  timeout: 10000,
});

有了 request 對象之後,我們就可以輕易地發送 HTTP 請求。在 checkFileExist 函數內部,我們會發起一個 GET 請求,同時攜帶的查詢參數是文件名(name)和文件的 MD5 值。

2.1.4 定義 upload 函數

當調用 checkFileExist 函數之後,如果發現文件尚未上傳或者只上傳完部分分塊的話,就會繼續調用 upload 函數來執行上傳任務。在 upload 函數內,我們使用了前面介紹的 asyncPool 函數來實現異步任務的併發控制,具體如下所示:

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); // 對文件進行切割
    return uploadChunk({
      url,
      chunk,
      chunkIndex: i,
      fileMd5,
      fileName: file.name,
    });
  });
}

對於切割完的文件塊,會通過 uploadChunk 函數,來執行實際的上傳操作:

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 函數

當所有分塊都上傳完成之後,我們需要通知服務端執行分塊合併操作,這裏我們定義了 concatFiles 函數來實現該功能:

function concatFiles(url, name, md5) {
  return request.get(url, {
    params: {
      name,
      md5,
    },
  });
}
2.1.6 定義 uploadFile 函數

在前面已定義輔助函數的基礎上,我們就可以根據大文件上傳的整體流程圖來實現一個 uploadFile 函數:

async function uploadFile() {
  if (!uploadFileEle.files.length) return;
  const file = uploadFileEle.files[0]; // 獲取待上傳的文件
  const fileMd5 = await calcFileMD5(file); // 計算文件的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, // 限制的併發數
     });
  }
  await concatFiles("/concatFiles", file.name, fileMd5);
}

2.2 大文件併發上傳示例

定義完 uploadFile 函數,要實現大文件併發上傳的功能就很簡單了,具體代碼如下所示:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta  />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>大文件併發上傳示例(阿寶哥)</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); // 計算文件的MD5
        // ...
      }
      // 省略其他函數
    </script>
  </body>
</html>

由於完整的示例代碼內容比較多,阿寶哥就不放具體的代碼了。感興趣的小夥伴,可以訪問以下地址瀏覽客戶端和服務器端代碼。

完整的示例代碼(代碼僅供參考,可根據實際情況進行調整):

https://gist.github.com/semlinker/b211c0b148ac9be0ac286b387757e692

最後我們來看一下大文件併發上傳示例的運行結果:

三、總結

本文介紹了在 JavaScript 中如何利用 async-pool 這個庫提供的 asyncPool 函數,來實現大文件的併發上傳。此外,文中我們也使用了 spark-md5 這個庫來計算文件的數字指紋,如果你數字指紋感興趣的話,可以閱讀 數字指紋有什麼用?趕緊來了解一下 這篇文章。

由於篇幅有限,阿寶哥並未介紹服務端的具體代碼。其實在做文件分塊合併時,阿寶哥是以流的形式進行合併,感興趣的小夥伴可以自行閱讀一下相關代碼。如果有遇到不清楚的地方,歡迎隨時跟阿寶哥交流喲。

四、參考資源

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-iSpCMaLruerHv7717P0Wg