Vue+Node實現(xiàn)大文件上傳和斷點續(xù)傳
源代碼
斷點續(xù)傳、分片上傳、秒傳、重試機制
文件上傳是開發(fā)中的難點, 大文件上傳及斷點續(xù)傳 難點中的細節(jié)及核心技術點。
element-ui 框架的上傳組件,是默認基于文件流的。
- 數(shù)據(jù)格式:form-data;
- 傳遞的數(shù)據(jù): file 文件流信息;filename 文件名字
通過 fileRead.readAsDataURL(file) 轉(zhuǎn)為 base64 字符串后, 用 encodeURIComponent 編譯再發(fā)送,發(fā)送的數(shù)據(jù)經(jīng)由 qs.stringify 處理, 請求頭添加 "Content-Type": "application/x-www-form-urlencoded"
es6文件對象、ajax 上傳, async await promise 、后臺文件存儲、 流操作等全面的全棧技能的同時, 提升難度到大文件和斷點續(xù)傳。
移動時代圖片成為社交的主流,短視屏時代鐵定是大文件。
大文件 上傳 8M size 1M 8份
- 前端上傳大文件時使用 Blob.prototype.slice 將文件切片,并發(fā)上傳多個切片,最后發(fā)送一個合并的請求通知服務端合并切片
- 服務端接收切片并存儲,收到合并請求后使用流將切片合并到最終文件
- 原生 XMLHttpRequest 的 upload.onprogress 對切片上傳進度的監(jiān)聽
- 使用 Vue 計算屬性根據(jù)每個切片的進度算出整個文件的上傳進度
- 使用 spark-md5 根據(jù)文件內(nèi)容算出文件 hash
- 通過 hash 可以判斷服務端是否已經(jīng)上傳該文件,從而直接提示用戶上傳成功(秒傳)
- 通過 XMLHttpRequest 的 abort 方法暫停切片的上傳
- 上傳前服務端返回已經(jīng)上傳的切片名,前端跳過這些切片的上傳
Blob.slice
Blob.slice() 方法用于創(chuàng)建一個包含源 Blob的指定字節(jié)范圍內(nèi)的數(shù)據(jù)的新 Blob 對象。
返回值
一個新的 Blob 對象,它包含了原始 Blob 對象的某一個段的數(shù)據(jù)。
切片
js 在es6 文件對象file node file stream 有所增強。
任何文件都是二進制, 分割blob
start, size, offset
http請求可并發(fā) n個切片并發(fā)上傳 速度更快, 改善了體驗。
前端的切片,讓http并發(fā)帶來上傳大文件的快感。
- file.slice 完成切片, blob 類型文件切片, js 二進制文件類型的 blob協(xié)議
- 在文件上傳到服務器之前就可以提前預覽。
服務器端
- 如何將這些切片, 合交成一個, 并且能顯示原來的圖片
- stream 流
- 可讀流, 可寫流
- chunk 都是一個二進制流文件,
- Promise.all 來包裝每個chunk 的寫入
- start end fse.createWriteStream
- 每個chunk寫入 先創(chuàng)建可讀流,再pipe給可寫流的過程
思路: 以原文件做為文件夾的名字,在上傳blobs到這個文件夾, 前且每個blob 都以文件-index的命名方式來存儲
- http并發(fā)上傳大文件切片
- vue 實現(xiàn)上傳文件的細節(jié)
無論是前端還是后端, 傳輸文件, 特別是大文件,有可能發(fā)生丟失文件的情況,網(wǎng)速, 服務器超時,
如何避免丟失呢?
- hash,文件名 并不是唯一的, 不同名的圖片 內(nèi)容是一樣, 針對文件內(nèi)容進行hash 計算
- hash 前端算一個, 單向
- 后端拿到內(nèi)容算hash
- 一樣,
- 不一樣 重傳
- html5特性你怎么理解, localStorage ...
Web Workers 優(yōu)化我們的前端性能, 將要花大量時間的, 復雜的,放到一個新的線程中去計算
文件上傳通過hash 計算, 文件沒有問題
es6 哪些特性, 你怎么用的
函數(shù)參數(shù)賦默認值
- 給用戶快速感知, 用戶體驗是核心
- 并發(fā)http 前后端體驗,
- 斷點續(xù)傳
? 上傳 hash abort 恢復
初始化文件內(nèi)容
yarn init -y yarn add -g live-server // web http方式 lastModified: 1644549553742 lastModifiedDate: Fri Feb 11 20xx 11:19:13 GMT+0800 (中國標準時間) {} name: "banner.png" size: 138424 type: "image/png" webkitRelativePath: ""j
yarn add multiparty // 表單文件上傳 $ vue --version @vue/cli 4.5.13 vue create vue-upload-big-file $ vue create vue-upload-big-file ? Please pick a preset: (Use arrow keys) ? Please pick a preset: Manually select features ? Check the features needed for your project: (Press <space> to select, <a> to t ? Check the features needed for your project: Choose Vue version, Babel ? Choose a version of Vue.js that you want to start the project with (Use arrow ? Choose a version of Vue.js that you want to start the project with 2.x ? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys) > In dedicated config files ? Where do you prefer placing config for Babel, ESLint, etc.? In package.json ? Save this as a preset for future projects? (y/N) n yarn add element-ui
在生成文件切片時,需要給每個切片一個標識作為hash,這里暫時使用 文件名+下標,這樣后端可以知道當前切片是第幾個切片,用于之后的合并切片
隨后調(diào)用uploadChunks上傳所有的文件切片,將文件切片,切片hash,以及文件名放入 formData中,再調(diào)用上一步的request函數(shù)返回一個promise,最后調(diào)用Promise.all并發(fā)上傳所有的切片
hash,文件名,并不是唯一的.
不同名的圖片,內(nèi)容是一樣。針對文件內(nèi)容進行hash計算
hash 前端算一個,單向. 內(nèi)容做hash計算
后端拿到內(nèi)容算hash一樣。不一樣就要重傳。
web workers 優(yōu)化我們的前端性能,將要花大量時間的,復雜的,放到一個新的線程中去計算, 文件上傳通過hash去計算,文件沒有問題。
yarn add fs-extra
FormData.append()
發(fā)送數(shù)據(jù)用到了 FormData
formData.append(name, value, filename),其中 filename 為可選參數(shù),是傳給服務器的文件名稱, 當一個 Blob 或 File 被作為第二個參數(shù)的時候, Blob 對象的默認文件名是 "blob"。
大文件上傳
- 將大文件轉(zhuǎn)換為二進制流的格式
- 利用流可以切割的屬性,將二進制流切割成多份
- 組裝和分割塊同等數(shù)量的請求塊,并行或串行的形式發(fā)出請求
- 再給服務器端發(fā)出一個合并的信息
斷點續(xù)傳
- 為每個文件切割塊添加不同的標識, hash
- 當上傳成功后,記錄上傳成功的標識
- 當我們暫?;蛘甙l(fā)送失敗后,可以重新發(fā)送沒有上傳成功的切割文件
代碼
<input v-if="!changeDisabled" type="file" :multiple="multiple" class="select-file-input" :accept="accept" @change="handleFileChange" />
創(chuàng)建切片
createFileChunk(file, size = chunkSize) { const fileChunkList = []; var count = 0; while (count < file.size) { fileChunkList.push({ file: file.slice(count, count + size) }); count += size; } return fileChunkList; }
并發(fā)及重試
// 為控制請求并發(fā)的Demo const sendRequest = (urls, max, callback) => { let finished = 0; const total = urls.length; const handler = () => { if (urls.length) { const url = urls.shift(); fetch(url) .then(() => { finished++; handler(); }) .catch((err) => { throw Error(err); }); } if (finished >= total) { callback(); } }; // for控制初始并發(fā) for (let i = 0; i < max; i++) { handler(); } }; const urls = Array.from({ length: 10 }, (v, k) => k); const fetch = function (idx) { return new Promise((resolve) => { const timeout = parseInt(Math.random() * 1e4); console.log('----請求開始'); setTimeout(() => { console.log('----請求結(jié)束'); resolve(idx); }, timeout); }); }; const max = 4; const callback = () => { console.log('所有請求執(zhí)行完畢'); }; sendRequest(urls, max, callback);
worker處理,性能及速度都會有很大提升.
// 生成文件 hash(web-worker) calculateHash(fileChunkList) { return new Promise(resolve => { this.container.worker = new Worker('./hash.js'); this.container.worker.postMessage({ fileChunkList }); this.container.worker.onmessage = e => { const { percentage, hash } = e.data; if (this.tempFilesArr[fileIndex]) { this.tempFilesArr[fileIndex].hashProgress = Number( percentage.toFixed(0) ); } if (hash) { resolve(hash); } }; }); }
文件的合并
mergeRequest(data) { const obj = { md5: data.fileHash, fileName: data.name, fileChunkNum: data.chunkList.length }; instance.post('fileChunk/merge', obj, { timeout: 0 }) .then((res) => { this.$message.success('上傳成功'); }); }
源碼
methods: { handleFileChange(e) { const [file] = e.target.files; if (!file) return; Object.assign(this.$data, this.$options.data()); this.container.file = file; }, async handleUpload() {} }
XMLHttpRequest封裝:
request({ url, method = "post", data, headers = {}, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr.open(method, url); Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e => { resolve({ data: e.target.response }); }; }); }
上傳切片
- 對文件進行切片
- 將切片傳輸給服務端
const SIZE = 10 * 1024 * 1024; // 切片大小 data: () => ({ container: { file: null }, data: [] }), handleFileChange() {}, // 生成文件切片 createFileChunk(file, size = SIZE) { const fileChunkList = []; let cur = 0; while(cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur + size) }); cur += size; } return fileChunkList; }, // 上傳切片 async uploadChunks() { const requestList = this.data .map(({ chunk, hash }) => { const formData = new FormData(); formData.append("chunk", chunk); formData.append("hash", hash); formData.append("filename", this.container.file.name); return { formData }; }) .map(async ({ formData }) => this.request({ url: "http://localhost: 3000", data: formData }) ); await Promise.all(requestList); // 并發(fā)切片 }, async handleUpload() { if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.data = fileChunkList.map(({file}, index) => ({ chunk: file, hash: this.container.file.name + '-' + index // 文件名 + 數(shù)組下標 })); await this.uploadChunks(); }
發(fā)送合并請求
await Promise.all(requestList); async mergeRequest() { await this.reques({ url: "http://localhost:3000/merge", headers: { "content-type": "application/json"" }, data: JSON.stringify({ filename: this.container.file.name }) }); }, async handleUpload() {}
http模塊搭建服務器:
const http = require("http"); const server = http.createServer(); server.on("request", async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Headers", "*"); if (req.method === "OPTIONS") { res.status = 200; res.end(); return; } }); server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
使用 multiparty 包處理前端傳來的 FormData
在 multiparty.parse 的回調(diào)中, files 參數(shù)保存了 FormData 中文件, fields 參數(shù)保存了 FormData 中非文件的字段
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲目錄 const multipart = new multiparty.Form(); multipart.parse(req. async(err, fields, files) => { if (err) { return; } const [chunk] = files.chunk; const [hash] = fields.hash; const [filename] = fields.filename; const chunkDir = path.resolve(UPLOAD_DIR, filename); // 切片目錄不存在,創(chuàng)建切片目錄 if (!fse.existsSync(chunkDir)) { await fse.mkdirs(chunkDir); } // fs-extra 專用方法,類似 fs.rename 并且跨平臺 // fs-extra 的 rename 方法 windows 平臺會有權(quán)限問題 await fse.move(chunk.path, `${chunkDir}/${hash}`); res.end("received file chunk"); });
合并切片
// 在接收到前端發(fā)送的合并請求后,服務端將文件夾下的所有切片進行合并 const resolvePost = req => new Promise(resolve => { let chunk = ""; req.on("data", data => { chunk += data; }); req.on("end", () => { resolve(JSON.parse(chunk)); }); }); const pipeStream = (path, writeStream) => new Promise(resolve => { const readStream = fse.createReadStream(path); readStream.on("end", () => { fse.unlinkSync(path); resolve(); }); readStream.pipe(writeStream); }); // 合并切片 const mergeFileChunk = async (filePath, filename, size) => { const chunkDir = path.resolve(UPLOAD_DIR, filename); const chunkPaths = await fse.readdir(chunkDir); // 根據(jù)切片下標進行排序 // 否則直接讀取目錄的獲取的順序可能會錯亂 chunkPaths.sort((a,b)=>a.split("-")[1] - b.split("-")[1]); await Promise.all( chunkPaths.map((chunkPath, index) => pipeStream( path.resolve(chunkDir, chunkPath), // 指定位置創(chuàng)建可寫流 fse.createWriteStream(filePath, { start: index * size, end: (index + 1) * size }) ) ) ); fse.rmdirSync(chunkDir); // 合并后刪除保存切片的目錄 } if (req.url === '/merge') { const data = await resolvePost(req); const { filename, size } = data; const filePath = path.resolve(UPLOAD_DIR, `${filename}`); await mergeFileChunk(filePath, filename); res.end( JSON.stringify({ code: 0, message: "file merged success" }) ) }
使用 fs.createWriteStream 創(chuàng)建一個可寫流,可寫流文件名就是切片文件夾名 + 后綴名組合
將切片通過 fs.createReadStream 創(chuàng)建可讀流,傳輸合并到目標文件中
生成hash
// /public/hash.js self.importScripts("/spark-md5.min.js"); // 導入腳本 // 生成文件 hash self.onmessage = e => { const { fileChunkList } = e.data; const spark = new self.SparkMD5.ArrayBuffer(); let percentage = 0; let count = 0; const loadNext = index => { const reader = new FileReader(); reader.readAsArrayBuffer(fileChunkList[index].file); reader.onload = e => { count++; spark.append(e.target.result); if (count === fileChunkList.length) { self.postMessage({ percentage: 100, hash: spark.end() }); self.close(); } else { percentage += 100 / fileChunkList.length; self.postMessage({ percentage }); // 遞歸計算下一個切片 loadNext(count); } }; }; loadNext(0); };
worker 線程通訊的邏輯
// 生成文件hash calculateHash(fileChunkList) { return new Promise(resolve => { // worker屬性 this.container.worker = new Worker('/hash.js'); this.container.worker.postMessage({ fileChunkList }); this.container.worker.onmessage = e => { const { percentage, hash } = e.data; this.hashPercentage = percentage; if (hash) { resolve(hash); } } }) }
文件秒傳
async verifyUpload(filename, fileHash) { const { data } = await this.request({ url: "http://localhost:3000/verify", headers: { "content-type": "application/json" }, data: JSON.stringify({ filename, fileHash }) }); return JSON.parse(data); }, async handleUpload() { if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList); const { shouldUpload } = await this.verifyUpload( this.container.file.name, this.container.hash ); if(!shouldUpload) { this.$message.success("秒傳:上傳成功"); return; } this.data = fileChunkList.map(({file}, index) => ({ fileHash: this.container.hash, index, hash: this.container.hash + "-" + index, chunk: file, percentage: 0 })); await this.uploadChunks(); }
服務端:
const extractExt = filename => filename.slice(filename.lastIndexOf("."), filename.length); // 提取后綴名
暫停上傳
request({ url, method = "post", data, headers = {}, onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e => { // requestList 中只保存正在上傳切片的 xhr // 將請求成功的xhr從列表中刪除 if (requestList) { const xhrIndex = requestList.findIndex(item => item === xhr); requestList.splice(xhrIndex, 1); } resolve({ data: e.targt.response }); }; // 暴露當前xhr給外部 requestList?.push(xhr); }) }
暫停按鈕
handlePause() { this.requestList.forEach(xhr => xhr?.abort()); this.requestList = []; }
前端每次上傳前發(fā)送一個驗證的請求,返回兩種結(jié)果
- 服務端已存在該文件,不需要再次上傳
- 服務端不存在該文件或者已上傳部分文件切片,通知前端進行上傳,并把已上傳的文件切片返回給前端
服務端驗證接口
// 返回已經(jīng)上傳切片名列表 const createUploadedList = async fileHash => fse.existsSync(path.resolve(UPLOAD_DIR, fileHash)) ? await fse.readdir(path.resolve(UPLOAD_DIR, fileHash)) : []; if (fse.existsSync(filePath)) { res.end( JSON.stringify({ shouldUpload: false }) ) } else { res.end( JSON.stringify({ shouldUpload: true, uploadedList: await createUploadedList(fileHash) }) ) }
- 點擊上傳時,檢查是否需要上傳和已上傳的切片
- 點擊暫停后的恢復上傳,返回已上傳的切片
async handleResume() { this.status = Status.uploading; const { uploadedList } = await this.verifyUpload( this.container.file.name, this.container.hash ) await this.uploadChunks(uploadedList) },
斷點續(xù)傳
- 服務器端返回,告知我從那開始
- 瀏覽器端自行處理
緩存處理
- 在切片上傳的axios成功回調(diào)中,存儲已上傳成功的切片
- 在切片上傳前,先看下localstorage中是否存在已上傳的切片,并修改uploaded
- 構(gòu)造切片數(shù)據(jù)時,過濾掉uploaded為true的
垃圾文件清理
- 前端在localstorage設置緩存時間,超過時間就發(fā)送請求通知后端清理碎片文件,同時前端也要清理緩存。
- 前后端都約定好,每個緩存從生成開始,只能存儲12小時,12小時后自動清理
(時間差問題)
秒傳
原理:計算整個文件的HASH,在執(zhí)行上傳操作前,向服務端發(fā)送請求,傳遞MD5值,后端進行文件檢索。 若服務器中已存在該文件,便不進行后續(xù)的任何操作,上傳也便直接結(jié)束。
在當前文件分片上傳完畢并且請求合并接口完畢后,再進行下一次循環(huán)。 每次點擊input時,清空數(shù)據(jù)。
Q: 處理暫?;謴秃?,進度條后退的問題
定義臨時變量fakeUploadProgress在每次暫停時存儲當前的進度,在上傳恢復后, 當當前進度大于fakeUploadProgress的進度,再進行賦值即可。
以上就是Vue+Node實現(xiàn)大文件上傳和斷點續(xù)傳的詳細內(nèi)容,更多關于Vue Node大文件上傳 斷點續(xù)傳的資料請關注腳本之家其它相關文章!
相關文章
Vue3組合式API之getCurrentInstance詳解
我們可以通過?getCurrentInstance這個函數(shù)來返回當前組件的實例對象,也就是當前vue這個實例對象,下面這篇文章主要給大家介紹了關于Vue3組合式API之getCurrentInstance的相關資料,需要的朋友可以參考下2022-09-09vue?this.$refs.xxx獲取dom注意事項?v-if?v-for渲染的dom不能直接使用
這篇文章主要介紹了vue?this.$refs.xxx獲取dom注意事項?v-if?v-for渲染的dom不能直接使用問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-03-03vue-infinite-loading2.0 中文文檔詳解
本篇文章主要介紹了vue-infinite-loading2.0 中文文檔詳解,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-04-04vue實現(xiàn)的微信機器人聊天功能案例【附源碼下載】
這篇文章主要介紹了vue實現(xiàn)的微信機器人聊天功能,結(jié)合實例形式分析了基于vue.js的微信機器人聊天相關界面布局、ajax交互等操作技巧,并附帶源碼供讀者下載參考,需要的朋友可以參考下2019-02-02