一文徹底搞透文件上傳

創建項目

基於 Vite + Vue3 創建最簡前端項目 —— file-upload,並在項目根目錄下基於 express 創建 Node Server,如何已熟悉,可以直接進入下一節。

前端項目

通過 Vite 來創建項目,然後選擇自己熟悉的技術棧,比如 Vue、React,只需要創建一個最簡單的項目即可,比如:

npm create vite@latest file-upload

執行 npm run dev 啓動前端項目

清空 /src/App.vue 文件,並輸入如下內容

<script setup>
</script>

<template>
  <div>
    我是 App.vue
  </div>
</template>

<style scoped>
</style>

效果:

Node Server

在項目根目錄下創建 ./server/index.js 文件,作爲服務端項目,並安裝 express 作爲框架

npm i express

代碼如下:

import express from 'express'

const app = express()

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(3000, () => {
  console.log('Server listening on port 3000')
})

啓動 node server

nodemon server/index.js

瀏覽器訪問 http://localhost:3000

好了,到這裏,基本準備工作就完成了,接下來就進入本文重點 —— 文件上傳。

最簡單的文件上傳

首先安裝以下三個 npm 包

npm i axios cors multer

前端 —— /src/App.vue:

<script setup>
import { ref } from 'vue'
import axios from 'axios'

const inputRef = ref(null)

async function handleUpload() {
  // 獲取文件對象
  const file = inputRef.value.files[0]

  const formData = new FormData()
  formData.append('file', file)

  const { data } = await axios.request({
    url: 'http://localhost:3000/uplaod',
    method: 'POST',
    data: formData,
    // 上傳進度,這個是通過 XMLHttpRequest 實現的能力
    onUploadProgress: function (progressEvent) {
      // 當前已上傳完的大小 / 總大小
      const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total)
      console.log('Upload Progress: ', `${percentage}%`)
    }
  })
  console.log('data = ', data)
}
</script>

<template>
  <div>
    <input type="file" ref="inputRef" />
    <button @click="handleUpload">Upload</button>
  </div>
</template>

邏輯很簡單

後端 —— /server/index.js:

import express from'express'
import cors from'cors'
import multer from'multer'
import { dirname, resolve } from'path'
import { fileURLToPath } from'url'
import { existsSync, mkdirSync } from'fs'

// 解決 ESM 無法使用 __dirname 變量的問題
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const app = express()

// 解決跨域問題
app.use(cors())

/**
 * Multer 是一個 node.js 中間件,用於處理 multipart/form-data 類型的表單數據,它主要用於上傳文件
 * 注意: Multer 不會處理任何非 multipart/form-data 類型的表單數據。
 */
const uplaod = multer({
// 存儲,上傳的文件落盤
storage: multer.diskStorage({
    // 將文件放到 /server/uploads 目錄下
    destination: (_, __, cb) ={
      const uploadsDir = resolve(__dirname, 'uploads')
      if (!existsSync(uploadsDir)) {
        mkdirSync(uploadsDir)
      }

      cb(null, uploadsDir)
    },
    // 文件名使用原始文件名
    filename: (_, file, cb) ={
      cb(null, file.originalname)
    }
  })
})

app.get('/', (_, res) => {
  res.send('Hello World!')
})

app.post('/uplaod', uplaod.single('file'), (_, res) => {
  res.send('File uploaded successfully')
})

app.listen(3000, () => {
console.log('Server listening on port 3000')
})

關鍵地方都寫了註釋,核心邏輯主要在 multer 配置項中。multer 是一個 node.js 中間件,用於處理 multipart/form-data 類型的表單數據,其主要用來處理文件上傳。

效果展示:

數據提交格式科普

提交 POST 請求時,我們一般會使用三種類型的 Content-Type:multipart/form-data、x-www-urlencoded、application/json,這三種類型有什麼區別呢?

multipart/form-data

multipart/form-data 是一種 MIME 類型,用於在 HTTP 請求中傳輸表單數據,這種類型的請求通常用於文件上傳,因爲它允許將文件作爲請求的一部分進行傳輸。當然,multipart/form-data 也可以用於傳輸其他類型的數據,例如文本字段、單選按鈕、複選框等。

它表示的數據是以多部分的形式進行編碼的,每個部分都有自己的 Content-Type 和 Content-Disposition 頭,用於描述數據的類型和文件信息,比如,以當前的上傳請求爲例:

其中的 ----WebKitFormBoundary 來自於 Request Header 中的 Content-Type,用來分割每一部分的數據,就是一個分隔符,這在服務端解析數據的時候會用到。

x-www-urlencoded

x-www-urlencoded 是另一種 MIME 類型,也用於在 HTTP 請求中傳輸表單數據。

和 multipart/form-data 區別在於,它通常用戶傳輸簡單的文本數據,如表單字段中的文本輸入框、單選按鈕、複選框等的值,它不適合傳輸文件或二進制數據。

當 Content-Type 爲 x-www-form-urlencoded 格式時,表單數據會被編碼爲 URL 編碼(類似於在 URL 中的查詢字符串使用的編碼),並放在請求體中,服務器接收到請求後,會解析請求體,提取其中的表單字段和值。

示例:

const formData = new URLSearchParams();
formData.append('username''your_username');
formData.append('password''your_password');

axios.post('/your_api_url', formData, {
headers: {
    'Content-Type''application/x-www-form-urlencoded',
  },
})
.then(response ={
// 處理響應
})
.catch(error ={
// 處理錯誤
});
import express from'express'
import bodyParser from'body-parser'

const app = express();

// 使用 body-parser 中間件來解析 x-www-form-urlencoded 類型的數據
app.use(bodyParser.urlencoded({ extended: true }));

app.post('/your-route', (req, res) => {
// 現在你可以訪問 req.body 來獲取解析後的數據
console.log(req.body);
  res.send('Data received');
});

app.listen(3000, () => {
console.log('Server is running on port 3000');
});

application/json

application/json 用於傳輸 JSON 格式的數據,JSON 是一種輕量級的數據交換格式,通常用於 Web API,比如:

import axios from 'axios'

axios.request({
  url: 'your-route',
  method: 'POST',
  data: {
    key1: 'value1',
    key2: 'value2'
  }
})

所以,總的來說,multipart/form-data用於複雜表單數據,包括文件上傳;x-www-urlencoded用於簡單表單數據;而application/json用於 Web API 的數據傳輸,特別是需要傳輸結構化數據的時候。

分片(大文件上傳)

爲什麼要支持分片上傳??

說了理論,接下來就進入實戰階段了。大致思路是:

分片上傳

代碼量比較少,就直接全部列出來了,都是基於之前的代碼進行迭代改造的

前端 —— App.vue

邏輯還是以點擊上傳按鈕爲開始(handleUpload)

<script setup>
import { ref } from 'vue'
import axios from 'axios'

const inputRef = ref(null)

// 分片大小,單位字節
// const chunkSize = 1024
const chunkSize = 4

/**
 * 文件切片
 * @param { File } file 文件對象
 * @param { number } start 開始位置
 * @param { number } end 結束位置
 * @param { Array } allFileChunks 所有文件切片
 * @returns void
 */
function splitChunkForFile(file, start, end, allFileChunks) {
  const { name, size, type, lastModified } = file

  // 說明文件已經切割完畢
  if (start > size) {
    return
  }

  const chunk = file.slice(start, Math.min(end, size))
  const newFileChunk = new File([chunk], name + allFileChunks.length, {
    type,
    lastModified,
  })

  allFileChunks.push(newFileChunk)
  splitChunkForFile(file, end, end + chunkSize, allFileChunks)
}

// 上傳文件
async function handleUpload() {
  // 獲取文件對象
  const file = inputRef.value.files[0]

  // 存放所有切片
  const allFileChunks = []
  // 文件切片
  splitChunkForFile(file, 0, chunkSize, allFileChunks)

  // 遍歷所有切片並上傳
  for (let i = 0; i < allFileChunks.length; i++) {
    const fileChunk = allFileChunks[i]

    const formData = new FormData()
    formData.append('file', fileChunk)
    // 標識當前 chunk 屬於哪個文件,方便服務端做內容分類和合並,實際場景中這塊兒需要考慮唯一性
    formData.append('uuid', file.name)
    // 標識當前 chunk 是文件的第幾個 chunk,即保證 chunk 順序
    formData.append('index', i)
    // 標識總共有多少 chunk,方便服務端判斷是否已經接收完所有 chunk
    formData.append('total', allFileChunks.length)

    axios.request({
      url: 'http://localhost:3000/uplaod',
      method: 'POST',
      data: formData,
      // 上傳進度,這個是通過 XMLHttpRequest 實現的能力
      onUploadProgress: function (progressEvent) {
        // 當前已上傳完的大小 / 總大小
        const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total)
        console.log('Upload Progress: ', `${percentage}%`)
      }
    }).then(res => {
      console.log('result = ', res.data)
    })
  }
}
</script>

<template>
  <div>
    <input type="file" ref="inputRef" />
    <button @click="handleUpload">Upload</button>
  </div>
</template>

服務端 —— index.js

相比於前面的代碼,所有的改動都在 /upload API 中,主要是結合前端給到的相關字段做切片的維護和合並,整體邏輯爲:

import express from'express'
import cors from'cors'
import multer from'multer'
import { dirname, resolve } from'path'
import { fileURLToPath } from'url'
import { existsSync, mkdirSync, unlinkSync, readFileSync, appendFileSync } from'fs'

// 解決 ESM 無法使用 __dirname 變量的問題
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const app = express()

// 解決跨域問題
app.use(cors({
maxAge: 86400
}))

/**
 * Multer 是一個 node.js 中間件,用於處理 multipart/form-data 類型的表單數據,它主要用於上傳文件
 * 注意: Multer 不會處理任何非 multipart/form-data 類型的表單數據。
 */
const uplaod = multer({
// 存儲,上傳的文件落盤
storage: multer.diskStorage({
    // 將文件放到 /server/uploads 目錄下
    destination: (_, __, cb) ={
      const uploadsDir = resolve(__dirname, 'uploads')
      if (!existsSync(uploadsDir)) {
        mkdirSync(uploadsDir)
      }

      cb(null, uploadsDir)
    },
    // 文件名使用原始文件名
    filename: (_, file, cb) ={
      cb(null, file.originalname)
    }
  })
})

app.get('/', (_, res) => {
  res.send('Hello World!')
})

// 以 uuid 爲 key,文件所有的 chunks 組成的數組爲 value
const allFiles = {}

app.post('/uplaod', uplaod.single('file'), (req, res) => {
const { uuid, index, total } = req.body
// chunk 的存儲路徑
const { path } = req.file

if (!allFiles[uuid]) allFiles[uuid] = []

  allFiles[uuid][index] = path

if (allFiles[uuid].filter(item => item).length === +total) {
    // 說明已經接收完了所有的 chunk
    const destFilePath = resolve(__dirname, 'uploads', uuid)

    // 合併臨時文件並將其刪除
    allFiles[uuid].forEach(async filePath => {
      const content = readFileSync(filePath)
      appendFileSync(destFilePath, content)
      unlinkSync(filePath)
    })
    allFiles[uuid] = []

    res.send(`file —— ${uuid} uploaded successfully`)
    return
  }
  res.send(`chunk —— ${uuid + index} uploaded successfully`)
})

app.listen(3000, () => {
console.log('Server listening on port 3000')
})

效果展示

如果文件足夠大(上傳需要一定的時間)或者在服務端的文件合併邏輯處打個斷點,就可以看到 /server/uploads 目錄下有很多很臨時的 chunk 文件。

切片優化 + 併發控制

爲什麼要做切片優化?現在的切片邏輯有什麼問題嗎?

換一個大點的文件,點擊上傳,出現如下報錯,很明顯棧溢出了。回顧邏輯,可以知道 splitChunkForFile 方法是一個遞歸函數,這就意味着,如果文件比較大,切片比較多,遞歸層級就會很深,自然就出現了棧溢出的問題。

另外,即使拋開溢出問題不談,當前切片邏輯是同步執行的,當切片數量很大時,主線會被長時間佔用,甚至瀏覽器被卡死。這點可以將切片邏輯換成循環執行來驗證(遞歸無法驗證,因爲會先出現棧溢出的情況):

/**
 * 文件切片
 * @param { File } file 文件對象
 * @returns Array<File> 文件的所有切片組成的 File 對象
 */
function splitChunkForFile(file) {
const { name, size, type, lastModified } = file

// 存放所有切片
const allFileChunks = []

// 每一片的開始位置
let start = 0

// 循環切每一片,直到整個文件切完
while (start <= size) {
    const chunk = file.slice(start, Math.min(start + chunkSize, size))
    const newFileChunk = new File([chunk], name + allFileChunks.length, {
      type,
      lastModified,
    })

    start += chunkSize

    allFileChunks.push(newFileChunk)
  }

return allFileChunks
}

// 文件切片
const allFileChunks = splitChunkForFile(file)

效果如下:

爲什麼需要併發控制?現在沒有併發控制有什麼問題嗎?

現在的邏輯是:先獲取文件的所有切片,然後遍歷切片數組,將所有切片一股腦全部發送給服務端。

優化思路

實戰

/src/App.vue

點擊 Upload 按鈕之後,邏輯如下:

<script setup>
import { ref } from 'vue'
import axios from 'axios'
import WebWorker from './web-worker.js?worker'

const inputRef = ref(null)

// 分片大小,單位 MB
const chunkSize = 1024 * 1024
// 分片大小,單位字節
// const chunkSize = 4

// 存放所有的文件切片
let allFileChunks = []

// WebWorker 實例
let worker = null

// 記錄當前文件已經切了多少片了
let hasBeenSplitNum = 0

// 當前上傳的 chunk 在 allFileChunks 數組中的索引
let allFileChunksIdx = 0

// 當前併發數
let curConcurrencyNum = 0
// 最大併發數
const MAX_CONCURRENCY_NUM = 6

// 上傳文件
async function handleUpload() {
  // 上傳開始前的數據初始化
  allFileChunksIdx = 0
  allFileChunks = []
  hasBeenSplitNum = 0
  curConcurrencyNum = 0

  // 獲取文件對象
  const file = inputRef.value.files[0]
  // 實例化 WebWorker,用來做文件切片
  worker = new WebWorker()
  // 將文件切片工作交給 web worker 來完成
  worker.postMessage({ operation: 'splitChunkForFile', file, chunkSize })

  // 總 chunk 數
  const total =  Math.ceil(file.size / chunkSize)

  // 接收 worker 發回的切片(持續發送,worker 每完成一個切片就發一個)
  worker.onmessage = function (e) {
    const { data } = e
    const { operation } = data

    if (operation === 'splitChunkForFile') {
      hasBeenSplitNum += 1
      pushFileChunk(data.file)

      // 說明整個文件已經切完了,釋放 worker 實例
      if (hasBeenSplitNum === total) {
        this.terminate()
      }
    }
  }
}

/**
 * 將 worker 完成切片存放到 allFileChunks 中,並觸發上傳邏輯
 * @param { File } file 文件切片的 File 對象
 */
function pushFileChunk(file) {
  allFileChunks.push(file)
  uploadChunk()
}

/**
 * 併發上傳文件切片,併發數 6(同一域名瀏覽器最多有 6個併發)
 */
function uploadChunk() {
  if (curConcurrencyNum >= MAX_CONCURRENCY_NUM || allFileChunksIdx >= allFileChunks.length) return

  // 獲取文件對象
  const file = inputRef.value.files[0]
  const { name, size } = file
  // 總 chunk 數
  const total =  Math.ceil(size / chunkSize)

  // 併發數 + 1
  curConcurrencyNum += 1

  // 從 allFileChunks 中獲取指定索引的 fileChunk,這個索引的存在還是爲了保證按順序取和上傳 chunk
  const fileChunk = allFileChunks[allFileChunksIdx]

  const formData = new FormData()
  formData.append('file', fileChunk)
  // 標識當前 chunk 屬於哪個文件,方便服務端做內容分類和合並,實際場景中這塊兒需要考慮唯一性
  formData.append('uuid', name)
  // 標識當前 chunk 是文件的第幾個 chunk,即保證 chunk 順序
  formData.append('index', allFileChunksIdx)
  // 標識總共有多少 chunk,方便服務端判斷是否已經接收完所有 chunk
  formData.append('total', total)

  axios.request({
    url: 'http://localhost:3000/uplaod',
    method: 'POST',
    data: formData,
    // 上傳進度,這個是通過 XMLHttpRequest 實現的能力
    onUploadProgress: function (progressEvent) {
      // 當前已上傳完的大小 / 總大小
      const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total)
      console.log('Upload Progress: ', `${percentage}%`)
    }
  }).then(res => {
    console.log('result = ', res.data)
  }).finally(() => {
    /**
     * 當前請求完成,
     */
    // 併發數 - 1
    curConcurrencyNum -= 1
    // 上傳下一個切片
    uploadChunk()
  })

  // 更新 chunk 索引,方便取下一個 chunk
  allFileChunksIdx += 1

  uploadChunk()
}
</script>

<template>
  <div>
    <input type="file" ref="inputRef" />
    <button @click="handleUpload">Upload</button>
  </div>
</template>

/src/web-worker.js

Worker 是獨立於主線程運行的線程,有自己獨立的執行上下文和事件循環。當然了,如果系統資源有限,還是回和其它線程(比如主線程)產生競爭。

它在這裏負責完成上面的同步切片操作,每切完一個,就回傳給主線程,由主線程完成上傳。所以,整個文件上傳是由主線程和 Worker 線程異步協同完成的。

/**
 * 文件切片
 *    通過循環將整個文件切片,在切的過程中每切一片,就當切好的分片發給主線程,當真個文件切完之後告訴主線程已完成
 * @param { File } file 文件對象
 * @param { number } chunkSize 單個切片的大小,單位字節
 */
function splitChunkForFile(file, chunkSize) {
const { name, size, type, lastModified } = file

// 每一片的開始位置
let start = 0
// chunk 索引,用來將序號添加到文件名上
let chunkIdx = 0

// 循環切每一片,直到整個文件切完
while (start < size) {
    const chunk = file.slice(start, Math.min(start + chunkSize, size))
    const newFileChunk = new File([chunk], name + chunkIdx++, {
      type,
      lastModified,
    })

    start += chunkSize

    // 將當前切片發給主線程
    this.postMessage({ operation: 'splitChunkForFile', file: newFileChunk })
  }
}

// 接收主線程的消息
onmessage = function (e) {
const { data } = e
const { operation } = data

if (operation === 'splitChunkForFile') {
    // 表示給對文件切片操作
    splitChunkForFile.apply(this, [data.file, data.chunkSize])
  }

// 還可以擴展其它操作
}

效果展示

可以發現切片不在卡主線程、請求也更早的開始了(不需要等到全部切片完成)、併發也只有 6 個。

斷點續傳

斷點續傳是在分片上傳的基礎上來實現的,當上傳過程中出現故障時,能夠從上次中斷的地方繼續上傳,而不是從頭開始。這點對於大文件的上傳尤爲重要,因爲上傳時間較長,出現中斷的概率較高。

實戰

斷點續傳的實現,和分片一樣,也需要前後端一起配合,具體調整如下。

/src/App.vue

這就是前端側改造的核心邏輯。

/**
 * 獲取文件已上傳的切片
 * @param { string } fileName 文件名
 */
async function getFileChunksByFileName(fileName) {
  const { data } = await axios.request({
    url: 'http://localhost:3000/get-file-chunks-by-uuid',
    method: 'GET',
    params: {
      uuid: fileName
    }
  })
  return data
}

/server/index.js

增加一個獲取指定文件已上傳切片列表的接口,供前端調用。

// 獲取指定文件的切片列表,即獲取該文件已上傳的切片,用於斷點續傳
app.get('/get-file-chunks-by-uuid', (req, res) => {
  // 這裏的 uuid 就是文件名
  const { uuid } = req.query || {}

  res.send(allFiles[uuid] || [])
})

效果

當前文件我先提前上傳了一部分,就是看到的前 6 個切片,然後刷新頁面打斷執行。接着再次上傳該文件,前 6 個切片瞬間完成(不需要重新上傳)。

切片大小

可以看到,文中的切片大小有兩種,一是 4B,二是 1MB。一直沒有提及切片的大小的設計,但這裏還是要簡單說明一下。

大家都知道 HTTP1.1 對於帶寬的利用率是很糟糕的,這是 HTTP1.1 的一個核心問題,其中一個原因就是 TCP 的慢啓動。一旦一個 TCP 連接建立之後,就進入了發送數據的狀態,剛開始 TCP 協議回採用一個非常慢的速度去發送數據,然後慢慢加快發送數據的速度,知道發送數據的速度達到一個理想狀態,這個過程就叫慢啓動。很像一輛車啓動加速的過程。

但慢啓動是 TCP 爲了減少網絡擁塞的一種策略,這個沒辦法改變。所以,這裏提到切片大小的設計,意思是切片大小不可設置的過小,但設置過大也不可取。關於切片大小其實沒有一個固定的標準,通常需要根據實際大小和網絡環境進行調整,以下是一些常見的策略:

總的來說,對於較小的文件,切片可以較大,以減少分片數量和控制開銷;對於大文件,可以適當減小切片大小,以確保每個分片都在合理的時間內上傳完成,並支持更細粒度的斷點續傳。

總結

本文的目標是:一文搞透文件上傳。當你讀到這裏,希望不負初衷。現在回顧一下文章的整體內容

新視頻和文章會第一時間在微信公衆號發送,歡迎關注:李永寧 lyn

文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。

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