前言
這段時(shí)間面試官都挺忙的哈甚疟,頻頻出現(xiàn)在很多的博客文章標(biāo)題里驶忌,雖然我不是特別想蹭熱度圈盔,但是實(shí)在想不到好的標(biāo)題了哈哈哈哈,我就蹭蹭保證不亂來(唉呀媽呀真香)
事實(shí)上我在面試的時(shí)候確實(shí)被問到了這個(gè)問題氏豌,而且是一道在線 coding 的編程題,當(dāng)時(shí)雖然思路正確热凹,可惜最終也并不算完全答對
結(jié)束后花了一段時(shí)間整理了下思路泵喘,那么究竟該如何實(shí)現(xiàn)一個(gè)大文件上傳泪电,以及在上傳中如何實(shí)現(xiàn)斷點(diǎn)續(xù)傳的功能呢?
本文將從零搭建前端和服務(wù)端涣旨,實(shí)現(xiàn)一個(gè)大文件上傳和斷點(diǎn)續(xù)傳的 demo
前端:vue
element-ui
服務(wù)端:nodejs
文章有誤解的地方歪架,歡迎指出,將在第一時(shí)間改正霹陡,有更好的實(shí)現(xiàn)方式希望留下你的評論
大文件上傳
整體思路
前端
前端大文件上傳網(wǎng)上的大部分文章已經(jīng)給出了解決方案和蚪,核心是Blob.prototype.slice
方法,和數(shù)組的 slice 方法相似烹棉,調(diào)用的 slice 方法可以返回原文件的某個(gè)切片
這樣我們就可以根據(jù)預(yù)先設(shè)置好的切片最大數(shù)量將文件切分為一個(gè)個(gè)切片攒霹,然后借助 http 的可并發(fā)性,同時(shí)上傳多個(gè)切片浆洗,這樣從原本傳一個(gè)大文件催束,變成了同時(shí)
傳多個(gè)小的文件切片,可以大大減少上傳時(shí)間
另外由于是并發(fā)伏社,傳輸?shù)椒?wù)端的順序可能會發(fā)生變化抠刺,所以我們還需要給每個(gè)切片記錄順序
服務(wù)端
服務(wù)端需要負(fù)責(zé)接受這些切片,并在接收到所有切片后合并
切片
這里又引伸出兩個(gè)問題
- 何時(shí)合并切片摘昌,即切片什么時(shí)候傳輸完成
- 如何合并切片
第一個(gè)問題需要前端進(jìn)行配合速妖,前端在每個(gè)切片中都攜帶切片最大數(shù)量的信息,當(dāng)服務(wù)端接受到這個(gè)數(shù)量的切片時(shí)自動合并聪黎,也可以額外發(fā)一個(gè)請求主動通知服務(wù)端進(jìn)行切片的合并
第二個(gè)問題罕容,具體如何合并切片呢?這里可以使用 nodejs 的 api fs.appendFileSync
稿饰,它可以同步地將數(shù)據(jù)追加到指定文件锦秒,也就是說,當(dāng)服務(wù)端接受到所有切片后喉镰,先創(chuàng)建一個(gè)最終的文件旅择,然后將所有切片逐步合并到這個(gè)文件中
talk is cheap,show me the code
,接著我們用代碼實(shí)現(xiàn)上面的思路
前端部分
前端使用 Vue 作為開發(fā)框架梧喷,對界面沒有太大要求砌左,原生也可以,考慮到美觀使用 element-ui 作為 UI 框架
上傳控件
首先創(chuàng)建選擇文件的控件铺敌,監(jiān)聽 change 事件以及上傳按鈕
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
</div>
</template>
<script>
export default {
data: () => ({
container: {
file: null
}
}),
methods: {
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
Object.assign(this.$data, this.$options.data());
this.container.file = file;
},
async handleUpload() {}
}
};
</script>
請求邏輯
考慮到通用性汇歹,這里沒有用第三方的請求庫,而是用原生 XMLHttpRequest 做一層簡單的封裝來發(fā)請求
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
});
};
});
}
上傳切片
接著實(shí)現(xiàn)比較重要的上傳功能偿凭,上傳需要做兩件事
- 對文件進(jìn)行切片
- 將切片傳輸給服務(wù)端
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
</div>
</template>
<script>
+ const LENGTH = 10; // 切片數(shù)量
export default {
data: () => ({
container: {
file: null,
+ data: []
}
}),
methods: {
request() {},
handleFileChange() {},
+ // 生成文件切片
+ createFileChunk(file, length = LENGTH) {
+ const fileChunkList = [];
+ const chunkSize = Math.ceil(file.size / length);
+ let cur = 0;
+ while (cur < file.size) {
+ fileChunkList.push({ file: file.slice(cur, cur + chunkSize) });
+ cur += chunkSize;
+ }
+ return fileChunkList;
+ },
+ // 上傳切片
+ async uploadChunks() {
+ const requestList = this.data
+ .map(({ chunk }) => {
+ 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ù)組下標(biāo)
+ }));
+ await this.uploadChunks();
+ }
}
};
</script>
當(dāng)點(diǎn)擊上傳按鈕時(shí),調(diào)用 createFileChunk
將文件切片,切片數(shù)量通過一個(gè)常量 Length 控制痰哨,這里設(shè)置為 10胶果,即將文件分成 10 個(gè)切片上傳
createFileChunk 內(nèi)使用 while 循環(huán)和 slice 方法將切片放入 fileChunkList
數(shù)組中返回
在生成文件切片時(shí),需要給每個(gè)切片一個(gè)標(biāo)識作為 hash斤斧,這里暫時(shí)使用文件名 + 下標(biāo)
早抠,這樣后端可以知道當(dāng)前切片是第幾個(gè)切片,用于之后的合并切片
隨后調(diào)用 uploadChunks
上傳所有的文件切片撬讽,將文件切片蕊连,切片 hash,以及文件名放入 FormData 中游昼,再調(diào)用上一步的 request
函數(shù)返回一個(gè) proimise甘苍,最后調(diào)用 Promise.all 并發(fā)上傳所有的切片
發(fā)送合并請求
這里使用整體思路中提到的第二種合并切片的方式,即前端主動通知服務(wù)端進(jìn)行合并烘豌,所以前端還需要額外發(fā)請求载庭,服務(wù)端接受到這個(gè)請求時(shí)主動合并切片
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
</div>
</template>
<script>
export default {
data: () => ({
container: {
file: null
},
data: []
}),
methods: {
request() {},
handleFileChange() {},
createFileChunk() {},
// 上傳切片,同時(shí)過濾已上傳的切片
async uploadChunks() {
const requestList = this.data
.map(({ chunk }) => {
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);
+ // 合并切片
+ await this.mergeRequest();
},
+ async mergeRequest() {
+ await this.request({
+ url: "http://localhost:3000/merge",
+ headers: {
+ "content-type": "application/json"
+ },
+ data: JSON.stringify({
+ filename: this.container.file.name
+ })
+ });
+ },
async handleUpload() {}
}
};
</script>
服務(wù)端部分
簡單使用 http 模塊搭建服務(wù)端
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 http = require("http");
const path = require("path");
const fse = require("fs-extra");
const multiparty = require("multiparty");
const server = http.createServer();
+ const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
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;
}
+ 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 = `${UPLOAD_DIR}/${filename}`;
+ // 切片目錄不存在,創(chuàng)建切片目錄
+ if (!fse.existsSync(chunkDir)) {
+ await fse.mkdirs(chunkDir);
+ }
+ // fs-extra 專用方法标锄,類似 fs.rename 并且跨平臺
+ // fs-extra 的 rename 方法 windows 平臺會有權(quán)限問題
+ // https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
+ await fse.move(chunk.path, `${chunkDir}/${hash}`);
+ res.end("received file chunk");
+ });
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
查看 multiparty 處理后的 chunk 對象靡挥,path 是存儲臨時(shí)文件的路徑,size 是臨時(shí)文件大小鸯绿,在 multiparty 文檔中提到可以使用 fs.rename(由于我用的是 fs-extra,rename windows 平臺權(quán)限問題簸淀,所以換成了 fse.move) 重命名的方式移動臨時(shí)文件瓶蝴,也就是文件切片
在接受文件切片時(shí),需要先創(chuàng)建存儲切片的文件夾租幕,由于前端在發(fā)送每個(gè)切片時(shí)額外攜帶了唯一值 hash舷手,所以以 hash 作為文件名,將切片從臨時(shí)路徑移動切片文件夾中劲绪,最后的結(jié)果如下
合并切片
在接收到前端發(fā)送的合并請求后男窟,服務(wù)端將文件夾下的所有切片進(jìn)行合并
const http = require("http");
const path = require("path");
const fse = require("fs-extra");
const server = http.createServer();
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
+ const resolvePost = req =>
+ new Promise(resolve => {
+ let chunk = "";
+ req.on("data", data => {
+ chunk += data;
+ });
+ req.on("end", () => {
+ resolve(JSON.parse(chunk));
+ });
+ });
+ // 合并切片
+ const mergeFileChunk = async (filePath, filename) => {
+ const chunkDir = `${UPLOAD_DIR}/${filename}`;
+ const chunkPaths = await fse.readdir(chunkDir);
+ await fse.writeFile(filePath, "");
+ chunkPaths.forEach(chunkPath => {
+ fse.appendFileSync(filePath, fse.readFileSync(`${chunkDir}/${chunkPath}`));
+ fse.unlinkSync(`${chunkDir}/${chunkPath}`);
+ });
+ fse.rmdirSync(chunkDir); // 合并后刪除保存切片的目錄
+ };
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;
}
+ if (req.url === "/merge") {
+ const data = await resolvePost(req);
+ const { filename } = data;
+ const filePath = `${UPLOAD_DIR}/${filename}`;
+ await mergeFileChunk(filePath, filename);
+ res.end(
+ JSON.stringify({
+ code: 0,
+ message: "file merged success"
+ })
+ );
+ }
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
由于前端在發(fā)送合并請求時(shí)會攜帶文件名,服務(wù)端根據(jù)文件名可以找到上一步創(chuàng)建的切片文件夾
接著使用 fs.writeFileSync 先創(chuàng)建一個(gè)空文件贾富,這個(gè)空文件的文件名就是切片文件夾名 + 后綴名組合而成歉眷,隨后通過 fs.appendFileSync 從切片文件夾中不斷將切片合并到空文件中,每次合并完成后刪除這個(gè)切片颤枪,等所有切片都合并完畢后最后刪除切片文件夾
至此一個(gè)簡單的大文件上傳就完成了汗捡,接下來我們再此基礎(chǔ)上擴(kuò)展一些額外的功能
顯示上傳進(jìn)度條
上傳進(jìn)度分兩種,一個(gè)是每個(gè)切片的上傳進(jìn)度畏纲,另一個(gè)是整個(gè)文件的上傳進(jìn)度扇住,而整個(gè)文件的上傳進(jìn)度是基于每個(gè)切片上傳進(jìn)度計(jì)算而來春缕,所以我們先實(shí)現(xiàn)切片的上傳進(jìn)度
切片進(jìn)度條
XMLHttpRequest 原生支持上傳進(jìn)度的監(jiān)聽,只需要監(jiān)聽 upload.onprogress 即可艘蹋,我們在原來的 request 基礎(chǔ)上傳入 onProgress 參數(shù)锄贼,給 XMLHttpRequest 注冊監(jiān)聽事件
// xhr
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 => {
resolve({
data: e.target.response
});
};
});
}
由于每個(gè)切片都需要觸發(fā)獨(dú)立的監(jiān)聽事件,所以還需要一個(gè)工廠函數(shù)女阀,根據(jù)傳入的切片返回不同的監(jiān)聽函數(shù)
在原先的前端上傳邏輯中新增監(jiān)聽函數(shù)部分
// 上傳切片宅荤,同時(shí)過濾已上傳的切片
async uploadChunks(uploadedList = []) {
const requestList = this.data
.map(({ chunk }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("filename", this.container.file.name);
return { formData };
})
.map(async ({ formData }) =>
this.request({
url: "http://localhost:3000",
data: formData,
+ onProgress: this.createProgressHandler(this.data[index]),
})
);
await Promise.all(requestList);
// 合并切片
await this.mergeRequest();
},
async handleUpload() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
this.data = fileChunkList.map(({ file }强品,index) => ({
chunk: file,
+ index,
hash: this.container.file.name + "-" + index
+ percentage:0
}));
await this.uploadChunks();
}
+ createProgressHandler(item) {
+ return e => {
+ item.percentage = parseInt(String((e.loaded / e.total) * 100));
+ };
+ }
每個(gè)切片在上傳時(shí)都會通過監(jiān)聽函數(shù)更新 data 數(shù)組對應(yīng)元素的 percentage 屬性膘侮,之后把將 data 數(shù)組放到視圖中展示即可
文件進(jìn)度條
將每個(gè)切片已上傳的部分累加,除以整個(gè)文件的大小的榛,就能得出當(dāng)前文件的上傳進(jìn)度琼了,所以這里使用 Vue 計(jì)算屬性
computed: {
uploadPercentage() {
if (!this.container.file || !this.data.length) return 0;
const loaded = this.data
.map(item => item.size * item.percentage)
.reduce((acc, cur) => acc + cur);
return parseInt((loaded / this.container.file.size).toFixed(2));
}
}
最終視圖如下
斷點(diǎn)續(xù)傳
斷點(diǎn)續(xù)傳的原理在于前端/服務(wù)端需要記住
已上傳的切片,這樣下次上傳就可以跳過之前已上傳的部分夫晌,有兩種方案實(shí)現(xiàn)記憶的功能
- 前端使用 localStorage 記錄已上傳的切片 hash
- 服務(wù)端保存已上傳的切片 hash雕薪,前端每次上傳前向服務(wù)端獲取已上傳的切片
第一種是前端的解決方案,第二種是服務(wù)端晓淀,而前端方案有一個(gè)缺陷所袁,如果換了個(gè)瀏覽器就失去了記憶的效果,所以這里選取后者
生成 hash
無論是前端還是服務(wù)端凶掰,都必須要生成文件和切片的 hash燥爷,之前我們使用文件名 + 切片下標(biāo)作為切片 hash
,這樣做文件名一旦修改就失去了效果懦窘,而事實(shí)上只要文件內(nèi)容不變前翎,hash 就不應(yīng)該變化,所以正確的做法是根據(jù)文件內(nèi)容生成 hash
畅涂,所以我們修改一下 hash 的生成規(guī)則
這里用到另一個(gè)庫 spark-md5
港华,它可以根據(jù)文件內(nèi)容計(jì)算出文件的 hash 值,另外考慮到如果上傳一個(gè)超大文件午衰,讀取文件內(nèi)容計(jì)算 hash 是非常耗費(fèi)時(shí)間的立宜,并且會引起 UI 的阻塞
,導(dǎo)致頁面假死狀態(tài)臊岸,所以我們使用 web-worker 在 worker 線程計(jì)算 hash橙数,這樣用戶仍可以在主界面正常的交互
由于實(shí)例化 web-worker 時(shí),參數(shù)是一個(gè) js 文件路徑且不能跨域帅戒,所以我們單獨(dú)創(chuàng)建一個(gè) hash.js 文件放在 public 目錄下商模,另外在 worker 中也是不允許訪問 dom 的,但它提供了importScripts
函數(shù)用于導(dǎo)入外部腳本,通過它導(dǎo)入 spark-md5
// /public/hash.js
self.importScripts("/spark-md5.min.js"); // 導(dǎo)入腳本
// 生成文件 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
});
// 遞歸計(jì)算下一個(gè)切片
loadNext(count);
}
};
};
loadNext(0);
};
在 worker 線程中施流,接受文件切片 fileChunkList响疚,利用 FileReader 讀取每個(gè)切片的 ArrayBuffer 并不斷傳入 spark-md5 中,每計(jì)算完一個(gè)切片通過 postMessage 向主線程發(fā)送一個(gè)進(jìn)度事件瞪醋,全部完成后將最終的 hash 發(fā)送給主線程
spark-md5 需要根據(jù)所有切片才能算出一個(gè) hash 值忿晕,不能直接將整個(gè)文件放入計(jì)算,否則即使不同文件也會有相同的 hash
接著編寫主線程與 worker 線程通訊的邏輯
+ // 生成文件 hash(web-worker)
+ 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 handleUpload() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
+ this.container.hash = await this.calculateHash(fileChunkList);
this.data = fileChunkList.map(({ file }银受,index) => ({
+ fileHash: this.container.hash,
chunk: file,
hash: this.container.file.name + "-" + index, // 文件名 + 數(shù)組下標(biāo)
percentage:0
}));
await this.uploadChunks();
}
主線程使用 postMessage
給 worker 線程傳入所有切片 fileChunkList践盼,并監(jiān)聽 worker 線程發(fā)出的 postMessage 事件拿到文件 hash
加上顯示計(jì)算 hash 的進(jìn)度條,看起來像這樣
至此前端需要將之前用文件名作為 hash 的地方改寫為 workder 返回的這個(gè) hash
服務(wù)端則使用 hash 作為切片文件夾名宾巍,hash + 下標(biāo)作為切片名咕幻,hash + 擴(kuò)展名作為文件名,沒有新增的邏輯
文件秒傳
在實(shí)現(xiàn)斷點(diǎn)續(xù)傳前先簡單介紹一下文件秒傳
所謂的文件秒傳顶霞,即在服務(wù)端已經(jīng)存在了上傳的資源肄程,所以當(dāng)用戶再次上傳
時(shí)會直接提示上傳成功
文件秒傳需要依賴上一步生成的 hash,即在上傳前
选浑,先計(jì)算出文件 hash蓝厌,并把 hash 發(fā)送給服務(wù)端進(jìn)行驗(yàn)證,由于 hash 的唯一性古徒,所以一旦服務(wù)端能找到 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();
}
秒傳其實(shí)就是給用戶看的障眼法,實(shí)質(zhì)上根本沒有上傳
服務(wù)端的邏輯非常簡單隧膘,新增一個(gè)驗(yàn)證接口代态,驗(yàn)證文件是否存在即可
+ const extractExt = filename =>
+ filename.slice(filename.lastIndexOf("."), filename.length); // 提取后綴名
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
const resolvePost = req =>
new Promise(resolve => {
let chunk = "";
req.on("data", data => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
server.on("request", async (req, res) => {
if (req.url === "/verify") {
+ const data = await resolvePost(req);
+ const { fileHash, filename } = data;
+ const ext = extractExt(filename);
+ const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
+ if (fse.existsSync(filePath)) {
+ res.end(
+ JSON.stringify({
+ shouldUpload: false
+ })
+ );
+ } else {
+ res.end(
+ JSON.stringify({
+ shouldUpload: true
+ })
+ );
+ }
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
暫停上傳
講完了生成 hash 和文件秒傳,回到斷點(diǎn)續(xù)傳
斷點(diǎn)續(xù)傳顧名思義即斷點(diǎn) + 續(xù)傳疹吃,所以我們第一步先實(shí)現(xiàn)“斷點(diǎn)”胆数,也就是暫停上傳
原理是使用 XMLHttpRequest 的 abort
方法,可以取消一個(gè) xhr 請求的發(fā)送互墓,為此我們需要將上傳每個(gè)切片的 xhr 對象保存起來,我們再改造一下 request 方法
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 => {
+ // 將請求成功的 xhr 從列表中刪除
+ if (requestList) {
+ const xhrIndex = requestList.findIndex(item => item === xhr);
+ requestList.splice(xhrIndex, 1);
+ }
resolve({
data: e.target.response
});
};
+ // 暴露當(dāng)前 xhr 給外部
+ requestList?.push(xhr);
});
},
這樣在上傳切片時(shí)傳入 requestList 數(shù)組作為參數(shù)蒋搜,request 方法就會將所有的 xhr 保存在數(shù)組中了
每當(dāng)一個(gè)切片上傳成功時(shí)篡撵,將對應(yīng)的 xhr 從 requestList 中刪除,所以 requestList 中只保存正在上傳切片的 xhr
之后新建一個(gè)暫停按鈕豆挽,當(dāng)點(diǎn)擊按鈕時(shí)育谬,調(diào)用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上傳的切片
handlePause() {
this.requestList.forEach(xhr => xhr?.abort());
this.requestList = [];
}
點(diǎn)擊暫停按鈕可以看到 xhr 都被取消了
恢復(fù)上傳
之前在介紹斷點(diǎn)續(xù)傳的時(shí)提到使用第二種服務(wù)端存儲的方式實(shí)現(xiàn)續(xù)傳
由于當(dāng)文件切片上傳后帮哈,服務(wù)端會建立一個(gè)文件夾存儲所有上傳的切片膛檀,所以每次前端上傳前可以調(diào)用一個(gè)接口,服務(wù)端將已上傳的切片的切片名返回,前端再跳過這些已經(jīng)上傳切片咖刃,這樣就實(shí)現(xiàn)了“續(xù)傳”的效果
而這個(gè)接口可以和之前秒傳的驗(yàn)證接口合并泳炉,前端每次上傳前發(fā)送一個(gè)驗(yàn)證的請求,返回兩種結(jié)果
- 服務(wù)端已存在該文件嚎杨,不需要再次上傳
- 服務(wù)端不存在該文件或者已上傳部分文件切片花鹅,通知前端進(jìn)行上傳,并把已上傳的文件切片返回給前端
所以我們改造一下之前文件秒傳的服務(wù)端驗(yàn)證接口
const extractExt = filename =>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后綴名
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
const resolvePost = req =>
new Promise(resolve => {
let chunk = "";
req.on("data", data => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
+ // 返回已經(jīng)上傳切片名列表
+ const createUploadedList = async fileHash =>
+ fse.existsSync(`${UPLOAD_DIR}/${fileHash}`)
+ ? await fse.readdir(`${UPLOAD_DIR}/${fileHash}`)
+ : [];
server.on("request", async (req, res) => {
if (req.url === "/verify") {
const data = await resolvePost(req);
const { fileHash, filename } = data;
const ext = extractExt(filename);
const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
shouldUpload: false
})
);
} else {
res.end(
JSON.stringify({
shouldUpload: true枫浙,
+ uploadedList: await createUploadedList(fileHash)
})
);
}
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
接著回到前端刨肃,前端有兩個(gè)地方需要調(diào)用驗(yàn)證的接口
- 點(diǎn)擊上傳時(shí),檢查是否需要上傳和已上傳的切片
- 點(diǎn)擊暫停后的恢復(fù)上傳箩帚,返回已上傳的切片
新增恢復(fù)按鈕并改造原來上傳切片的邏輯
<template>
<div id="app">
<input
type="file"
@change="handleFileChange"
/>
<el-button @click="handleUpload">上傳</el-button>
<el-button @click="handlePause" v-if="isPaused">暫停</el-button>
+ <el-button @click="handleResume" v-else>恢復(fù)</el-button>
//...
</div>
</template>
+ async handleResume() {
+ const { uploadedList } = await this.verifyUpload(
+ this.container.file.name,
+ this.container.hash
+ );
+ await this.uploadChunks(uploadedList);
},
async handleUpload() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
this.container.hash = await this.calculateHash(fileChunkList);
+ const { shouldUpload, uploadedList } = 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(uploadedList);
},
// 上傳切片,同時(shí)過濾已上傳的切片
+ async uploadChunks(uploadedList = []) {
const requestList = this.data
+ .filter(({ hash }) => !uploadedList.includes(hash))
.map(({ chunk, hash, index }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
formData.append("fileHash", this.container.hash);
return { formData, index };
})
.map(async ({ formData, index }) =>
this.request({
url: "http://localhost:3000",
data: formData,
onProgress: this.createProgressHandler(this.data[index]),
requestList: this.requestList
})
);
await Promise.all(requestList);
// 之前上傳的切片數(shù)量 + 本次上傳的切片數(shù)量 = 所有切片數(shù)量時(shí)
// 合并切片
+ if (uploadedList.length + requestList.length === this.data.length) {
await this.mergeRequest();
+ }
}
這里給原來上傳切片的函數(shù)新增 uploadedList 參數(shù)紧帕,即上圖中服務(wù)端返回的切片名列表盔然,通過 filter 過濾掉已上傳的切片,并且由于新增了已上傳的部分焕参,所以之前合并接口的觸發(fā)條件做了一些改動
到這里斷點(diǎn)續(xù)傳的功能基本完成了
進(jìn)度條改進(jìn)
雖然實(shí)現(xiàn)了斷點(diǎn)續(xù)傳轻纪,但還需要修改一下進(jìn)度條的顯示規(guī)則,否則在暫停上傳/接收到已上傳切片時(shí)的進(jìn)度條會出現(xiàn)偏差
切片進(jìn)度條
由于在點(diǎn)擊上傳/恢復(fù)上傳時(shí)叠纷,會調(diào)用驗(yàn)證接口返回已上傳的切片刻帚,所以需要將已上傳切片的進(jìn)度變成 100%
async handleUpload() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
this.container.hash = await this.calculateHash(fileChunkList);
const { shouldUpload, uploadedList } = 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: uploadedList.includes(index) ? 100 : 0
}));
await this.uploadChunks(uploadedList);
},
uploadedList 會返回已上傳的切片,在遍歷所有切片時(shí)判斷當(dāng)前切片是否在已上傳列表里即可
文件進(jìn)度條
之前說到文件進(jìn)度條是一個(gè)計(jì)算屬性涩嚣,根據(jù)所有切片的上傳進(jìn)度計(jì)算而來崇众,這就遇到了一個(gè)問題
點(diǎn)擊暫停會取消并清空切片的 xhr 請求,此時(shí)如果已經(jīng)上傳了一部分航厚,就會發(fā)現(xiàn)文件進(jìn)度條有倒退
的現(xiàn)象
當(dāng)點(diǎn)擊恢復(fù)時(shí)顷歌,由于重新創(chuàng)建了 xhr 導(dǎo)致切片進(jìn)度清零,所以總進(jìn)度條就會倒退
解決方案是創(chuàng)建一個(gè)“假”的進(jìn)度條幔睬,這個(gè)假進(jìn)度條基于文件進(jìn)度條眯漩,但只會停止和增加,然后給用戶展示這個(gè)假的進(jìn)度條
這里我們使用 Vue 的監(jiān)聽屬性
data: () => ({
+ fakeUploadPercentage: 0
}),
computed: {
uploadPercentage() {
if (!this.container.file || !this.data.length) return 0;
const loaded = this.data
.map(item => item.size * item.percentage)
.reduce((acc, cur) => acc + cur);
return parseInt((loaded / this.container.file.size).toFixed(2));
}
},
watch: {
+ uploadPercentage(now) {
+ if (now > this.fakeUploadPercentage) {
+ this.fakeUploadPercentage = now;
+ }
}
},
當(dāng) uploadPercentage 即真的文件進(jìn)度條增加時(shí)麻顶,fakeUploadPercentage 也增加赦抖,一旦文件進(jìn)度條后退,假的進(jìn)度條只需停止即可
至此一個(gè)大文件上傳 + 斷點(diǎn)續(xù)傳的解決方案就完成了
總結(jié)
大文件上傳
- 前端上傳大文件時(shí)使用 Blob.prototype.slice 將文件切片辅肾,并發(fā)上傳多個(gè)切片队萤,最后發(fā)送一個(gè)合并的請求通知服務(wù)端合并切片
- 服務(wù)端接收切片并存儲,收到合并請求后使用 fs.appendFileSync 對多個(gè)切片進(jìn)行合并
- 原生 XMLHttpRequest 的 upload.onprogress 對切片上傳進(jìn)度的監(jiān)聽
- 使用 Vue 計(jì)算屬性根據(jù)每個(gè)切片的進(jìn)度算出整個(gè)文件的上傳進(jìn)度
斷點(diǎn)續(xù)傳
- 使用 spart-md5 根據(jù)文件內(nèi)容算出文件 hash
- 通過 hash 可以判斷服務(wù)端是否已經(jīng)上傳該文件矫钓,從而直接提示用戶上傳成功(秒傳)
- 通過 XMLHttpRequest 的 abort 方法暫停切片的上傳
- 上傳前服務(wù)端返回已經(jīng)上傳的切片名要尔,前端跳過這些切片的上傳
源代碼
源代碼增加了一些按鈕的狀態(tài)舍杜,交互更加友好
能看到這里說明大家小編的內(nèi)容還是挺感興趣的,那么點(diǎn)個(gè)關(guān)注再走吧赵辕。感謝大家的閱讀
作者:yeyan1996
鏈接:https://juejin.im/post/5dff8a26