檔案上傳的方案
大檔案上傳
:將大檔案切分成較小的片段(通常稱為分片或塊),然後逐個上傳這些分片。這種方法可以提高上傳的穩定性,因為如果某個分片上傳失敗,只需要重新上傳該分片而不需要重新上傳整個檔案。同時,分片上傳還可以利用多個網路連線並行上傳多個分片,提高上傳速度。斷點續傳
:在上傳過程中,如果網路中斷或上傳被中止,斷點續傳技術可以記錄已成功上傳的分片資訊,以便在恢復上傳時繼續上傳未完成的部分,而不需要重新上傳整個檔案。這種技術可以大大減少上傳失敗的影響,並節省時間和頻寬。
安裝依賴:
express:啟動後端服務,並且提供介面
multer:讀取檔案,儲存
cors:解決跨域
npm i express npm i multer npm i cors
multer中介軟體
Multer 是一個node.js中介軟體,用於處理
multipart/form-data
型別的表單資料,主要用於上傳檔案。enctype = "multipart/form-data"
Multer 不會處理任何非
multipart/form-data
型別的表單資料。不要將 Multer 作為全域性中介軟體使用,因為惡意使用者可以上傳檔案到一個沒有預料到的路由,應該只在需要處理上傳檔案的路由上使用。
初始化multer
Multer是一個函式,接受一個 options 配置物件,其中最基本的是storage屬性,這將告訴 Multer 將上傳檔案儲存在哪。如果省略 options 物件,這些檔案將儲存在記憶體中,永遠不會寫入磁碟,options 配置如下:
屬性值 | 描述 |
---|---|
dest 或者 storage | 在哪裏儲存檔案 |
limits | 限制上傳資料的大小 |
fileFilter | 檔案過濾器,控制哪些檔案可以被接受 |
preservePath | 儲存包含檔名的完整檔案路徑 |
dest:指定上傳檔案的儲存路徑。檔名預設為隨機字元。如果想自定義檔名稱,使用storage自定義儲存引擎屬性,屬性值用multer.diskStorage磁碟儲存引擎來配置。
let upload = multer({dest:"attachment/"});
storage:自定義儲存引擎,可以是磁碟儲存引擎,也可以是記憶體儲存引擎。
multer.diskStorage()
是磁碟儲存引擎,磁碟儲存引擎可以控制檔案的儲存。它是一個函式,函式接受一個 options 配置物件,配置物件有兩個屬性,屬性值都是函式。destination用於指定檔案儲存的路徑;filename用於指定檔案的儲存名稱。
import multer from 'multer' // 上傳檔案處理 // 自定義磁碟儲存引擎 const storage = multer.diskStorage({ // 儲存檔案的目錄 destination(req, file, callback) { console.log(file,"destination");//列印結果如下圖 callback(null, 'uploads/'); // 第一個引數是error錯誤物件,不需要設定為null,第二個引數是每個上傳檔案儲存的目錄 }, // 儲存檔案的名稱 filename(req, file, callback) { console.log(req.body,"canshu"); // 接受前端傳的引數,multipart/form-data 型別 callback(null, `${req.body.index}-${req.body.filename}`); // 檔案的名稱 } }); // 初始化multer中介軟體,multer是一個函式,接受的引數是一個配置物件 const uploadContainer = multer({ storage // 自定義儲存 });
multer.memoryStorage()
是記憶體儲存引擎
記憶體儲存引擎將檔案儲存在記憶體中的 Buffer 物件,它沒有任何選項。
當使用記憶體儲存引擎,檔案資訊將包含一個 buffer 欄位,裡面包含了整個檔案資料。
當使用記憶體儲存,上傳非常大的檔案,或者非常多的小檔案,會導致應用程式記憶體溢位。
let storage = multer.memoryStorage() let upload = multer({storage})
limits:用來指定一些資料大小的限制,設定 limits 可以幫助保護站點抵禦拒絕服務 (DoS) 攻擊。是一個物件,包含如下屬性:
屬性 | 值型別 | 預設值 | 描述 |
---|---|---|---|
files | Number | 無限 | 上傳時,檔案的最大數量 |
fileSize | Number | 無限 | 上傳時,每一個檔案最大長度 (單位:bytes) |
fields | Number | 無限 | 上傳時,可以提交非檔案的欄位的數量 |
fieldNameSize | Number | 100 bytes | 上傳時,每一個欄位名字的最大長度 |
fieldSize | Number | 1048576 bytes,即1MB | 上傳時,每一個欄位名的屬性值的最大長度 |
parts | Number | 無限 | 上傳時,傳輸的最大數量(fields + files) |
headerPairs | Number | 2000 | 在上傳的表單型別資料中,鍵值對最大組數 |
const multer=require("multer"); let upload=multer({ limits:{ files:2, //最多上傳2個檔案 fileSize:5120 //設定單個檔案最大為 5kb } });
fileFilter屬性值是一個函式,用來控制什麼檔案可以上傳以及什麼檔案應該跳過
let storage = multer.memoryStorage() var upload = multer({ fileFilter(req, file, cb) { // 透過呼叫cb,用boolean值來指示是否應接受該檔案 // 拒絕這個檔案,使用false,像這樣: cb(null, false) // 接受這個檔案,使用true,像這樣: cb(null, true) // 如果有問題,可以總是這樣傳送一個錯誤: cb(new Error('I don\'t have a clue!')) }})
multer方法
multer(options).single(fieldname)
:上傳單個檔案,比如一次只上傳一張圖片。fieldname
是前端傳參的上傳檔案的欄位名稱。然後它會自動將檔案儲存到設定的路徑。
multer(options).array(fieldname[,maxCount])
:適用於同一個欄位,一次上傳多個檔案的情況,例如使用者選擇多張圖片傳送,接受一個以 fieldname 命名的檔案陣列,fieldname
是前端傳參的上傳檔案的欄位名稱。maxCount可選引數,可以指定限制上傳的最大數量。這些檔案的資訊儲存在 req.files
。
//一次最多上傳3個檔案 let upload=multer({dest:"attachment/"}).array("photo",3); // 前端傳參格式 let formdata = new FromData(); const fileList = [fileObj1,fileObj2,fileObj3]; formdata.append('photo',fileList)
上傳的資料格式如下:
multer(options).fields(fields)
:適用於上傳多個欄位的情況。接受指定 fields 的混合檔案。這些檔案的資訊儲存在 req.files
。fields 是一個物件陣列,具有 name 和可選的 maxCount 屬性。
let fieldsList=[ {name:"photo1"}, {name:"photo2",maxCount:2} ] let upload=multer({dest:"attachment/"}).fields(fieldsList); // 前端傳參格式 let formdata = new FromData(); const fileList = [fileObj2,fileObj3]; formdata.append('photo1',fileObj1) formdata.append('photo2',fileList)
上傳的資料格式如下:
multer(options).none()
:接收只有文字域的表單,如果上傳任何檔案,會返回 “LIMIT_UNEXPECTED_FILE” 錯誤。
let upload=multer({dest:"attachment/"}).none();
multer(options).any()
:接收一切上傳的檔案。
let upload=multer({dest:"attachment/"}).any();
錯誤處理機制
當遇到一個錯誤,multer 將會把錯誤傳送給 express。如果想捕捉 Multer 錯誤,可以使用 multer 物件下的 MulterError 類 (即 err instanceof multer.MulterError)。
var multer = require("multer") var upload = multer().single("photo") upload(req, res, function (err) { if (err instanceof multer.MulterError) { // 捕捉 Multer 錯誤 } else if (err) { // 捕捉 express 錯誤 } else { // 上傳成功 } })
前端
上傳部分的邏輯可以分為三部分:
對每個切片進行包裝:首先將資料放進 formdata 中,然後發起請求
使用
Promise.all
傳送請求發起 merge 請求讓後端進行切片的合併
前端程式碼:
<script setup> import { ref } from "vue"; import axios from "axios"; import { ElMessage } from "element-plus"; // 檔案列表 const fileListArray = ref([]); // 檔案上傳前限制檔案型別和大小 const beforeUpload = (file) => { // const mimeTypes = ['audio/mpeg', 'audio/x-m4a', 'audio/aac', 'video/mp4', 'video/x-m4v'] // if (!mimeTypes.includes(file.type)) { // ElMessage({ // type: 'error', // message: '只能上傳 MP3、M4A、AAC、MP4、M4V 格式的檔案', // duration: 6000 // }) // return false // } if (file.size / 1024 / 1024 / 1024 > 1.5) { ElMessage({ type: "error", message: "檔案大小不能超過 1.5G", duration: 6000, }); return false; } return true; }; // 當前切片上傳 AbortController let controller = null; // 上傳進度 const percentage = ref(0); const dialogVisible = ref(false); // 取消上傳 const cancelUpload = ref(false); // 檔案總大小 let fileTotal = 0; // 已上傳檔案切片的大小 let loadedSize = 0; // 檔案的名字 let fileCancelName = ''; // 分割檔案為多個切片 const sliceFile = (file, chunkSize = 1024 * 10) => { // file 接受檔案物件,chunkSize 檔案切片大小,1024*1024表示為1M,這裏預設是是10kb,實際可以根據專案情況進行設定 // 存放切片檔案的陣列 let chunks = []; // file.size 檔案物件取出檔案大小,單位是位元組byte,1024byte = 1kb for (let i = 0; i < file.size; i += chunkSize) { // 切割檔案:[0-10kb,10kbM-20kb,20kb-30kb,...] // i切片開始位置,i + chunkSize切片結束位置 chunks.push(file.slice(i, Math.min(i + chunkSize, file.size))); } return chunks; }; // 上傳單個檔案切片,對每個切片進行包裝並上傳:首先將資料放進 formdata 中,然後發起請求 const uploadChunk = (chunk, index, filename, mimeTypes) => { controller = new AbortController(); // 每一次上傳切片都要新生成一個 AbortController ,否則重新上傳會失敗 return new Promise((resolve, reject) => { // 將資料放進 formdata 中 const formData = new FormData(); // 每個切片標識欄位 formData.append("index", index); // 檔名欄位 formData.append("filename", filename); // 檔案MIME型別欄位 formData.append("type", mimeTypes); // 切片檔案,上傳的檔案必須寫在最後(因為後端multer模組讀取引數時,當讀取到檔案時就會停止讀取引數,其後麵的引數就不會再處理了) formData.append("file", chunk); if (cancelUpload.value) { // 若已經取消上傳,則不再上傳切片 console.log("cacel"); return; } // 發起請求 axios({ url: "http://localhost:3000/upload", method: "POST", data: formData, // `onUploadProgress` 允許為上傳處理進度事件 onUploadProgress: (progressEvent) => { // progressEvent.loaded 為已上傳檔案位元組數,progressEvent.total 為檔案總位元組數 // 處理原生進度事件,計算上傳進度,將每個已經上傳的切片檔案的位元組數相加再除以檔案總的大小 loadedSize += progressEvent.loaded; percentage.value = Number( ((Math.min(fileTotal, loadedSize) / fileTotal) * 100).toFixed(2) ); }, signal: controller.signal, // 取消上傳 }) .then(resolve) .catch(reject); }); }; // 使用Promise.all上傳所有切片 const uploadFileChunks = async (chunks, filename, mimeTypes) => { // chunks 儲存切割完成的檔案切片陣列 filename 上傳的檔案切片的檔名 try { const res = await Promise.all( chunks.map((chunk, index) => uploadChunk(chunk, index, filename, mimeTypes) ) ); // 合併檔案的名字 const fileName = res[0].data.data.filename; // 檔案型別 const type = res[0].data.data.type; console.log("All chunks uploaded successfully"); setTimeout(() => { // 延遲關閉上傳進度框使用者體驗會更好 dialogVisible.value = false; ElMessage({ message: "上傳成功", type: "success", }); axios.post("http://localhost:3000/merge", { filename: fileName, mimeTypes: type, }); // 呼叫後端合併切片介面,引數需要與後端對齊 }, 500); } catch (error) { console.error("Error uploading chunks", error); } }; // 自行實現上傳檔案的請求,使用Promise.all上傳所有切片 const upload = async (file) => { percentage.value = 0; // 每次上傳檔案前清空進度條 dialogVisible.value = true; // 顯示上傳進度 cancelUpload.value = false; // 每次上傳檔案前將取消上傳標識置為 false // 上傳的檔案 const uoloadfile = file.file; fileTotal = uoloadfile.size; // 獲取檔名 const filename = uoloadfile.name.split(".")[0]; fileCancelName = filename; // 獲取檔案的MIME型別 const mimeTypes = uoloadfile.name.split(".")[1]; // 將檔案進行切片 const chunks = sliceFile(uoloadfile); // 開始上傳 uploadFileChunks(chunks, filename, mimeTypes); }; // 取消上傳 const cancel = () => { dialogVisible.value = false; cancelUpload.value = true; controller?.abort(); axios.post("http://localhost:3000/cancelUpload", { filename: fileCancelName }); // 呼叫後端介面,刪除已上傳的切片 }; </script> <template> <div> <el-upload :before-upload="beforeUpload" :http-request="upload" :show-file-list="false" > <template #trigger> <el-button type="primary">選取檔案</el-button> </template> <template #tip> <div>不能低於1M</div> </template> </el-upload> <el-dialog v-model="dialogVisible" :fullscreen="true" :show-close="false" custom-class="dispute-upload-dialog" > <div> <div class="fz-18 ellipsis">正在上傳</div> <el-progress :text-inside="true" :stroke-width="16" :percentage="percentage" /> <el-button @click="cancel">取消上傳</el-button> </div> </el-dialog> </div> </template> <style> .dispute-upload-dialog { background: none; } </style> <style scoped> .center { color: #fff; width: 50%; text-align: center; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } </style>
上傳切片:
取消上傳:
上傳完成
後端(node.js)
後端主要提供兩個介面
upload:用來儲存切片,用multer中介軟體幫忙處理檔案的儲存
merge:用於合併切片,根據前端上傳的檔名合併檔案切片,並按照切片的索引從小到大進行合併。合併完的切片需要刪除切片並將合併的檔案儲存。
後端程式碼:
import fs from 'node:fs' // 檔案操作 import path from 'node:path' // 路勁操作 import express from 'express' // 提供介面服務 import multer from 'multer' // 上傳檔案處理 import cors from 'cors' // 解決跨域 // 自定義磁碟儲存引擎 const storage = multer.diskStorage({ // 儲存檔案的目錄 destination(req, file, callback) { callback(null, 'uploads/'); // 第一個引數是error錯誤物件,不需要設定為null,第二個引數是每個上傳檔案儲存的目錄 }, // 儲存檔案的名稱 filename(req, file, callback) { // req.body接受前端傳的引數,multipart/form-data 型別 callback(null, `${req.body.index}-${req.body.filename}`); // 檔案的名稱 } }); // 初始化multer中介軟體,multer是一個函式,接受的引數是一個配置物件 const uploadContainer = multer({ storage // 自定義儲存 }); const app = express(); // 註冊跨域中介軟體,支援跨域 app.use(cors()); app.use(express.json()); // 上傳切片介面,在上傳檔案的介面加上multer中介軟體 app.post('/upload', uploadContainer.single('file'), (req, res) => { // 前端傳入的欄位 const { filename, type } = req.body; res.send({ succes: true, msg: '切片上傳成功', data: { filename, type } }); }); // 合併切片介面 app.post('/merge', async (req, res) => { // 讀取存放切片的目錄:process.cwd()會返回當前檔案的工作目錄的絕對路徑;path.join()使用系統的分隔符將路徑片段進行拼接 const uploadDir = path.join(process.cwd(), 'uploads'); console.log(uploadDir, 'path join'); // 讀取存放切片目錄下的所有切片檔案,返回的是一個數組,但是陣列是亂序的 let files = fs.readdirSync(uploadDir); console.log(files, 'files Array'); // 給陣列排序,按照切片的索引進行升序排序 files = files.sort((a, b) => a.split('-')[0] - b.split('-')[0]); console.log(files, 'files Array by order'); // 合併切片後完整的檔案存放的路徑 const writePath = path.join(process.cwd(), `finallyFile`, `${req.body.filename}.${req.body.mimeTypes}`); console.log(writePath, 'writePath'); // 遍歷切片陣列,合併切片 files.forEach((item) => { // 合併切片 // readFileSync()讀取檔案,appendFile()以追加方式寫檔案 // writePath:寫入檔案的路徑 // data:要寫入檔案的資料此處是fs.readFileSync(path.join(uploadDir, item))讀取出來的每個切片 // path.join(uploadDir, item)):拼接的每個切片的路徑 fs.appendFileSync(writePath, fs.readFileSync(path.join(uploadDir, item))); // 刪除合併完的切片 fs.unlinkSync(path.join(uploadDir, item)); }); res.send({ succes: true, msg: '檔案上傳成功', }); }); // 刪除切片 app.post('/cancelUpload', (req, res) => { // 讀取存放切片的目錄:process.cwd()會返回當前檔案的工作目錄的絕對路徑;path.join()使用系統的分隔符將路徑片段進行拼接 const uploadDir = path.join(process.cwd(), 'uploads'); // 讀取存放切片目錄下的所有切片檔案,返回的是一個數組 let files = fs.readdirSync(uploadDir); // 需要刪除的切片陣列 const unlinkArr = files.filter(item => { // 前端傳入的檔名 if (item.indexOf(req.body.filename) != -1) { return true; } }); unlinkArr.forEach(item => { // 刪除需要刪除的切片 fs.unlinkSync(path.join(uploadDir, item)); }); res.send({ succes: true, msg: '檔案取消上傳成功', }); }); app.listen(3000, () => { console.log('3000 port is running'); });