面試官桀桀一笑:你沒做過大檔案上傳功能?那你回去等通知吧!

水冗水孚發表於2023-04-09
  • 本文略長,建議收藏,文末會附上完整前後端程式碼(vue2&vue3+springboot
  • 湊合算是一套解決方案吧???
  • 前端vscode大家都有,後端大家需要下載一個idea,搞一下maven,這一點可以請後端同事幫忙
  • 對於普通的單個的大檔案上傳需求,應該可以應對
  • 筆者本地測試,兩三個G的大檔案沒有問題,線上嘛,你懂的

大檔案上傳問題描述

問題背景

筆者的一個好友上個月被裁,最近在面試求職,在面試時,最後一個問題是問他有沒有做過大檔案上傳功能,我朋友說沒做過...

現在的就業環境不太好,要求都比之前高一些,當然也有可能面試官刷面試KPI的,或者這個崗位不急著找人,慢慢面試唄。畢竟也算是自己的工作量,能寫進週報裡面...

對於我們每個人而言:【生於黑暗,追逐黎明————《異獸迷城》】

既然面試官會問,那我們們就一起來看看,大檔案上傳功能如何實現吧...

中等檔案上傳解決方案-nginx放行

在我們工作中,上傳功能最常見的就是excel的上傳功能,一般來說,一個excel的大小在10MB以內吧,如果有好幾十MB的excel,就勉強算是中等檔案吧,此時,我們需要設定nginx的client_max_body_size值,將其放開,只不過一次上傳一個幾十MB的檔案,介面會慢一些,不過也勉強能夠接受。

前端手握狼牙棒,後端手持流星錘,對產品朗聲笑道:要是不能接受,就請忍受???

但是,如果一個檔案有幾百兆,或者好幾個G呢?上述方式就不合適了。

既然一次性上傳不行,那麼我們們就把大檔案拆分開來,進行分批、分堆、分片、一點點上傳的操作,等上傳完了,再將一片片檔案合併一起,再恢復成原來的樣子即可

最常見的這個需求,就是影片的上傳,比如:騰訊影片創作平臺、嗶哩嗶哩後臺等...

大檔案上傳解決方案-檔案分片

一共三步即可:

  • 第一步,大檔案拆分成一片又一片(分片操作)
  • 第二步,每一次請求給後端帶一片檔案(分片上傳)
  • 第三步,當每一片檔案都上傳完,再發請求告知後端將分片的檔案合併即可(合併分片)
檔案分片操作大致可分為上述三步驟,但在這三步驟中,還有一些細節需要我們注意,這個後文中會一一說到,我們繼續往下閱讀

大檔案上傳效果圖

為便於更好理解,我們看一下已經做好的效果圖:

由上述效果圖,我們可以看到,一個58MB的大檔案,被分成了12片上傳,很快啊!上傳完成。

思考兩個問題:

  1. 若某個檔案已經存在(曾經上傳過),那我還需要上傳嗎?
  2. 若同一時刻,兩個人都在分片上傳完大檔案,併發起合併請求,如何才能保證不合並錯呢?如A檔案分片成a1,a2,a3;B檔案分片成b1,b2,b3 。合併操作肯定不能把a1,a2,a3檔案內容合併到B檔案中去。

解決方案就是:

  • 要告知後端我這次上傳的檔案是哪一個,下次上傳的檔案又是哪一個
  • 就像我們去修改表格中的某條資料時,需要有一個固定的引數id,告知後端去update具體的那一條資料
  • 知道具體的檔案id,就不會操作錯了

那新的問題又來了:

前端如何才能確定檔案的id,如何才能得到檔案的唯一標識?

如何得到檔案唯一標識?

樹上沒有兩片相同的葉子,天上沒有兩朵相同的雲彩,檔案是獨一無二的(前提是內容不同,複製一份的不算)

who know?

spark-md5怪笑一聲: 寡人知曉!

什麼是spark-md5?

spark-md5是基於md5的一種優秀的演算法,用處很多,其中就可以去計算檔案的唯一身份證標識-hash值

  • 只要檔案內容不同(包含的二進位制01不同),那麼使用spark-md5這個npm包包,得到的結果hash值就不一樣
  • 這個獨一無二的hash值,就可以看做大檔案的id
  • 發請求時,就可以將這個大檔案的hash值唯一id帶著傳給後端,後端就知道去操作那個檔案了
當然還有別的工具庫,如CryptoJS也可以計算檔案的hash值,不過spark-md5更主流、更優秀

使用spark-md5直接計算整個檔案的hash值(唯一id身份證標識)

直接計算一整個檔案的hash值:

<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>

<input type="file" @change="changeFile">
<script>
    const inputDom = document.querySelector('input') // 獲取input檔案標籤的dom元素
    inputDom.onchange = (e) => {
        let file = inputDom.files[0] // 拿到檔案
        let spark = new SparkMD5.ArrayBuffer() // 例項化spark-md5
        let fileReader = new FileReader() // 例項化檔案閱讀器
        fileReader.onload = (e) => {
            spark.append(e.target.result) // 新增到spark演算法中計算
            let hash = spark.end() // 計算完成得到hash結果
            console.log('檔案的hash值為:', hash);
        }
        fileReader.readAsArrayBuffer(file) // 開始閱讀這個檔案,閱讀完成觸發onload方法
    }
</script>

直接計算一個整檔案的hash值,檔案小的話,還是比較快的,但是當檔案比較大的時,直接計算一整個檔案的hash值,就會比較慢了。

此刻大檔案分片的好處,再一次體現出來:大檔案分片不僅僅可以用於傳送請求傳遞給後端,也可以用於計算大檔案的hash值,直接計算一個大檔案hash值任務慢,那就拆分成一些小任務,這樣效率也提升了不少

至此,又延伸出一個問題,如何給大檔案分片?

當我們想解決一個A問題時,我們發現需要進一步,解決其中包含a1問題,當我們想要解決a1問題時,我們發現需要再進一步解決a1的核心a11問題。當a11問題被解決時,a1也就解決了,與此同時A問題也就迎刃而解了

給檔案分片操作

  • 檔案分片,別名檔案分堆,又名檔案分塊,也叫作檔案拆分
  • 類比,一個大的字串可以擷取slice(切割)成好幾個小的字串
  • 同理,一個大檔案也可以slice成好多小檔案,對應api: file.slice
  • 檔案file是特殊的二進位制blob檔案(所以file可以用blob的方法)
  • 上程式碼
const inputDom = document.querySelector('input') // 獲取input檔案標籤的dom元素
inputDom.onchange = (e) => {
    let file = inputDom.files[0] // 拿到檔案
    function sliceFn(file, chunkSize = 1 * 1024 * 1024) {
        const result = [];
        // 從第0位元組開始切割,一次切割1 * 1024 * 1024位元組
        for (let i = 0; i < file.size; i = i + chunkSize) {
            result.push(file.slice(i, i + chunkSize));
        }
        return result;
    }
    const chunks = sliceFn(file)
    console.log('檔案分片成陣列', chunks);
}

檔案分片結果效果圖(比如我選了一個5兆多的檔案去分片):

大檔案分片後搭配spark-md5計算整個檔案的hash值

有了上述分好片的chunks陣列(陣列中存放一片又一片小檔案),再結合spark-md5,使用遞迴的寫法,一片一片的再去讀取計算,最終算出結果

/**
* chunks:檔案分好片的陣列、progressCallbackFn回撥函式方法,用於告知外界進度的
* 因為檔案閱讀器是非同步的,所以要套一層Promise方便拿到非同步的計算結果
**/ 
function calFileMd5Fn(chunks, progressCallbackFn) {
    return new Promise((resolve, reject) => {
        let currentChunk = 0 // 準備從第0塊開始讀
        let spark = new SparkMD5.ArrayBuffer() // 例項化SparkMD5用於計算檔案hash值
        let fileReader = new FileReader() // 例項化檔案閱讀器用於讀取blob二進位制檔案
        fileReader.onerror = reject // 兜一下錯
        fileReader.onload = (e) => {
            progressCallbackFn(Math.ceil(currentChunk / chunks.length * 100)) // 丟擲一個函式,用於告知進度
            spark.append(e.target.result) // 將二進位制檔案追加到spark中(官方方法)
            currentChunk = currentChunk + 1 // 這個讀完就加1,讀取下一個blob
            // 若未讀取到最後一塊,就繼續讀取;否則讀取完成,Promise帶出結果
            if (currentChunk < chunks.length) {
                fileReader.readAsArrayBuffer(chunks[currentChunk])
            } else {
                resolve(spark.end()) // resolve出去告知結果 spark.end官方api
            }
        }
        // 檔案讀取器的readAsArrayBuffer方法開始讀取檔案,從blob陣列中的第0項開始
        fileReader.readAsArrayBuffer(chunks[currentChunk])
    })
}

使用:

inputDom.onchange = (e) => {
    let file = inputDom.files[0]
    function sliceFn(file, chunkSize = 1 * 1024 * 1024) {
        const result = [];
        for (let i = 0; i < file.size; i = i + chunkSize) {
            result.push(file.slice(i, i + chunkSize));
        }
        return result;
    }
    const chunks = sliceFn(file)
    // 分好片的大檔案陣列,去計算hash。progressFn為進度條函式,需額外定義
    const hash = await calFileMd5Fn(chunks,progressFn)
    // "233075d0c65166792195384172387deb" // 32位的字串
}

至此,我們大檔案分片上傳操作,已經完成了三分之一了。我們已經完成了大檔案的分片和計算大檔案的hash值唯一身份證id(實際上,計算大檔案的hash值,還是挺耗費時長的,最佳化方案就是開一個輔助執行緒進行非同步計算操作,不過這個是最佳化的點,文末會提到)

接下來,就到了第二步,發請求環節:將已經分好片的每一片和這個大檔案的hash值作為引數傳遞給後端(當然還有別的引數,比如檔名、檔案分了多少片,每次上傳的是那一片【索引】等---看後端定義)

大檔案上傳解決方案:

  • 第一步,大檔案拆分成一片又一片(分片操作)✔️
  • 第二步,每一次請求給後端帶一片檔案(分片上傳)
  • 第三步,當每一片檔案都上傳完,再發請求告知後端將分片的檔案合併即可

分片上傳發請求,一片就是一請求

詩曰:

分片上傳發請求,一片就是一請求。

請求之前帶校驗,這樣操作才規範。

分片上傳請求前的校驗請求

校驗邏輯思路如下:

  • 大檔案分好片以後,在分片檔案上傳前,先發個請求帶著大檔案的唯一身份證標識hash值,去問問後端有沒有上傳過這個檔案,或者服務端的這個檔案是否上傳完整(比如曾經上傳一半的時候,突然斷網了,或者重新整理網頁導致上傳中斷)
  • 後端去看看已經操作完成的資料夾中的檔案,有沒有叫做這個hash的,根據有沒有返回不同的狀態碼

比如,如下狀態碼:

  • 等於0表示沒有上傳過,直接上傳
  • 等於1曾經上傳過,不需要再上傳了(或:障眼法檔案秒傳遞)
  • 等於2表示曾經上傳過一部分,現在要繼續上傳

對應前端程式碼:

以下程式碼舉例是vue3的語法舉例,大家知道每一步做什麼即可,文章看完,建議大家去筆者的github倉庫把前後端程式碼,都拉下來跑起來,結合程式碼中的註釋,才能夠更好的理解

html結構

<template>
  <div id="app">
    <input ref="inputRef" class="inputFile" type="file" @change="changeFile" />
    <div>大檔案 <span class="bigFileC">?</span> 分了{{ chunksCount }}片:</div>
    <div class="pieceItem" v-for="index in chunksCount" :key="index">
      <span class="a">{{ index - 1 }}</span>
      <span class="b">?</span>
    </div>
    <div>計算此大檔案的hash值進度</div>
    <div class="r">結果為: {{ fileHash }}</div>
    <progress max="100" :value="hashProgress"></progress> {{ hashProgress }}%
    <div>
      <div>上傳檔案的進度</div>
      <div class="r" v-show="fileProgress == 100">檔案上傳完成</div>
      <progress max="100" :value="fileProgress"></progress> {{ fileProgress }}%
    </div>
  </div>
</template>

發校驗請求

/**
 * 發請求,校驗檔案是否上傳過,分三種情況:見:fileStatus
 * */
export function checkFileFn(fileMd5) {
    return new Promise((resolve, reject) => {
        resolve(axios.post(`http://127.0.0.1:8686/bigfile/check?fileMd5=${fileMd5}`))
    })
}

const res = await checkFileFn(fileMd5);
// res.data.resultCode 為0 或1 或2

對應後端程式碼:

筆者後端程式碼是springboot,文末會附上程式碼,大家看一下

private String fileStorePath = "F:\kkk\"; // 大檔案上傳操作在F盤下的kkk資料夾中操作

/**
 * @param fileMd5
 * @Title: 判斷檔案是否上傳過,是否存在分片,斷點續傳
 * @MethodName: checkBigFile
 * @Exception
 * @Description: 檔案已存在,1
 * 檔案沒有上傳過,0
 * 檔案上傳中斷過,2 以及現在有的陣列分片索引
 */
 
@RequestMapping(value = "/check", method = RequestMethod.POST)
@ResponseBody
public JsonResult checkBigFile(String fileMd5) {
    JsonResult jr = new JsonResult();
    // 秒傳
    File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5);
    if (mergeMd5Dir.exists()) {
        mergeMd5Dir.mkdirs();
        jr.setResultCode(1);//檔案已存在
        return jr;
    }
    // 讀取目錄裡的所有檔案
    File dir = new File(fileStorePath + "/" + fileMd5);
    File[] childs = dir.listFiles();
    if (childs == null) {
        jr.setResultCode(0);//檔案沒有上傳過
    } else {
        jr.setResultCode(2);//檔案上傳中斷過,除了狀態碼為2,還有已上傳的檔案分片索引
        List<String> list = Arrays.stream(childs).map(f->f.getName()).collect(Collectors.toList());
        jr.setResultData(list.toArray());
    }
    return jr;
}

前端根據介面的狀態碼,作相應控制,沒上傳過正常操作,曾經上傳過了,就做個提示檔案已上傳。這裡需要特別注意一下,曾經上傳中斷的情況

特別情況:當前上傳的檔案曾經中斷過(斷點續傳)

我們來捋一下邏輯就明晰了:

  • 假設一個大檔案分為了10片,對應檔案片的索引是0~9
  • 在執行上傳的時候,發了10個請求,分別帶上對應的索引檔案片
  • 由於不可抗力因素,導致只上傳成功了3片檔案,分別是索引0、索引8、索引9
  • 還有索引1、2、3、4、5、6、7這七片檔案沒上傳成功
  • 那麼在檢查檔案時,後端除了返回狀態碼2,同時也返回後端已經上傳成功的片的索引有哪些
  • 即:{resultCode:2 , resultData:[0,8,9]}
  • 我們在執行上傳檔案操作時候,去掉這三個已經上傳完成的即可,上傳那些未完成的
// 等於2表示曾經上傳過一部分,現在要繼續上傳
if (res.data.resultCode == 2) {
    // 若是檔案曾上傳過一部分,後端會返回上傳過得部分的檔案索引,前端透過索引可以知道哪些
    // 上傳過,做一個過濾,已上傳的檔案就不用繼續上傳了,上傳未上傳過的檔案片
    doneFileList = res.data.resultData.map((item) => {
      return item * 1; // 後端給到的是字串索引,這裡轉成數字索引
    });
}

doneFileList陣列儲存的就是後端返回的,曾經上傳過一部分的陣列分片檔案索引

比如下面這兩張圖,就是檔案曾經上傳中斷以後的,再次上傳的檢查介面返回的資料

示例圖一:

返回的是分片檔案的名,也就是分片的索引,如下圖:

前端根據doneFileList判斷,去準備引數

  // 說明沒有上傳過,組裝一下,直接使用
  if (doneFileList.length == 0) {
    formDataList = chunks.map((item, index) => {
      // 後端接參大致有:檔案片、檔案分的片數、每次上傳是第幾片(索引)、檔名、此完整大檔案hash值
      // 具體後端定義的引數prop屬性名,看他們如何定義的,這個無妨...
      let formData = new FormData();
      formData.append("file", item); // 使用FormData可以將blob檔案轉成二進位制binary
      formData.append("chunks", chunks.length);
      formData.append("chunk", index);
      formData.append("name", fileName);
      formData.append("md5", fileMd5);
      return { formData };
    });
  }
  // 說明曾經上傳過,需要過濾一下,曾經上傳過的就不用再上傳了
  else {
    formDataList = chunks
      .filter((index) => {
        return !doneFileList.includes(index);
      })
      .map((item, index) => {
        let formData = new FormData();
        // 這幾個是後端需要的引數
        formData.append("file", item); // 使用FormData可以將blob檔案轉成二進位制binary
        formData.append("chunks", chunks.length);
        formData.append("chunk", index);
        formData.append("name", fileName);
        formData.append("md5", fileMd5);
        return { formData };
      });
  }
  // 帶著分片陣列請求引數,和檔名 fileName = file.name
  // 準備一次併發很多的請求
  fileUpload(formDataList, fileName);

上述程式碼實現了,正常上傳以及曾經中斷過的檔案繼續上傳,這就是斷點續傳

上述程式碼實現了,正常上傳以及曾經中斷過的檔案繼續上傳,這就是斷點續傳

上述程式碼實現了,正常上傳以及曾經中斷過的檔案繼續上傳,這就是斷點續傳

使用Promise.allSettled(arr)併發上傳分好片的檔案

  • 使用Promise.allSettled發請求好一些,掛了的就掛了,不影響後續不掛的分片上傳請求
  • Promise.all則不行,一個掛了都掛了

前端程式碼

const fileUpload = (formDataList, fileName) => {
  const requestListFn = formDataList.map(async ({ formData }, index) => {
    const res = await sliceFileUploadFn(formData);
    // 每上傳完畢一片檔案,後端告知已上傳了多少片,除以總片數,就是進度
    fileProgress.value = Math.ceil(
      (res.data.resultData / chunksCount.value) * 100
    );
    return res;
  });
  // 使用allSettled發請求好一些,掛了的就掛了,不影響後續不掛的請求
  Promise.allSettled(requestListFn).then((many) => {
    // 都上傳完畢了,檔案上傳進度條就為100%了
  });
};

後端程式碼

/**
 * 上傳檔案
 * @param param
 * @param request
 * @return
 * @throws Exception
 */
@RequestMapping(value = "/upload", method = RequestMethod.POST)
@ResponseBody
public JsonResult filewebUpload(MultipartFileParam param, HttpServletRequest request) {
    JsonResult jr = new JsonResult();
    boolean isMultipart = ServletFileUpload.isMultipartContent(request);
    // 檔名
    String fileName = param.getName();
    // 檔案每次分片的下標
    int chunkIndex = param.getChunk();
    if (isMultipart) {
        File file = new File(fileStorePath + "/" + param.getMd5());
        if (!file.exists()) { // 沒有檔案建立檔案
            file.mkdir();
        }
        File chunkFile = new File(
                fileStorePath + "/" + param.getMd5() + "/" + chunkIndex);
        try {
            FileUtils.copyInputStreamToFile(param.getFile().getInputStream(), chunkFile); // 流檔案操作
        } catch (Exception e) {
            jr.setResultCode(-1);
            e.printStackTrace();
        }
    }
    logger.info("檔案-:{}的小標-:{},上傳成功", fileName, chunkIndex);
    File dir = new File(fileStorePath + "/" + param.getMd5());
    File[] childs = dir.listFiles();
    if(childs!=null){
        jr.setResultData(childs.length); // 返回上傳了幾個,即為上傳進度
    }
    return jr;
}

最後別忘了去合併這些檔案分片

添一個上傳檔案的效果圖

  • 由上述動態圖,我們可以看到把檔案切割成了12份,所以傳送了12個上傳分片請求
  • 當然,上傳完成以後,最後,再發一個請求,告知後端去合併這些一片片檔案即可
  • 即merge請求,當然也要帶上此大檔案的hash值
  • 告知後端具體合併哪一個檔案,這樣才不會出錯

前端程式碼

// 使用allSettled發請求好一些,掛了的就掛了,不影響後續不掛的請求
Promise.allSettled(requestListFn).then(async (many) => {
    // 都上傳完畢了,檔案上傳進度條就為100%了
    fileProgress.value = 100;
    // 最後再告知後端合併一下已經上傳的檔案碎片了即可
    const loading = ElLoading.service({
      lock: true,
      text: "檔案合併中,請稍後???...",
      background: "rgba(0, 0, 0, 0.7)",
    });
    const res = await tellBackendMergeFn(fileName, fileHash.value);
    if (res.data.resultCode === 0) {
      console.log("檔案併合成功,大檔案上傳任務完成");
      loading.close();
    } else {
      console.log("檔案併合失敗,大檔案上傳任務未完成");
      loading.close();
    }
});

後端程式碼

    /**
     * 分片上傳成功之後,合併檔案
     * @param request
     * @return
     */
    @RequestMapping(value = "/merge", method = RequestMethod.POST)
    @ResponseBody
    public JsonResult filewebMerge(HttpServletRequest request) {
        FileChannel outChannel = null;
        JsonResult jr = new JsonResult();
        int code =0;
        try {
            String fileName = request.getParameter("fileName");
            String fileMd5 = request.getParameter("fileMd5");
            // 讀取目錄裡的所有檔案
            File dir = new File(fileStorePath + "/" + fileMd5);
            File[] childs = dir.listFiles();
            if (Objects.isNull(childs) || childs.length == 0) {
                jr.setResultCode(-1);
                return jr;
            }
            // 轉成集合,便於排序
            List<File> fileList = new ArrayList<File>(Arrays.asList(childs));
            Collections.sort(fileList, new Comparator<File>() {
                @Override
                public int compare(File o1, File o2) {
                    if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) {
                        return -1;
                    }
                    return 1;
                }
            });
            // 合併後的檔案
            File outputFile = new File(fileStorePath + "/" + "merge" + "/" + fileMd5 + "/" + fileName);
            // 建立檔案
            if (!outputFile.exists()) {
                File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5);
                if (!mergeMd5Dir.exists()) {
                    mergeMd5Dir.mkdirs();
                }
                logger.info("建立檔案");
                outputFile.createNewFile();
            }
            outChannel = new FileOutputStream(outputFile).getChannel();
            FileChannel inChannel = null;
            try {
                for (File file : fileList) {
                    inChannel = new FileInputStream(file).getChannel();
                    inChannel.transferTo(0, inChannel.size(), outChannel);
                    inChannel.close();
                    // 刪除分片
                    file.delete();
                }
            } catch (Exception e) {
                code =-1;
                e.printStackTrace();
                //發生異常,檔案合併失敗 ,刪除建立的檔案
                outputFile.delete();
                dir.delete();//刪除資料夾
            } finally {
                if (inChannel != null) {
                    inChannel.close();
                }
            }
            dir.delete(); //刪除分片所在的資料夾
        } catch (IOException e) {
            code =-1;
            e.printStackTrace();
        } finally {
            try {
                if (outChannel != null) {
                    outChannel.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        jr.setResultCode(code);
        return jr;
    }
}

至此,大檔案上傳的三步都完成了

大檔案上傳解決方案:

  • 第一步,大檔案拆分成一片又一片(分片操作)✔️
  • 第二步,每一次請求給後端帶一片檔案(分片上傳)✔️
  • 第三步,當每一片檔案都上傳完,再發請求告知後端將分片的檔案合併即可✔️
  • 筆者用本機測試了一下,兩三個G的檔案都是沒有問題的
  • 實際專案上線,大檔案上傳功能,會受到網路頻寬、裝置效能等各種因素影響
  • 一定要注意檔案分片時切分的大小,例:CHUNK_SIZE = 5 * 1024 * 1024;
  • 這個檔案的大小決定了切割多少片,決定了併發多少請求(不可過大,也不可能非常小)
  • 太大單個請求就太慢了,太小瀏覽器一次發幾千上萬個請求,也扛不住

輔助執行緒去最佳化

開啟輔助執行緒計算大檔案的hash值

首先,定義函式非同步,開啟輔助執行緒,計算

const calFileMd5ByThreadFn = (chunks) => {
  return new Promise((resolve) => {
    worker = new Worker("./hash.js"); // 例項化一個webworker執行緒
    worker.postMessage({ chunks }); // 主執行緒向輔助執行緒傳遞資料,發分片陣列用於計算
    worker.onmessage = (e) => {
      const { hash } = e.data; // 輔助執行緒將相關計算資料發給主執行緒
      hashProgress.value = e.data.hashProgress; // 更改進度條
      if (hash) {
        // 當hash值被算出來時,就可以關閉主執行緒了
        worker.terminate();
        resolve(hash); // 將結果帶出去
      }
    };
  });
};

然後,在public目錄下新建hash.js去撰寫輔助執行緒程式碼

// 使用importScripts引入cdn使用
self.importScripts('https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js')
self.onmessage = e => {
    const { chunks } = e.data // 獲取到分片陣列
    const spark = new self.SparkMD5.ArrayBuffer() // 例項化spark物件用於計算檔案hash
    let currentChunk = 0
    let fileReader = new FileReader()
    fileReader.onload = (e) => {
        spark.append(e.target.result)
        currentChunk = currentChunk + 1
        if (currentChunk < chunks.length) {
            fileReader.readAsArrayBuffer(chunks[currentChunk])
            // 未曾計算完只告知主執行緒計算進度
            self.postMessage({
                hashProgress: Math.ceil(currentChunk / chunks.length * 100)
            })
        } else {
            // 計算完了進度和hash結果就都可以告知了
            self.postMessage({
                hash: spark.end(),
                hashProgress: 100
            })
            self.close();
        }
    }
    fileReader.readAsArrayBuffer(chunks[currentChunk])
}

使用的話,直接傳遞分好片檔案陣列引數即可

const fileMd5 = await calFileMd5ByThreadFn(chunks); // 根據分片計算
console.log('hash',fileMd5) // 得出此大檔案的hash值了
單純計算加減乘除啥的倒是可以使用vue-worker這個外掛,參見筆者之前的文章:https://segmentfault.com/a/1190000043411552

這樣的話,速度就會快一些了...

附錄

大檔案上傳流程圖

  • 當我們把上述文章讀完以後,一個大檔案上傳的流程圖就清晰的浮現在我們的腦海中了
  • 筆者用processOn畫了一個流程圖,如下:

程式碼倉庫

程式碼倉庫:https://github.com/shuirongshuifu/bigfile

歡迎star,您的認可是我們創作的動力哦

當下後端程式碼是java同事濤哥提供的,感謝之。

後續空閒了(star多了),筆者再補充node版本的後端程式碼吧

參考資料

思考

  • 到這裡的話,普通公司的大檔案上傳需求(一次上傳一個),基本上湊合解決
  • 本文的內容也應該基本上能應付面試官了
  • 但是如何才能自己做到類似百度網盤那種上傳效果?請研究webuploader原始碼
  • 道阻且長,還是需要我們持續最佳化的...

相關文章