一個(gè)企業(yè)級(jí)的文件上傳組件應(yīng)該是什么樣的
前言
大廠技術(shù) 高級(jí)前端 Node進(jìn)階
點(diǎn)擊上方 程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
大家好這里是陽(yáng)九,一個(gè)中途轉(zhuǎn)行的野路子碼農(nóng),熱衷于研究和手寫(xiě)前端工具.
我的宗旨就是 萬(wàn)物皆可手寫(xiě)
新手創(chuàng)作不易,有問(wèn)題歡迎指出和輕噴,謝謝
本文適合有一定node后端基礎(chǔ)的前端同學(xué),如果對(duì)后端完全不了解請(qǐng)惡補(bǔ)前置知識(shí)。
廢話不多說(shuō),直接進(jìn)入正題。
我們來(lái)看一下,各個(gè)版本的文件上傳組件大概都長(zhǎng)什么樣
| 等級(jí) | 功能 |
|---|---|
| 青銅-垃圾玩意 | 原生+axios.post |
| 白銀-體驗(yàn)升級(jí) | 粘貼,拖拽,進(jìn)度條 |
| 黃金-功能升級(jí) | 斷點(diǎn)續(xù)傳,秒傳,類(lèi)型判斷 |
| 鉑金-速度升級(jí) | web-worker,時(shí)間切片,抽樣hash |
| 鉆石-網(wǎng)絡(luò)升級(jí) | 異步并發(fā)數(shù)控制,切片報(bào)錯(cuò)重試 |
| 王者-精雕細(xì)琢 | 慢啟動(dòng)控制,碎片清理等等 |
1.最簡(jiǎn)單的文件上傳
文件上傳,我們需要獲取文件對(duì)象,然后使用formData發(fā)送給后端接收即可
function upload(file){
let formData = new FormData();
formData.append('newFile', file);
axios.post(
'http://localhost:8000/uploader/upload',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
}
復(fù)制代碼
2.拖拽+粘貼+樣式優(yōu)化
懶得寫(xiě),你們網(wǎng)上找找?guī)彀?網(wǎng)上啥都有,或者直接組件庫(kù)解決問(wèn)題
3.斷點(diǎn)續(xù)傳+秒傳+進(jìn)度條
文件切片
我們通過(guò)將一個(gè)文件分為多個(gè)小塊,保存到數(shù)組中.逐個(gè)發(fā)送給后端,實(shí)現(xiàn)斷點(diǎn)續(xù)傳。

// 計(jì)算文件hash作為id
const { hash } = await calculateHashSample(file)
//todo 生成文件分片列表
// 使用file.slice()將文件切片
const fileList = [];
const count = Math.ceil(file.size / globalProp.SIZE);
const partSize = file.size / count;
let cur = 0 // 記錄當(dāng)前切片的位置
for (let i = 0; i < count; i++) {
let item = {
chunk: file.slice(cur, cur + partSize),
filename: `${hash}_${i}`
};
fileList.push(item);
}
復(fù)制代碼
計(jì)算hash
為了讓后端知道,這個(gè)切片是某個(gè)文件的一部分,以便聚合成一個(gè)完整的文件。我們需要計(jì)算完整file的唯一值(md5),作為切片的文件名。
// 通過(guò)input的event獲取到file
<input type="file" @change="getFile">
// 使用SparkMD5計(jì)算文件hash,讀取文件為blob,計(jì)算hash
let fileReader = new FileReader();
fileReader.onload = (e)=>{
let hexHash = SparkMD5.hash(e.target.result)
; console.log(hexHash);
};
復(fù)制代碼
斷點(diǎn)續(xù)傳+秒傳(前端)
我們此時(shí)有保存了100個(gè)文件切片的數(shù)組,遍歷切片連續(xù)向后端發(fā)送axios.post請(qǐng)求即可 設(shè)置一個(gè)開(kāi)關(guān),實(shí)現(xiàn)啟動(dòng)-暫停功能。
如果我們傳了50份,關(guān)掉了瀏覽器怎么辦?
此時(shí)我們需要后端配合,在上傳文件之前,先檢查一下后端接收了多少文件。
當(dāng)然,如果發(fā)現(xiàn)后端已經(jīng)上傳過(guò)這個(gè)文件,直接顯示上傳完畢(秒傳)
// 解構(gòu)出已經(jīng)上傳的文件數(shù)組 文件是否已經(jīng)上傳完畢
// 通過(guò)文件hash和后綴查詢(xún)當(dāng)前文件有多少已經(jīng)上傳的部分
const {isFileUploaded, uploadedList} = await axios.get(
`http://localhost:8000/uploader/count
?hash=${hash}
&suffix=${fileSuffix}
`)
復(fù)制代碼
斷點(diǎn)續(xù)傳+秒傳(后端)
至于后端的操作,就比較簡(jiǎn)單了
根據(jù)文件hash創(chuàng)建文件夾,保存文件切片 檢查某文件的上傳情況,通過(guò)接口返回給前端
例如以下文件切片文件夾

//! --------通過(guò)hash查詢(xún)服務(wù)器中已經(jīng)存放了多少份文件(或者是否已經(jīng)存在文件)------
function checkChunks(hash, suffix) {
//! 查看已經(jīng)存在多少文件 獲取已上傳的indexList
const chunksPath = `${uploadChunksDir}${hash}`;
const chunksList = (fs.existsSync(chunksPath) && fs.readdirSync(chunksPath)) || [];
const indexList = chunksList.map((item, index) =>item.split('_')[1])
//! 通過(guò)查詢(xún)文件hash+suffix 判斷文件是否已經(jīng)上傳
const filename = `${hash}${suffix}`
const fileList = (fs.existsSync(uploadFileDir) && fs.readdirSync(uploadFileDir)) || [];
const isFileUploaded = fileList.indexOf(filename) === -1 ? false : true
console.log('已經(jīng)上傳的chunks', chunksList.length);
console.log('文件是否存在', isFileUploaded);
return {
code: 200,
data: {
count: chunksList.length,
uploadedList: indexList,
isFileUploaded: isFileUploaded
}
}
}
復(fù)制代碼
進(jìn)度條
實(shí)時(shí)計(jì)算一下已經(jīng)成功上傳的片段不就行了,自行實(shí)現(xiàn)吧
4.抽樣hash和webWorker
因?yàn)樯蟼髑?我們需要計(jì)算文件的md5值,作為切片的id使用。
md5的計(jì)算是一個(gè)非常耗時(shí)的事情,如果文件過(guò)大,js會(huì)卡在計(jì)算md5這一步,造成頁(yè)面長(zhǎng)時(shí)間卡頓。
我們這里提供三種思路進(jìn)行優(yōu)化
抽樣hash(md5)
抽樣hash是指,我們截取整個(gè)文件的一部分,計(jì)算hash,提升計(jì)算速度.
1. 我們將file解析為二進(jìn)制buffer數(shù)據(jù),
2. 抽取文件頭尾2mb, 中間的部分每隔2mb抽取2kb
3. 將這些片段組合成新的buffer,進(jìn)行md5計(jì)算。
圖解:

樣例代碼
//! ---------------抽樣md5計(jì)算-------------------
function calculateHashSample(file) {
return new Promise((resolve) => {
//!轉(zhuǎn)換文件類(lèi)型(解析為BUFFER數(shù)據(jù) 用于計(jì)算md5)
const spark = new SparkMD5.ArrayBuffer();
const { size } = file;
const OFFSET = Math.floor(2 * 1024 * 1024); // 取樣范圍 2M
const reader = new FileReader();
let index = OFFSET;
// 頭尾全取,中間抽2字節(jié)
const chunks = [file.slice(0, index)];
while (index < size) {
if (index + OFFSET > size) {
chunks.push(file.slice(index));
} else {
const CHUNK_OFFSET = 2;
chunks.push(file.slice(index, index + 2),
file.slice(index + OFFSET - CHUNK_OFFSET, index + OFFSET));
}
index += OFFSET;
}
// 將抽樣后的片段添加到spark
reader.onload = (event) => {
spark.append(event.target.result);
resolve({
hash: spark.end(),//Promise返回hash
});
}
reader.readAsArrayBuffer(new Blob(chunks));
});
}
復(fù)制代碼
webWorker
除了抽樣hash,我們可以另外開(kāi)啟一個(gè)webWorker線程去專(zhuān)門(mén)計(jì)算md5.
webWorker: 就是給JS創(chuàng)造多線程運(yùn)行環(huán)境,允許主線程創(chuàng)建worker線程,分配任務(wù)給后者,主線程運(yùn)行的同時(shí)worker線程也在運(yùn)行,相互不干擾,在worker線程運(yùn)行結(jié)束后把結(jié)果返回給主線程。
具體使用方式可以參考MDN或者其他文章
使用 Web Workers \- Web API 接口參考 | MDN \(mozilla.org\)[1]
一文徹底學(xué)會(huì)使用web worker \- 掘金 \(juejin.cn\)[2]
時(shí)間切片
熟悉React時(shí)間切片的同學(xué)也可以去試一試,不過(guò)個(gè)人認(rèn)為這個(gè)方案沒(méi)有以上兩種好。
不熟悉的同學(xué)可以自行掘金一下,文章還是很多的。
這里就不多做論述,只提供思路
時(shí)間切片也就是傳說(shuō)中的requestIdleCallback,requestAnimationCallback 這兩個(gè)API了,或者高級(jí)一點(diǎn)自己通過(guò)messageChannel去封裝。
切片計(jì)算hash,將多個(gè)短任務(wù)分布在每一幀里,減少頁(yè)面卡頓。
5.文件類(lèi)型判斷
簡(jiǎn)單一點(diǎn),我們可以通過(guò)input標(biāo)簽的accept屬性,或者截取文件名來(lái)判斷類(lèi)型
<input id="file" type="file" accept="image/*" />
const ext = file.name.substring(file.name.lastIndexOf('.') + 1);
復(fù)制代碼
當(dāng)然這種限制可以簡(jiǎn)單的通過(guò)修改文件后綴名來(lái)突破,并不嚴(yán)謹(jǐn)。
通過(guò)文件頭判斷文件類(lèi)型
我們將文件轉(zhuǎn)化為二進(jìn)制blob,文件的前幾個(gè)字節(jié)就表示了文件類(lèi)型,我們讀取進(jìn)行判斷即可。
比如如下代碼
// 判斷是否為 .jpg
async function isJpg(file) {
// 截取前幾個(gè)字節(jié),轉(zhuǎn)換為string
const res = await blobToString(file.slice(0, 3))
return res === 'FF D8 FF'
}
// 判斷是否為 .png
async function isPng(file) {
const res = await blobToString(file.slice(0, 4))
return res === '89 50 4E 47'
}
// 判斷是否為 .gif
async function isGif(file) {
const res = await blobToString(file.slice(0, 4))
return res === '47 49 46 38'
}
復(fù)制代碼
當(dāng)然咱們有現(xiàn)成的庫(kù)可以做這件事情,比如 file-type 這個(gè)庫(kù)
file-type \- npm \(npmjs.com\)[3]
6.異步并發(fā)數(shù)控制(重要)
我們需要將多個(gè)文件片段上傳給后端,總不能一個(gè)個(gè)發(fā)送把?我們這里使用TCP的并發(fā)+實(shí)現(xiàn)控制并發(fā)進(jìn)行上傳。

首先我們將100個(gè)文件片段都封裝為axios.post函數(shù),存入任務(wù)池中 創(chuàng)建一個(gè)并發(fā)池,同時(shí)執(zhí)行并發(fā)池中的任務(wù),發(fā)送片段 設(shè)置計(jì)數(shù)器i,當(dāng)i<并發(fā)數(shù)時(shí),才能將任務(wù)推入并發(fā)池 通過(guò)promise.race方法 最先執(zhí)行完畢的請(qǐng)求會(huì)被返回 即可調(diào)用其.then方法 傳入下一個(gè)請(qǐng)求(遞歸) 當(dāng)最后一個(gè)請(qǐng)求發(fā)送完畢 向后端發(fā)起請(qǐng)求 合并文件片段
圖解

代碼
//! 傳入請(qǐng)求列表 最大并發(fā)數(shù) 全部請(qǐng)求完畢后的回調(diào)
function concurrentSendRequest(requestArr: any, max = 3, callback: any) {
let i = 0 // 執(zhí)行任務(wù)計(jì)數(shù)器
let concurrentRequestArr: any[] = [] //并發(fā)請(qǐng)求列表
let toFetch: any = () => {
// (每次執(zhí)行i+1) 如果i=arr.length 說(shuō)明是最后一個(gè)任務(wù)
// 返回一個(gè)resolve 作為最后的toFetch.then()執(zhí)行
// (執(zhí)行Promise.all() 全部任務(wù)執(zhí)行完后執(zhí)行回調(diào)函數(shù) 發(fā)起文件合并請(qǐng)求)
if (i === requestArr.length) {
return Promise.resolve()
}
//TODO 執(zhí)行異步任務(wù) 并推入并發(fā)列表(計(jì)數(shù)器+1)
let it = requestArr[i++]()
concurrentRequestArr.push(it)
//TODO 任務(wù)執(zhí)行后 從并發(fā)列表中刪除
it.then(() => {
concurrentRequestArr.splice(concurrentRequestArr.indexOf(it), 1)
})
//todo 如果并發(fā)數(shù)達(dá)到最大數(shù),則等其中一個(gè)異步任務(wù)完成再添加
let p = Promise.resolve()
if (concurrentRequestArr.length >= max) {
//! race方法 返回fetchArr中最快執(zhí)行的任務(wù)結(jié)果
p = Promise.race(concurrentRequestArr)
}
//todo race中最快完成的promise,在其.then遞歸toFetch函數(shù)
if (globalProp.stop) { return p.then(() => { console.log('停止發(fā)送') }) }
return p.then(() => toFetch())
}
// 最后一組任務(wù)全部執(zhí)行完再執(zhí)行回調(diào)函數(shù)(發(fā)起合并請(qǐng)求)(如果未合并且未暫停)
toFetch().then(() =>
Promise.all(concurrentRequestArr).then(() => {
if (!globalProp.stop && !globalProp.finished) { callback() }
})
)
}
復(fù)制代碼
7.并發(fā)錯(cuò)誤重試
使用catch捕獲任務(wù)錯(cuò)誤,上述axios.post任務(wù)執(zhí)行失敗后,重新把任務(wù)放到任務(wù)隊(duì)列中 給每個(gè)任務(wù)對(duì)象設(shè)置一個(gè)tag,記錄任務(wù)重試的次數(shù) 如果一個(gè)切片任務(wù)出錯(cuò)超過(guò)3次,直接reject。并且可以直接終止文件傳輸
8.慢啟動(dòng)控制
由于文件大小不一,我們每個(gè)切片的大小設(shè)置成固定的也有點(diǎn)略顯笨拙,我們可以參考TCP協(xié)議的慢啟動(dòng)策略,
. 設(shè)置一個(gè)初始大小,根據(jù)上傳任務(wù)完成的時(shí)候,來(lái)動(dòng)態(tài)調(diào)整下一個(gè)切片的大小, 確保文件切片的大小和當(dāng)前網(wǎng)速匹配
chunk中帶上size值,不過(guò)進(jìn)度條數(shù)量不確定了,修改createFileChunk, 請(qǐng)求加上時(shí)間統(tǒng)計(jì) 比如我們理想是30秒傳遞一個(gè) 初始大小定為1M,如果上傳花了10秒,那下一個(gè)區(qū)塊大小變成3M 如果上傳花了60秒,那下一個(gè)區(qū)塊大小變成500KB 以此類(lèi)推
9.碎片清理
如果用戶上傳文件到一半終止,并且以后也不傳了,后端保存的文件片段也就沒(méi)有用了。
我們可以在node端設(shè)置一個(gè)定時(shí)任務(wù)setInterval,每隔一段時(shí)間檢查并清理不需要的碎片文件
可以使用 node-schedule 來(lái)管理定時(shí)任務(wù),比如每天檢查一次目錄,如果文件是一個(gè)月前的,那就直接刪除把。
垃圾碎片文件 
后記
以上就是一個(gè)完整的比較高級(jí)的文件上傳組件的全部功能,希望各位有耐心看到這里的新手小伙伴能夠融會(huì)貫通。每天進(jìn)步一點(diǎn)點(diǎn)。
參考資料
https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API/Using_web_workers: https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FWeb_Workers_API%2FUsing_web_workers
[2]https://juejin.cn/post/7139718200177983524: https://juejin.cn/post/7139718200177983524
[3]https://www.npmjs.com/package/file-type: https://link.juejin.cn?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Ffile-type
關(guān)于本文
作者:不月陽(yáng)九
https://juejin.cn/post/7187695113597321275
Node 社群 我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
“分享、點(diǎn)贊、在看” 支持一波??
