2020-7-20 seo達(dá)人
凡是要知其然知其所以然
文件上傳相信很多朋友都有遇到過,那或許你也遇到過當(dāng)上傳大文件時,上傳時間較長,且經(jīng)常失敗的困擾,并且失敗后,又得重新上傳很是煩人。那我們先了解下失敗的原因吧!
據(jù)我了解大概有以下原因:
服務(wù)器配置:例如在PHP中默認(rèn)的文件上傳大小為8M【post_max_size = 8m】,若你在一個請求體中放入8M以上的內(nèi)容時,便會出現(xiàn)異常
請求超時:當(dāng)你設(shè)置了接口的超時時間為10s,那么上傳大文件時,一個接口響應(yīng)時間超過10s,那么便會被Faild掉。
網(wǎng)絡(luò)波動:這個就屬于不可控因素,也是較常見的問題。
基于以上原因,聰明的人們就想到了,將文件拆分多個小文件,依次上傳,不就解決以上1,2問題嘛,這便是分片上傳。 網(wǎng)絡(luò)波動這個實在不可控,也許一陣大風(fēng)刮來,就斷網(wǎng)了呢。那這樣好了,既然斷網(wǎng)無法控制,那我可以控制只上傳已經(jīng)上傳的文件內(nèi)容,不就好了,這樣大大加快了重新上傳的速度。所以便有了“斷點(diǎn)續(xù)傳”一說。此時,人群中有人插了一嘴,有些文件我已經(jīng)上傳一遍了,為啥還要在上傳,能不能不浪費(fèi)我流量和時間。喔...這個嘛,簡單,每次上傳時判斷下是否存在這個文件,若存在就不重新上傳便可,于是又有了“秒傳”一說。從此這"三兄弟" 便自行CP,統(tǒng)治了整個文件界?!?
注意文中的代碼并非實際代碼,請移步至github查看代碼
https://github.com/pseudo-god...
分片上傳
HTML
原生INPUT樣式較丑,這里通過樣式疊加的方式,放一個Button.
<div class="btns">
<el-button-group>
<el-button :disabled="changeDisabled">
<i class="el-icon-upload2 el-icon--left" size="mini"></i>選擇文件
<input
v-if="!changeDisabled"
type="file"
:multiple="multiple"
class="select-file-input"
:accept="accept"
@change="handleFileChange"
/>
</el-button>
<el-button :disabled="uploadDisabled" @click="handleUpload()"><i class="el-icon-upload el-icon--left" size="mini"></i>上傳</el-button>
<el-button :disabled="pauseDisabled" @click="handlePause"><i class="el-icon-video-pause el-icon--left" size="mini"></i>暫停</el-button>
<el-button :disabled="resumeDisabled" @click="handleResume"><i class="el-icon-video-play el-icon--left" size="mini"></i>恢復(fù)</el-button>
<el-button :disabled="clearDisabled" @click="clearFiles"><i class="el-icon-video-play el-icon--left" size="mini"></i>清空</el-button>
</el-button-group>
<slot
//data 數(shù)據(jù)
var chunkSize = 10 * 1024 * 1024; // 切片大小
var fileIndex = 0; // 當(dāng)前正在被遍歷的文件下標(biāo)
data: () => ({
container: {
files: null
},
tempFilesArr: [], // 存儲files信息
cancels: [], // 存儲要取消的請求
tempThreads: 3,
// 默認(rèn)狀態(tài)
status: Status.wait
}),
一個稍微好看的UI就出來了。
選擇文件
選擇文件過程中,需要對外暴露出幾個鉤子,熟悉elementUi的同學(xué)應(yīng)該很眼熟,這幾個鉤子基本與其一致。onExceed:文件超出個數(shù)限制時的鉤子、beforeUpload:文件上傳之前
fileIndex 這個很重要,因為是多文件上傳,所以定位當(dāng)前正在被上傳的文件就很重要,基本都靠它
handleFileChange(e) {
const files = e.target.files;
if (!files) return;
Object.assign(this.$data, this.$options.data()); // 重置data所有數(shù)據(jù)
fileIndex = 0; // 重置文件下標(biāo)
this.container.files = files;
// 判斷文件選擇的個數(shù)
if (this.limit && this.container.files.length > this.limit) {
this.onExceed && this.onExceed(files);
return;
}
// 因filelist不可編輯,故拷貝filelist 對象
var index = 0; // 所選文件的下標(biāo),主要用于剔除文件后,原文件list與臨時文件list不對應(yīng)的情況
for (const key in this.container.files) {
if (this.container.files.hasOwnProperty(key)) {
const file = this.container.files[key];
if (this.beforeUpload) {
const before = this.beforeUpload(file);
if (before) {
this.pushTempFile(file, index);
}
}
if (!this.beforeUpload) {
this.pushTempFile(file, index);
}
index++;
}
}
},
// 存入 tempFilesArr,為了上面的鉤子,所以將代碼做了拆分
pushTempFile(file, index) {
// 額外的初始值
const obj = {
status: fileStatus.wait,
chunkList: [],
uploadProgress: 0,
hashProgress: 0,
index
};
for (const k in file) {
obj[k] = file[k];
}
console.log('pushTempFile -> obj', obj);
this.tempFilesArr.push(obj);
}
分片上傳
創(chuàng)建切片,循環(huán)分解文件即可
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;
}
循環(huán)創(chuàng)建切片,既然咱們做的是多文件,所以這里就有循環(huán)去處理,依次創(chuàng)建文件切片,及切片的上傳。
async handleUpload(resume) {
if (!this.container.files) return;
this.status = Status.uploading;
const filesArr = this.container.files;
var tempFilesArr = this.tempFilesArr;
for (let i = 0; i < tempFilesArr.length; i++) {
fileIndex = i;
//創(chuàng)建切片
const fileChunkList = this.createFileChunk(
filesArr[tempFilesArr[i].index]
);
tempFilesArr[i].fileHash ='xxxx'; // 先不用看這個,后面會講,占個位置
tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
fileHash: tempFilesArr[i].hash,
fileName: tempFilesArr[i].name,
index,
hash: tempFilesArr[i].hash + '-' + index,
chunk: file,
size: file.size,
uploaded: false,
progress: 0, // 每個塊的上傳進(jìn)度
status: 'wait' // 上傳狀態(tài),用作進(jìn)度狀態(tài)顯示
}));
//上傳切片
await this.uploadChunks(this.tempFilesArr[i]);
}
}
上傳切片,這個里需要考慮的問題較多,也算是核心吧,uploadChunks方法只負(fù)責(zé)構(gòu)造傳遞給后端的數(shù)據(jù),核心上傳功能放到sendRequest方法中
async uploadChunks(data) {
var chunkData = data.chunkList;
const requestDataList = chunkData
.map(({ fileHash, chunk, fileName, index }) => {
const formData = new FormData();
formData.append('md5', fileHash);
formData.append('file', chunk);
formData.append('fileName', index); // 文件名使用切片的下標(biāo)
return { formData, index, fileName };
});
try {
await this.sendRequest(requestDataList, chunkData);
} catch (error) {
// 上傳有被reject的
this.$message.error('親 上傳失敗了,考慮重試下呦' + error);
return;
}
// 合并切片
const isUpload = chunkData.some(item => item.uploaded === false);
console.log('created -> isUpload', isUpload);
if (isUpload) {
alert('存在失敗的切片');
} else {
// 執(zhí)行合并
await this.mergeRequest(data);
}
}
sendReques。上傳這是最重要的地方,也是容易失敗的地方,假設(shè)有10個分片,那我們?nèi)羰侵苯影l(fā)10個請求的話,很容易達(dá)到瀏覽器的瓶頸,所以需要對請求進(jìn)行并發(fā)處理。
并發(fā)處理:這里我使用for循環(huán)控制并發(fā)的初始并發(fā)數(shù),然后在 handler 函數(shù)里調(diào)用自己,這樣就控制了并發(fā)。在handler中,通過數(shù)組API.shift模擬隊列的效果,來上傳切片。
重試: retryArr 數(shù)組存儲每個切片文件請求的重試次數(shù),做累加。比如[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次。為保證能與文件做對應(yīng),const index = formInfo.index; 我們直接從數(shù)據(jù)中拿之前定義好的index。 若失敗后,將失敗的請求重新加入隊列即可。
關(guān)于并發(fā)及重試我寫了一個小Demo,若不理解可以自己在研究下,文件地址:https://github.com/pseudo-god... , 重試代碼好像被我弄丟了,大家要是有需求,我再補(bǔ)吧!
// 并發(fā)處理
sendRequest(forms, chunkData) {
var finished = 0;
const total = forms.length;
const that = this;
const retryArr = []; // 數(shù)組存儲每個文件hash請求的重試次數(shù),做累加 比如[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次
return new Promise((resolve, reject) => {
const handler = () => {
if (forms.length) {
// 出棧
const formInfo = forms.shift();
const formData = formInfo.formData;
const index = formInfo.index;
instance.post('fileChunk', formData, {
onUploadProgress: that.createProgresshandler(chunkData[index]),
cancelToken: new CancelToken(c => this.cancels.push(c)),
timeout: 0
}).then(res => {
console.log('handler -> res', res);
// 更改狀態(tài)
chunkData[index].uploaded = true;
chunkData[index].status = 'success';
finished++;
handler();
})
.catch(e => {
// 若暫停,則禁止重試
if (this.status === Status.pause) return;
if (typeof retryArr[index] !== 'number') {
retryArr[index] = 0;
}
// 更新狀態(tài)
chunkData[index].status = 'warning';
// 累加錯誤次數(shù)
retryArr[index]++;
// 重試3次
if (retryArr[index] >= this.chunkRetry) {
return reject('重試失敗', retryArr);
}
this.tempThreads++; // 釋放當(dāng)前占用的通道
// 將失敗的重新加入隊列
forms.push(formInfo);
handler();
});
}
if (finished >= total) {
resolve('done');
}
};
// 控制并發(fā)
for (let i = 0; i < this.tempThreads; i++) {
handler();
}
});
}
切片的上傳進(jìn)度,通過axios的onUploadProgress事件,結(jié)合createProgresshandler方法進(jìn)行維護(hù)
// 切片上傳進(jìn)度
createProgresshandler(item) {
return p => {
item.progress = parseInt(String((p.loaded / p.total) * 100));
this.fileProgress();
};
}
Hash計算
其實就是算一個文件的MD5值,MD5在整個項目中用到的地方也就幾點(diǎn)。
秒傳,需要通過MD5值判斷文件是否已存在。
續(xù)傳:需要用到MD5作為key值,當(dāng)唯一值使用。
本項目主要使用worker處理,性能及速度都會有很大提升.
由于是多文件,所以HASH的計算進(jìn)度也要體現(xiàn)在每個文件上,所以這里使用全局變量fileIndex來定位當(dāng)前正在被上傳的文件
執(zhí)行計算hash
正在上傳文件
// 生成文件 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);
}
};
});
}
因使用worker,所以我們不能直接使用NPM包方式使用MD5。需要單獨(dú)去下載spark-md5.js文件,并引入
//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
});
loadNext(count);
}
};
};
loadNext(0);
};
文件合并
當(dāng)我們的切片全部上傳完畢后,就需要進(jìn)行文件的合并,這里我們只需要請求接口即可
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('上傳成功');
});
}
Done: 至此一個分片上傳的功能便已完成
斷點(diǎn)續(xù)傳
顧名思義,就是從那斷的就從那開始,明確思路就很簡單了。一般有2種方式,一種為服務(wù)器端返回,告知我從那開始,還有一種是瀏覽器端自行處理。2種方案各有優(yōu)缺點(diǎn)。本項目使用第二種。
思路:已文件HASH為key值,每個切片上傳成功后,記錄下來便可。若需要續(xù)傳時,直接跳過記錄中已存在的便可。本項目將使用Localstorage進(jìn)行存儲,這里我已提前封裝好addChunkStorage、getChunkStorage方法。
存儲在Stroage的數(shù)據(jù)
緩存處理
在切片上傳的axios成功回調(diào)中,存儲已上傳成功的切片
instance.post('fileChunk', formData, )
.then(res => {
// 存儲已上傳的切片下標(biāo)
+ this.addChunkStorage(chunkData[index].fileHash, index);
handler();
})
在切片上傳前,先看下localstorage中是否存在已上傳的切片,并修改uploaded
async handleUpload(resume) {
+ const getChunkStorage = this.getChunkStorage(tempFilesArr[i].hash);
tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
+ uploaded: getChunkStorage && getChunkStorage.includes(index), // 標(biāo)識:是否已完成上傳
+ progress: getChunkStorage && getChunkStorage.includes(index) ? 100 : 0,
+ status: getChunkStorage && getChunkStorage.includes(index)? 'success'
+ : 'wait' // 上傳狀態(tài),用作進(jìn)度狀態(tài)顯示
}));
}
構(gòu)造切片數(shù)據(jù)時,過濾掉uploaded為true的
async uploadChunks(data) {
var chunkData = data.chunkList;
const requestDataList = chunkData
+ .filter(({ uploaded }) => !uploaded)
.map(({ fileHash, chunk, fileName, index }) => {
const formData = new FormData();
formData.append('md5', fileHash);
formData.append('file', chunk);
formData.append('fileName', index); // 文件名使用切片的下標(biāo)
return { formData, index, fileName };
})
}
垃圾文件清理
隨著上傳文件的增多,相應(yīng)的垃圾文件也會增多,比如有些時候上傳一半就不再繼續(xù),或上傳失敗,碎片文件就會增多。解決方案我目前想了2種
前端在localstorage設(shè)置緩存時間,超過時間就發(fā)送請求通知后端清理碎片文件,同時前端也要清理緩存。
前后端都約定好,每個緩存從生成開始,只能存儲12小時,12小時后自動清理
以上2中方案似乎都有點(diǎn)問題,極有可能造成前后端因時間差,引發(fā)切片上傳異常的問題,后面想到合適的解決方案再來更新吧。
Done: 續(xù)傳到這里也就完成了。
秒傳
這算是最簡單的,只是聽起來很厲害的樣子。原理:計算整個文件的HASH,在執(zhí)行上傳操作前,向服務(wù)端發(fā)送請求,傳遞MD5值,后端進(jìn)行文件檢索。若服務(wù)器中已存在該文件,便不進(jìn)行后續(xù)的任何操作,上傳也便直接結(jié)束。大家一看就明白
async handleUpload(resume) {
if (!this.container.files) return;
const filesArr = this.container.files;
var tempFilesArr = this.tempFilesArr;
for (let i = 0; i < tempFilesArr.length; i++) {
const fileChunkList = this.createFileChunk(
filesArr[tempFilesArr[i].index]
);
// hash校驗,是否為秒傳
+ tempFilesArr[i].hash = await this.calculateHash(fileChunkList);
+ const verifyRes = await this.verifyUpload(
+ tempFilesArr[i].name,
+ tempFilesArr[i].hash
+ );
+ if (verifyRes.data.presence) {
+ tempFilesArr[i].status = fileStatus.secondPass;
+ tempFilesArr[i].uploadProgress = 100;
+ } else {
console.log('開始上傳切片文件----》', tempFilesArr[i].name);
await this.uploadChunks(this.tempFilesArr[i]);
}
}
}
// 文件上傳之前的校驗: 校驗文件是否已存在
verifyUpload(fileName, fileHash) {
return new Promise(resolve => {
const obj = {
md5: fileHash,
fileName,
...this.uploadArguments //傳遞其他參數(shù)
};
instance
.post('fileChunk/presence', obj)
.then(res => {
resolve(res.data);
})
.catch(err => {
console.log('verifyUpload -> err', err);
});
});
}
Done: 秒傳到這里也就完成了。
后端處理
文章好像有點(diǎn)長了,具體代碼邏輯就先不貼了,除非有人留言要求,嘻嘻,有時間再更新
Node版
請前往 https://github.com/pseudo-god... 查看
JAVA版
下周應(yīng)該會更新處理
PHP版
1年多沒寫PHP了,抽空我會慢慢補(bǔ)上來
待完善
切片的大?。哼@個后面會做出動態(tài)計算的。需要根據(jù)當(dāng)前所上傳文件的大小,自動計算合適的切片大小。避免出現(xiàn)切片過多的情況。
文件追加:目前上傳文件過程中,不能繼續(xù)選擇文件加入隊列。(這個沒想好應(yīng)該怎么處理。)
更新記錄
組件已經(jīng)運(yùn)行一段時間了,期間也測試出幾個問題,本來以為沒BUG的,看起來BUG都挺嚴(yán)重
BUG-1:當(dāng)同時上傳多個內(nèi)容相同但是文件名稱不同的文件時,出現(xiàn)上傳失敗的問題。
預(yù)期結(jié)果:第一個上傳成功后,后面相同的問文件應(yīng)該直接秒傳
實際結(jié)果:第一個上傳成功后,其余相同的文件都失敗,錯誤信息,塊數(shù)不對。
原因:當(dāng)?shù)谝粋€文件塊上傳完畢后,便立即進(jìn)行了下一個文件的循環(huán),導(dǎo)致無法及時獲取文件是否已秒傳的狀態(tài),從而導(dǎo)致失敗。
解決方案:在當(dāng)前文件分片上傳完畢并且請求合并接口完畢后,再進(jìn)行下一次循環(huán)。
將子方法都改為同步方式,mergeRequest 和 uploadChunks 方法
BUG-2: 當(dāng)每次選擇相同的文件并觸發(fā)beforeUpload方法時,若第二次也選擇了相同的文件,beforeUpload方法失效,從而導(dǎo)致整個流程失效。
原因:之前每次選擇文件時,沒有清空上次所選input文件的數(shù)據(jù),相同數(shù)據(jù)的情況下,是不會觸發(fā)input的change事件。
解決方案:每次點(diǎn)擊input時,清空數(shù)據(jù)即可。我順帶優(yōu)化了下其他的代碼,具體看提交記錄吧。
<input
v-if="!changeDisabled"
type="file"
:multiple="multiple"
class="select-file-input"
:accept="accept"
+ οnclick="f.outerHTML=f.outerHTML"
@change="handleFileChange"/>
重寫了暫停和恢復(fù)的功能,實際上,主要是增加了暫停和恢復(fù)的狀態(tài)
之前的處理邏輯太簡單粗暴,存在諸多問題?,F(xiàn)在將狀態(tài)定位在每一個文件之上,這樣恢復(fù)上傳時,直接跳過即可
封裝組件
寫了一大堆,其實以上代碼你直接復(fù)制也無法使用,這里我將此封裝了一個組件。大家可以去github下載文件,里面有使用案例 ,若有用記得隨手給個star,謝謝!
偷個懶,具體封裝組件的代碼就不列出來了,大家直接去下載文件查看,若有不明白的,可留言。
組件文檔
Attribute
參數(shù) 類型 說明 默認(rèn) 備注
headers Object 設(shè)置請求頭
before-upload Function 上傳文件前的鉤子,返回false則停止上傳
accept String 接受上傳的文件類型
upload-arguments Object 上傳文件時攜帶的參數(shù)
with-credentials Boolean 是否傳遞Cookie false
limit Number 最大允許上傳個數(shù) 0 0為不限制
on-exceed Function 文件超出個數(shù)限制時的鉤子
multiple Boolean 是否為多選模式 true
base-url String 由于本組件為內(nèi)置的AXIOS,若你需要走代理,可以直接在這里配置你的基礎(chǔ)路徑
chunk-size Number 每個切片的大小 10M
threads Number 請求的并發(fā)數(shù) 3 并發(fā)數(shù)越高,對服務(wù)器的性能要求越高,盡可能用默認(rèn)值即可
chunk-retry Number 錯誤重試次數(shù) 3 分片請求的錯誤重試次數(shù)
Slot
方法名 說明 參數(shù) 備注
header 按鈕區(qū)域 無
tip 提示說明文字 無
后端接口文檔:按文檔實現(xiàn)即可
藍(lán)藍(lán)設(shè)計( www.88yangsc.com )是一家專注而深入的界面設(shè)計公司,為期望卓越的國內(nèi)外企業(yè)提供卓越的UI界面設(shè)計、BS界面設(shè)計 、 cs界面設(shè)計 、 ipad界面設(shè)計 、 包裝設(shè)計 、 圖標(biāo)定制 、 用戶體驗 、交互設(shè)計、 網(wǎng)站建設(shè) 、平面設(shè)計服務(wù)
藍(lán)藍(lán)設(shè)計的小編 http://www.88yangsc.com