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

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

//! --------通過hash查詢服務器中已經(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])
//! 通過查詢文件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
}
}
}
復制代碼
進度條
實時計算一下已經(jīng)成功上傳的片段不就行了,自行實現(xiàn)吧
4.抽樣hash和webWorker
因為上傳前,我們需要計算文件的md5值,作為切片的id使用。
md5的計算是一個非常耗時的事情,如果文件過大,js會卡在計算md5這一步,造成頁面長時間卡頓。
我們這里提供三種思路進行優(yōu)化
抽樣hash(md5)
抽樣hash是指,我們截取整個文件的一部分,計算hash,提升計算速度.
1. 我們將file解析為二進制buffer數(shù)據(jù),
2. 抽取文件頭尾2mb, 中間的部分每隔2mb抽取2kb
3. 將這些片段組合成新的buffer,進行md5計算。
圖解:

樣例代碼
//! ---------------抽樣md5計算-------------------
function calculateHashSample(file) {
return new Promise((resolve) => {
//!轉換文件類型(解析為BUFFER數(shù)據(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));
});
}
復制代碼
webWorker
除了抽樣hash,我們可以另外開啟一個webWorker線程去專門計算md5.
webWorker: 就是給JS創(chuàng)造多線程運行環(huán)境,允許主線程創(chuàng)建worker線程,分配任務給后者,主線程運行的同時worker線程也在運行,相互不干擾,在worker線程運行結束后把結果返回給主線程。
具體使用方式可以參考MDN或者其他文章
使用 Web Workers \- Web API 接口參考 | MDN \(mozilla.org\)[1]
一文徹底學會使用web worker \- 掘金 \(juejin.cn\)[2]
時間切片
熟悉React時間切片的同學也可以去試一試,不過個人認為這個方案沒有以上兩種好。
不熟悉的同學可以自行掘金一下,文章還是很多的。
這里就不多做論述,只提供思路
時間切片也就是傳說中的requestIdleCallback,requestAnimationCallback 這兩個API了,或者高級一點自己通過messageChannel去封裝。
切片計算hash,將多個短任務分布在每一幀里,減少頁面卡頓。
5.文件類型判斷
簡單一點,我們可以通過input標簽的accept屬性,或者截取文件名來判斷類型
<input id="file" type="file" accept="image/*" />
const ext = file.name.substring(file.name.lastIndexOf('.') + 1);
復制代碼
當然這種限制可以簡單的通過修改文件后綴名來突破,并不嚴謹。
通過文件頭判斷文件類型
我們將文件轉化為二進制blob,文件的前幾個字節(jié)就表示了文件類型,我們讀取進行判斷即可。
比如如下代碼
// 判斷是否為 .jpg
async function isJpg(file) {
// 截取前幾個字節(jié),轉換為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'
}
復制代碼
當然咱們有現(xiàn)成的庫可以做這件事情,比如 file-type 這個庫
file-type \- npm \(npmjs.com\)[3]
6.異步并發(fā)數(shù)控制(重要)
我們需要將多個文件片段上傳給后端,總不能一個個發(fā)送把?我們這里使用TCP的并發(fā)+實現(xiàn)控制并發(fā)進行上傳。

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

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