How to elegantly upload multiple files

January 11, 2023

  • vue
  • concept
有任何問題,我能幫上忙的話都能到 github 發 issue 問我。

Preface

要如何優雅的上傳大量的檔案呢?這是一個很常見的問題,但是大多數的人都會用一個一個的上傳,這樣的方式,所以當上傳的檔案數量很多的時候,就會變得很慢,而且也不太好管理,所以今天就來寫一下如何優雅的上傳大量的檔案。

Vue frontend code

下方是很簡單的上傳檔案的前端程式碼,這邊我有加上 webkitdirectory,讓使用者可以以選擇資料夾的方式進行上傳,這部分請依照自己的需求做修改。

後續我會將重點著重在 handleFileChange 這個 function 上面。

<script setup lang='ts'>
import { ref } from 'vue'

const inputRef = ref<HTMLInputElement>()

async function handleFileChange() {
  // how to upload multiple files
}
</script>

<template>
  <label for='file-input'>
    <input 
      id='file-input' 
      ref='inputRef' 
      type="file" 
      webkitdirectory
      multiple 
      @change="handleFileChange" 
      style='display: none' 
    />
    <button @click='inputRef?.click()'>Upload</button>
  </label>
</template>

<style lang='scss' scoped></style>

How to upload multiple files

其實上傳檔案的重點就是,你對 Promise 這個物件,是否有更深一層的了解。

async function handleFileChange() {
  const files = (inputRef.value as HTMLInputElement).files

  if (files) {
    // 由於 files 是一個 FileList 物件,所以我們若想要使用 array 的方式操作他,需要將它轉成陣列
    // 接著就可以用下方的方式做你需要的事情,當然你也可以單純的使用 for loop 來做
    const fileList = Array.from(files)
      .filter(file => {
        if (file.name.startsWith('.')) return false // 濾掉 .DS_Store 這種檔案
        if (file.type !== 'image/jpeg') return false // 濾掉非 jpeg 的檔案
        return true
      })
      .map(...)

    if (fileList.length !== 0) {
      try {
        // 解決方式一,也是大部分人都會使用的方式
        // 很簡單的上傳檔案,但是這樣的方式,會讓上傳的檔案變得很慢
        // 要等到前面結束,後面才會開始
        for (let i = 0; i < fileList.length; i++) {
          const file = fileList[i]
          const formData = new FormData()
          formData.append('file', file)
          await uploadFile(formData)
        }
    
        // 解決方式二,使用 Promise.all 來加速上傳的速度
        // 最大的原因是因為我們不需要等到上傳完一個檔案之後,才能上傳下一個檔案
        // 且上一次的上傳,不會影響且這一次的上傳,也就是順序不重要
        const BATCH = 20; // 我們每 20 個檔案一組,進行上傳
        const promises = [] as Promise<void>[]
        for (let i = 0; i < fileList.length; i++) {
          const file = fileList[i]
          const formData = new FormData()
          formData.append('file', file)
    
          // create promise then push into promises
          const promise = async () => {
            await uploadFile(formData)
          }
          promises.push(promise())
    
          if (promises.length === BATCH || i === fileList.length - 1) {
            await Promise.all(promises)
            promises = []
          }
        }
  
        // 解決方式三
        // 但是使用 Promise.all 來加速上傳的速度,也有一個缺點,就是假設裡面有一個檔案上傳失敗,
        // 整個流程就會變得很麻煩,而這時我們可以使用 Promise.allSettled 來解決這個問題,輸出時他會多一個 status 參數
        const BATCH = 20 // 我們每 20 個檔案一組,進行上傳
        const promises = [] as Promise<void>[]
        for (let i = 0; i < fileList.length; i++) {
          const file = fileList[i]
          const formData = new FormData()
          formData.append('file', file)
    
          // create promise then push into promises
          const promise = async () => {
            await uploadFile(formData)
          }
          promises.push(promise())
    
          if (promises.length === BATCH || i === fileList.length - 1) {
            const res = await Promise.allSettled(promises)
            // [{ status: 'fulfilled', value: undefined }, { status: 'rejected', reason: 'error' }]
            // 接著你就可以做更多後續的處理
            res.some(item => item.status === 'rejected') && throw new Error(`upload file error`)
            promises = []
          }
        }
      } catch (error) {
        console.log(error)
      } finally {
        //
      }
    } else {
      //
    }
  }
}

Disadvantage by using Promise.all and Promise.allSettled

上述我們使用了 Promise.allPromise.allSettled 來上傳多檔,也是作者我建議使用的方式,但是使用這兩個方式也是有缺點的,假使我們上傳檔案的大小是如下:

music1.mp4 (25MB)
music2.mp4 (25MB)
music3.mp4 (100GB) <-- upload slow
music4.mp4 (25MB) <-- 而這個檔案其實是有問題的,ex: error format music file
music5.mp4 (25MB)

缺點就是必須要等到 batch 達到我們指定的數量,才會回傳結果,也就是說要等待 100GB 的檔案上傳完成,僅管 25MB 的檔案上傳有問題且已經報錯了,這情況其實是比較不樂觀的,而這時其實就可以回歸原點使用 uploadFile.then().catch() 的方式來進行處理,儘管使用此方法比較複雜,但好處是假使你在 batch 區間執行時已經發現錯誤,並想要 cancel 掉後續 API 的執行,甚至可以搭配 AbortController 來進行處理,因為比較複雜,下方僅是示意的程式碼及圖片,後續就交給讀者自己去實作了。

async function handleFileChange() {
  let result = []
  const batch = 3

  for (let i = 0; i < 6; i++) {
    new Promise((resolve) => {
      setTimeout(() => {
        resolve({
          index: i,
          date: new Date()
        }, i * 1000)
      })
    }).then((res) => {
      result.push(res)
      if (result.length % batch === 0) {
        result = []
      }
    }).catch((error) => {
      //
    })
  }
}
image

Conclusion

在做公司內部的一些網站時,總會遇到這些奇奇怪怪的事情,像是有需求是要上傳超過 1000 個檔案之類的奇耙操作,總結一下,上傳檔案的重點其實就是,你對 Promise 這個物件,是否有更深一層的了解,希望對讀者能有所收穫。


Mayvis Chen
你好,我是 Mayvis Chen
住在台北,會點前端,會點後端,熱愛分享新知及愛喝奶茶的全端打雜工程師。