溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

web前端怎么使用koa實(shí)現(xiàn)大文件分片上傳

發(fā)布時(shí)間:2022-08-26 11:27:43 來(lái)源:億速云 閱讀:152 作者:iii 欄目:開(kāi)發(fā)技術(shù)

本篇內(nèi)容介紹了“web前端怎么使用koa實(shí)現(xiàn)大文件分片上傳”的有關(guān)知識(shí),在實(shí)際案例的操作過(guò)程中,不少人都會(huì)遇到這樣的困境,接下來(lái)就讓小編帶領(lǐng)大家學(xué)習(xí)一下如何處理這些情況吧!希望大家仔細(xì)閱讀,能夠?qū)W有所成!

    引言

    一個(gè)文件資源服務(wù)器,很多時(shí)候需要保存的不只是圖片,文本之類的體積相對(duì)較小的文件,有時(shí)候,也會(huì)需要保存音視頻之類的大文件。在上傳這些大文件的時(shí)候,我們不可能一次性將這些文件數(shù)據(jù)全部發(fā)送,網(wǎng)絡(luò)帶寬很多時(shí)候不允許我們這么做,而且這樣也極度浪費(fèi)網(wǎng)絡(luò)資源。

    因此,對(duì)于這些大文件的上傳,往往會(huì)考慮用到分片傳輸。

    分片傳輸,顧名思義,也就是將文件拆分成若干個(gè)文件片段,然后一個(gè)片段一個(gè)片段的上傳,服務(wù)器也一個(gè)片段一個(gè)片段的接收,最后再合并成為完整的文件。

    下面我們來(lái)一起簡(jiǎn)單地實(shí)現(xiàn)以下如何進(jìn)行大文件分片傳輸。

    前端

    拆分上傳的文件流

    首先,我們要知道一點(diǎn):文件信息的 File 對(duì)象繼承自 Blob 類,也就是說(shuō), File 對(duì)象上也存在 slice 方法,用于截取指定區(qū)間的 Buffer 數(shù)組。

    通過(guò)這個(gè)方法,我們就可以在取得用戶需要上傳的文件流的時(shí)候,將其拆分成多個(gè)文件來(lái)上傳:

    <script setup lang='ts'>
    import { ref } from "vue"
    import { uploadLargeFile } from "@/api"
    const fileInput = ref<HTMLInputElement>()
    const onSubmit = () => {
      // 獲取文件對(duì)象
      const file = onlyFile.value?.file;
      if (!file) {
        return
      }
      const fileSize = file.size;  // 文件的完整大小
      const range = 100 * 1024; // 每個(gè)區(qū)間的大小
      let beginSide = 0; // 開(kāi)始截取文件的位置
      // 循環(huán)分片上傳文件
      while (beginSide < fileSize) {
        const formData = new FormData()
        formData.append(
          file.name, 
          file.slice(beginSide, beginSide + range), 
          (beginSide / range).toString()
        )
        beginSide += range
        uploadLargeFile(formData)
      }
    }
    </script>
    <template>
      <input
        ref="fileInput"
        type="file"
        placeholder="選擇你的文件"
      >
      <button @click="onSubmit">提交</button>
    </template>

    我們先定義一個(gè) onSubmit 方法來(lái)處理我們需要上傳的文件。

    onSubmit 中,我們先取得 ref 中的文件對(duì)象,這里我們假設(shè)每次有且僅有一個(gè)文件,我們也只處理這一個(gè)文件。

    然后我們定義 一個(gè) beginSiderange 變量,分別表示每次開(kāi)始截取文件數(shù)據(jù)的位置,以及每次截取的片段的大小。

    這樣一來(lái),當(dāng)我們使用 file.slice(beginSide, beginSide + range) 的時(shí)候,我們就取得了這一次需要上傳的對(duì)應(yīng)的文件數(shù)據(jù),之后便可以使用 FormData 封裝這個(gè)文件數(shù)據(jù),然后調(diào)用接口發(fā)送到服務(wù)器了。

    接著,我們使用一個(gè)循環(huán)不斷重復(fù)這一過(guò)程,直到 beginSide 超過(guò)了文件本身的大小,這時(shí)就表示這個(gè)文件的每個(gè)片段都已經(jīng)上傳完成了。當(dāng)然,別忘了每次切完片后,將 beginSide 移動(dòng)到下一個(gè)位置。

    另外,需要注意的是,我們將文件的片添加到表單數(shù)據(jù)的時(shí)候,總共傳入了三個(gè)參數(shù)。第二個(gè)參數(shù)沒(méi)有什么好說(shuō)的,是我們的文件片段,關(guān)鍵在于第一個(gè)和第三個(gè)參數(shù)。這兩個(gè)參數(shù)都會(huì)作為 Content-Disposition 中的屬性。

    第一個(gè)參數(shù),對(duì)應(yīng)的字段名叫做 name ,表示的是這個(gè)數(shù)據(jù)本身對(duì)應(yīng)的名稱,并不區(qū)分是什么數(shù)據(jù),因?yàn)?FormData 不只可以用作文件流的傳輸,也可以用作普通 JSON 數(shù)據(jù)的傳輸,那么這時(shí)候,這個(gè) name 其實(shí)就是 JSON 中某個(gè)屬性的 key 。

    而第二個(gè)參數(shù),對(duì)應(yīng)的字段則是 filename ,這個(gè)其實(shí)才應(yīng)該真正地叫做文件名。

    我們可以使用 wireshark 捕獲一下我們發(fā)送地請(qǐng)求以驗(yàn)證這一點(diǎn)。

    web前端怎么使用koa實(shí)現(xiàn)大文件分片上傳

    我們?cè)儆^察上面構(gòu)建 FormData 的代碼,可以發(fā)現(xiàn),我們 append 進(jìn) FormData 實(shí)例的每個(gè)文件片段,使用的 name 都是固定為這個(gè)文件的真實(shí)名稱,因此,同一個(gè)文件的每個(gè)片,都會(huì)有相同的 name ,這樣一來(lái),服務(wù)器就能區(qū)分哪個(gè)片是屬于哪個(gè)文件的。

    filename ,使用 beginSide 除以 range 作為其值,根據(jù)上下文語(yǔ)意可以推出,每個(gè)片的 filename 將會(huì)是這個(gè)片的 序號(hào) ,這是為了在后面服務(wù)端合并文件片段的時(shí)候,作為前后順序的依據(jù)。

    當(dāng)然,上面的代碼還有一點(diǎn)問(wèn)題。

    在循環(huán)中,我們確實(shí)是將文件切成若干個(gè)片單獨(dú)發(fā)送,但是,我們知道, http 請(qǐng)求是異步的,它不會(huì)阻塞主線程。所以,當(dāng)我們發(fā)送了一個(gè)請(qǐng)求之后,并不會(huì)等這個(gè)請(qǐng)求收到響應(yīng)再繼續(xù)發(fā)送下一個(gè)請(qǐng)求。因此,我們只是做到了將文件拆分成多個(gè)片一次性發(fā)送而已,這并不是我們想要的。

    想要解決這個(gè)問(wèn)題也很簡(jiǎn)單,只需要將 onSubmit 方法修改為一個(gè)異步方法,使用 await 等待每個(gè) http 請(qǐng)求完成即可:

    // 省略一些代碼
    const onSubmit = async () => {
      // ......
      while(beginSide < fileSize) {
        // ......
        await uploadLargeFile(formData)
      }
    }
    // ......

    這樣一來(lái),每個(gè)片都會(huì)等到上一個(gè)片發(fā)送完成才發(fā)送,可以在網(wǎng)絡(luò)控制臺(tái)的時(shí)間線中看到這一點(diǎn):

    后端

    接收文件片段

    這里我們使用的 koa-body 來(lái) 處理上傳的文件數(shù)據(jù):

    import Router = require("@koa/router")
    import KoaBody = require("koa-body")
    import { resolve } from 'path'
    import { publicPath } from "../common";
    import { existsSync, mkdirSync } from "fs"
    import { MD5 } from "crypto-js"
    const router = new Router()
    const savePath = resolve(publicPath, 'assets')
    const tempDirPath = resolve(publicPath, "assets", "temp")
    router.post(
      "/upload/largeFile",
      KoaBody({
        multipart: true,
        formidable: {
          maxFileSize: 1024 * 1024 * 2,
          onFileBegin(name, file) {
            const hashDir = MD5(name).toString()
            const dirPath = resolve(tempDirPath, hashDir)
            if (!existsSync(dirPath)) {
              mkdirSync(dirPath, { recursive: true })
            }
            if (file.originalFilename) {
              file.filepath = resolve(dirPath, file.originalFilename)
            }
          }
        }
      }),
      async (ctx, next) => {
        ctx.response.body = "done";
        next()
      }
    )

    我們的策略是先將同一個(gè) name 的文件片段收集到以這個(gè) name 進(jìn)行 MD5 哈希轉(zhuǎn)換后對(duì)應(yīng)的文件夾名稱的文件夾當(dāng)中,但使用 koa-body 提供的配置項(xiàng)無(wú)法做到這么細(xì)致的工作,所以,我們需要使用自定義 onFileBegin ,即在文件保存之前,將我們期望的工作完成。

    首先,我們拼接出我們期望的路徑,并判斷這個(gè)路徑對(duì)應(yīng)的文件夾是否已經(jīng)存在,如果不存在,那么我們先創(chuàng)建這個(gè)文件夾。然后,我們需要修改 koa-body 傳給我們的 file 對(duì)象。因?yàn)閷?duì)象類型是引用類型,指向的是同一個(gè)地址空間,所以我們修改了這個(gè) file 對(duì)象的屬性, koa-body 最后獲得的 file 對(duì)象也就被修改了,因此, koa-body 就能夠根據(jù)我們修改的 file 對(duì)象去進(jìn)行后續(xù)保存文件的操作。

    這里我們因?yàn)橐獙⒈4娴奈募付槲覀兤谕穆窂?,所以需要修?filepath 這個(gè)屬性。

    而在上文中我們提到,前端在 FormData 中傳入了第三個(gè)參數(shù)(文件片段的序號(hào)),這個(gè)參數(shù),我們可以通過(guò) file.originalFilename 訪問(wèn)。這里,我們就直接使用這個(gè)序號(hào)字段作為文件片段的名稱,也就是說(shuō),每個(gè)片段最終會(huì)保存到 ${tempDir}/${hashDir}/${序號(hào)} 這個(gè)文件。

    由于每個(gè)文件片段沒(méi)有實(shí)際意義以及用處,所以我們不需要指定后綴名。

    合并文件片段

    在我們合并文件之前,我們需要知道文件片段是否已經(jīng)全部上傳完成了,這里我們需要修改一下前端部分的 onSubmit 方法,以發(fā)送給后端這個(gè)信號(hào):

    // 省略一些代碼
    const onSubmit = async () => {
      // ......
      while(beginSide < fileSize) {
        const formData = new FormData()
        formData.append(
          file.name, 
          file.slice(beginSide, beginSide + range), 
          (beginSide / range).toString()
        )
        beginSide += range
        // 滿足這個(gè)條件表示文件片段已經(jīng)全部發(fā)送完成,此時(shí)在表單中帶入結(jié)束信息
        if(beginSide >= fileSize) {
          formData.append("over", file.name)
        }
        await uploadLargeFile(formData)
      }
    }
    // ......

    為圖方便,我們直接在一個(gè)接口中做傳輸結(jié)束的判斷。判斷的依據(jù)是:當(dāng) beiginSide 大于等于 fileSize 的時(shí)候,就放入一個(gè) over 字段,并以這個(gè)文件的真實(shí)名稱作為其屬性值。

    這樣,后端代碼就可以以是否存在 over 這個(gè)字段作為文件片段是否已經(jīng)全部發(fā)送完成的標(biāo)志:

    router.post(
      "/upload/largeFile",
      KoaBody({
        // 省略一些配置
      }),
      async (ctx, next) => {
        if (ctx.request.body.over) { // 如果 over 存在值,那么表示文件片段已經(jīng)全部上傳完成了
          const _fileName = ctx.request.body.over;
          const ext = _fileName.split("\.")[1]
          const hashedDir = MD5(_fileName).toString()
          const dirPath = resolve(tempDirPath, hashedDir)
          const fileList = readdirSync(dirPath);
          let p = Promise.resolve(void 0)
          fileList.forEach(fragmentFileName => {
            p = p.then(() => new Promise((r) => {
                const ws = createWriteStream(resolve(savePath, `${hashedDir}.${ext}`), { flags: "a" })
                const rs = createReadStream(resolve(dirPath, fragmentFileName))
                rs.pipe(ws).on("finish", () => {
                  ws.close()
                  rs.close();
                  r(void 0)
                })
              })
            )
          })
          await p
        }
        ctx.response.body = "done";
        next()
      }
    )

    我們先取得這個(gè)文件真實(shí)名字的 hash ,這個(gè)也是我們之前用于存放對(duì)應(yīng)文件片段使用的文件夾的名稱。

    接著我們獲取該文件夾下的文件列表,這會(huì)是一個(gè)字符串?dāng)?shù)組(并且由于我們前期的設(shè)計(jì)邏輯,我們不需要在這里考慮文件夾的嵌套)。

    然后我們遍歷這個(gè)數(shù)組,去拿到每個(gè)文件片段的路徑,以此來(lái)創(chuàng)建一個(gè)讀入流,再以存放合并后的文件的路徑創(chuàng)建一個(gè)寫(xiě)入流(注意,此時(shí)需要帶上擴(kuò)展名,并且,需要設(shè)置 flags'a' ,表示追加寫(xiě)入),最后以管道流的方式進(jìn)行傳輸。

    但我們知道,這些使用到的流的操作都是異步回調(diào)的。可是,我們保存的文件片段彼此之間是有先后順序的,也就是說(shuō),我們得保證在前面一個(gè)片段寫(xiě)入完成之后再寫(xiě)入下一個(gè)片段,否則文件的數(shù)據(jù)就錯(cuò)誤了。

    要實(shí)現(xiàn)這一點(diǎn),需要使用到 Promise 這一api。

    首先我們定義了一個(gè) fulfilled 狀態(tài)的 Promise 變量 p ,也就是說(shuō),這個(gè) p 變量的 then 方法將在下一個(gè)微任務(wù)事件的調(diào)用時(shí)間點(diǎn)直接被執(zhí)行。

    接著,我們?cè)诒闅v文件片段列表的時(shí)候,不直接進(jìn)行讀寫(xiě),而是把讀寫(xiě)操作放到 pthen 回調(diào)當(dāng)中,并且將其封裝在一個(gè) Promsie 對(duì)象當(dāng)中。在這個(gè) Promise 對(duì)象中,我們把 resolve 方法的執(zhí)行放在管道流的 finish 事件中,這表示,這個(gè) then 回調(diào)返回的 Promise 實(shí)例,將會(huì)在一個(gè)文件片段寫(xiě)入完成后被修改狀態(tài)。此時(shí),我們只需要將這個(gè) then 回調(diào)返回的 Promsie 實(shí)例賦值給 p 即可。

    這樣一來(lái),在下個(gè)遍歷節(jié)點(diǎn),也就是處理第二個(gè)文件片段的時(shí)候,取得的 p 的值便是上一個(gè)文件片段執(zhí)行完讀寫(xiě)操作返回的 Promise 實(shí)例,而且第二個(gè)片段的執(zhí)行代碼會(huì)在第一個(gè)片段對(duì)應(yīng)的 Promise 實(shí)例 then 方法被觸發(fā),也就是上一個(gè)片段的文件寫(xiě)入完成之后,再添加到微任務(wù)隊(duì)列。

    以此類推,每個(gè)片段都會(huì)在前一個(gè)片段寫(xiě)入完成之后再進(jìn)行寫(xiě)入,保證了文件數(shù)據(jù)先后順序的正確性。

    當(dāng)所有的文件片段讀寫(xiě)完成后,我們就拿實(shí)現(xiàn)了將完整的文件保存到了服務(wù)器。

    不過(guò)上面的還有許多可以優(yōu)化的地方,比如:在合并完文件之后,刪除所有的文件片段,節(jié)省磁盤(pán)空間;

    使用一個(gè) Map 來(lái)保存真實(shí)文件名與 MD5 哈希值的映射關(guān)系,避免每次都進(jìn)行 MD5 運(yùn)算等等。但這里只是給出了簡(jiǎn)單的實(shí)習(xí),具體的優(yōu)化還請(qǐng)根據(jù)實(shí)際需求進(jìn)行調(diào)整。

    “web前端怎么使用koa實(shí)現(xiàn)大文件分片上傳”的內(nèi)容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業(yè)相關(guān)的知識(shí)可以關(guān)注億速云網(wǎng)站,小編將為大家輸出更多高質(zhì)量的實(shí)用文章!

    向AI問(wèn)一下細(xì)節(jié)

    免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

    AI