前幾天看到一個文章,感觸很深javascript
做者從0實現了大文件的切片上傳,斷點續傳,秒傳,暫停等功能,深刻淺出的把這個面試題進行了全面的剖析html
彩虹屁很少吹,我決定蹭蹭熱點,彔彔視頻,把做者完整寫代碼的過程加進去,而且接着這篇文章寫,因此請看完上面的文章後再食用,我作了一些擴展以下前端
hash
耗時的問題,不只能夠經過web-workder
,還能夠參考React
的FFiber
架構,經過requestIdleCallback
來利用瀏覽器的空閒時間計算,也不會卡死主線程hash
的計算,是爲了判斷文件是否存在,進而實現秒傳的功能,因此咱們能夠參考布隆過濾
器的理念, 犧牲一點點的識別率來換取時間,好比咱們能夠抽樣算hash
web-workder
讓hash
計算不卡頓主線程,可是大文件因爲切片過多,過多的HTTP
連接過去,也會把瀏覽器打掛 (我試了4個G的,直接卡死了), 咱們能夠經過控制異步請求的併發數
來解決,我記得這也是頭條的一個面試題TCP
協議的慢啓動
策略, 設置一個初始大小,根據上傳任務完成的時候,來動態調整下一個切片的大小, 確保文件切片的大小和當前網速匹配已經存在的秒傳的切片就是綠的,正在上傳的是藍色的,併發量是4,廢話很少說,咱們一塊兒代碼開花java
其實就是time-slice
概念,React
中Fiber
架構的核心理念,利用瀏覽器的空閒時間,計算大的diff過程,中途又任何的高優先級任務,好比動畫和輸入,都會中斷diff任務, 雖然整個計算量沒有減少,可是大大提升了用戶的交互體驗node
這多是最通俗的 React Fiber(時間分片) 打開方式 react
window.requestIdleCallback()
方法將在瀏覽器的空閒時段內調用的函數排隊。這使開發者可以在主事件循環上執行後臺和低優先級工做 requestIdelCallback
執行的方法,會傳遞一個deadline
參數,可以知道當前幀的剩餘時間,用法以下ios
requestIdelCallback(myNonEssentialWork);
function myNonEssentialWork (deadline) {
// deadline.timeRemaining()能夠獲取到當前幀剩餘時間
// 當前幀還有時間 而且任務隊列不爲空
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
doWorkIfNeeded();
}
if (tasks.length > 0){
requestIdleCallback(myNonEssentialWork);
}
}
複製代碼
deadline的結構以下git
interface Dealine {
didTimeout: boolean // 表示任務執行是否超過約定時間
timeRemaining(): DOMHighResTimeStamp // 任務可供執行的剩餘時間
}
複製代碼
該圖中的兩個幀,在每一幀內部,TASK
和redering
只花費了一部分時間,並無佔據整個幀,那麼這個時候,如圖中idle period
的部分就是空閒時間,而每一幀中的空閒時間,根據該幀中處理事情的多少,複雜度等,消耗不等,因此空閒時間也不等。github
而對於每個deadline.timeRemaining()
的返回值,就是如圖中,Idle Callback
到所在幀結尾的時間(ms級)
咱們接着以前文章的代碼,改造一下calculateHash
async calculateHashIdle(chunks) {
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer();
let count = 0;
// 根據文件內容追加計算
const appendToSpark = async file => {
return new Promise(resolve => {
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = e => {
spark.append(e.target.result);
resolve();
};
});
};
const workLoop = async deadline => {
// 有任務,而且當前幀還沒結束
while (count < chunks.length && deadline.timeRemaining() > 1) {
await appendToSpark(chunks[count].file);
count++;
// 沒有了 計算完畢
if (count < chunks.length) {
// 計算中
this.hashProgress = Number(
((100 * count) / chunks.length).toFixed(2)
);
// console.log(this.hashProgress)
} else {
// 計算完畢
this.hashProgress = 100;
resolve(spark.end());
}
}
window.requestIdleCallback(workLoop);
};
window.requestIdleCallback(workLoop);
});
},
複製代碼
計算過程當中,頁面放個輸入框,輸入無壓力,時間切片的威力
上圖是React1
5和
Fiber
架構的對比,能夠看出下圖任務量沒邊,可是變得零散了,不混卡頓主線程
計算文件md5
值的做用,無非就是爲了斷定文件是否存在,咱們能夠考慮設計一個抽樣的hash
,犧牲一些命中率的同時,提高效率,設計思路以下
md5
,稱之爲影分身Hash
hash
的結果,就是文件存在,有小几率誤判,可是若是不存在,是100%準的的 ,和布隆過濾器的思路有些類似, 能夠考慮兩個hash
配合使用抽樣md5: 1028.006103515625ms
全量md5: 21745.13916015625ms
複製代碼
async calculateHashSample() {
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const file = this.container.file;
// 文件大小
const size = this.container.file.size;
let offset = 2 * 1024 * 1024;
let chunks = [file.slice(0, offset)];
// 前面100K
let cur = offset;
while (cur < size) {
// 最後一塊所有加進來
if (cur + offset >= size) {
chunks.push(file.slice(cur, cur + offset));
} else {
// 中間的 前中後去兩個字節
const mid = cur + offset / 2;
const end = cur + offset;
chunks.push(file.slice(cur, cur + 2));
chunks.push(file.slice(mid, mid + 2));
chunks.push(file.slice(end - 2, end));
}
// 前取兩個字節
cur += offset;
}
// 拼接
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = e => {
spark.append(e.target.result);
resolve(spark.end());
};
});
}
複製代碼
大文件hash
計算後,一次發幾百個http
請求,計算哈希沒卡,結果TCP
創建的過程就把瀏覽器弄死了,並且我記得自己異步請求併發數的控制,自己就是頭條的一個面試題
思路其實也不難,就是咱們把異步請求放在一個隊列裏,好比並發數是3,就先同時發起3個請求,而後有請求結束了,再發起下一個請求便可, 思路清楚,代碼也就呼之欲出了
咱們經過併發數max來管理併發數,發起一個請求max--
,結束一個請求max++
便可
+async sendRequest(forms, max=4) {
+ return new Promise(resolve => {
+ const len = forms.length;
+ let idx = 0;
+ let counter = 0;
+ const start = async ()=> {
+ // 有請求,有通道
+ while (idx < len && max > 0) {
+ max--; // 佔用通道
+ console.log(idx, "start");
+ const form = forms[idx].form;
+ const index = forms[idx].index;
+ idx++
+ request({
+ url: '/upload',
+ data: form,
+ onProgress: this.createProgresshandler(this.chunks[index]),
+ requestList: this.requestList
+ }).then(() => {
+ max++; // 釋放通道
+ counter++;
+ if (counter === len) {
+ resolve();
+ } else {
+ start();
+ }
+ });
+ }
+ }
+ start();
+ });
+}
async uploadChunks(uploadedList = []) {
// 這裏一塊兒上傳,遇見大文件就是災難
// 沒被hash計算打到,被一次性的tcp連接把瀏覽器稿掛了
// 異步併發控制策略,我記得這個也是頭條一個面試題
// 好比並髮量控制成4
const list = this.chunks
.filter(chunk => uploadedList.indexOf(chunk.hash) == -1)
.map(({ chunk, hash, index }, i) => {
const form = new FormData();
form.append("chunk", chunk);
form.append("hash", hash);
form.append("filename", this.container.file.name);
form.append("fileHash", this.container.hash);
return { form, index };
})
- .map(({ form, index }) =>
- request({
- url: "/upload",
- data: form,
- onProgress: this.createProgresshandler(this.chunks[index]),
- requestList: this.requestList
- })
- );
- // 直接全量併發
- await Promise.all(list);
// 控制併發
+ const ret = await this.sendRequest(list,4)
if (uploadedList.length + list.length === this.chunks.length) {
// 上傳和已經存在之和 等於所有的再合併
await this.mergeRequest();
}
},
複製代碼
話說字節跳動另一個面試題我也作出來的,不知道能不能經過他們的一面
TCP擁塞控制的問題 其實就是根據當前網絡狀況,動態調整切片的大小
chunk
中帶上size
值,不過進度條數量不肯定了,修改createFileChunk
, 請求加上時間統計)handleUpload1
函數async handleUpload1(){
// @todo數據縮放的比率 能夠更平緩
// @todo 併發+慢啓動
// 慢啓動上傳邏輯
const file = this.container.file
if (!file) return;
this.status = Status.uploading;
const fileSize = file.size
let offset = 1024*1024
let cur = 0
let count =0
this.container.hash = await this.calculateHashSample();
while(cur<fileSize){
// 切割offfset大小
const chunk = file.slice(cur, cur+offset)
cur+=offset
const chunkName = this.container.hash + "-" + count;
const form = new FormData();
form.append("chunk", chunk);
form.append("hash", chunkName);
form.append("filename", file.name);
form.append("fileHash", this.container.hash);
form.append("size", chunk.size);
let start = new Date().getTime()
await request({ url: '/upload',data: form })
const now = new Date().getTime()
const time = ((now -start)/1000).toFixed(4)
let rate = time/30
// 速率有最大2和最小0.5
if(rate<0.5) rate=0.5
if(rate>2) rate=2
// 新的切片大小等比變化
console.log(`切片${count}大小是${this.format(offset)},耗時${time}秒,是30秒的${rate}倍,修正大小爲${this.format(offset/rate)}`)
// 動態調整offset
offset = parseInt(offset/rate)
// if(time)
count++
}
}
複製代碼
調整下slow 3G網速 看下效果
切片0大小是1024.00KB,耗時13.2770秒,是30秒的0.5倍,修正大小爲2.00MB
切片1大小是2.00MB,耗時25.4130秒,是30秒的0.8471倍,修正大小爲2.36MB
切片2大小是2.36MB,耗時14.1260秒,是30秒的0.5倍,修正大小爲4.72MB
複製代碼
搞定
這就屬於小優化了,方便咱們查看存在的文件區塊和併發數,靈感來自於硬盤掃描
<div class="cube-container" :style="{width:cubeWidth+'px'}">
<div class="cube" v-for="chunk in chunks" :key="chunk.hash">
<div :class="{ 'uploading':chunk.progress>0&&chunk.progress<100, 'success':chunk.progress==100 }" :style="{height:chunk.progress+'%'}" >
<i v-if="chunk.progress>0&&chunk.progress<100" class="el-icon-loading" style="color:#F56C6C;"></i>
</div>
</div>
</div>
複製代碼
.cube-container
width 100px
overflow hidden
.cube
width 14px
height 14px
line-height 12px;
border 1px solid black
background #eee
float left
>.success
background #67C23A
>.uploading
background #409EFF
複製代碼
// 方塊進度條儘量的正方形 切片的數量平方根向上取整 控制進度條的寬度
cubeWidth(){
return Math.ceil(Math.sqrt(this.chunks.length))*16
},
複製代碼
效果還能夠 再看一遍🐶
[1,0,2]
,就是第0個文件切片報錯1次,第2個報錯2次reject
首前後端模擬報錯
if(Math.random()<0.5){
// 機率報錯
console.log('機率報錯了')
res.statusCode=500
res.end()
return
}
複製代碼
async sendRequest(urls, max=4) {
- return new Promise(resolve => {
+ return new Promise((resolve,reject) => {
const len = urls.length;
let idx = 0;
let counter = 0;
+ const retryArr = []
const start = async ()=> {
// 有請求,有通道
- while (idx < len && max > 0) {
+ while (counter < len && max > 0) {
max--; // 佔用通道
console.log(idx, "start");
- const form = urls[idx].form;
- const index = urls[idx].index;
- idx++
+ // 任務不能僅僅累加獲取,而是要根據狀態
+ // wait和error的能夠發出請求 方便重試
+ const i = urls.findIndex(v=>v.status==Status.wait || v.status==Status.error )// 等待或者error
+ urls[i].status = Status.uploading
+ const form = urls[i].form;
+ const index = urls[i].index;
+ if(typeof retryArr[index]=='number'){
+ console.log(index,'開始重試')
+ }
request({
url: '/upload',
data: form,
onProgress: this.createProgresshandler(this.chunks[index]),
requestList: this.requestList
}).then(() => {
+ urls[i].status = Status.done
max++; // 釋放通道
counter++;
+ urls[counter].done=true
if (counter === len) {
resolve();
} else {
start();
}
- });
+ }).catch(()=>{
+ urls[i].status = Status.error
+ if(typeof retryArr[index]!=='number'){
+ retryArr[index] = 0
+ }
+ // 次數累加
+ retryArr[index]++
+ // 一個請求報錯3次的
+ if(retryArr[index]>=2){
+ return reject()
+ }
+ console.log(index, retryArr[index],'次報錯')
+ // 3次報錯之內的 重啓
+ this.chunks[index].progress = -1 // 報錯的進度條
+ max++; // 釋放當前佔用的通道,可是counter不累加
+
+ start()
+ })
}
}
start();
}
複製代碼
如圖所示,報錯後會區塊變紅,可是會重試
若是不少人傳了一半就離開了,這些切片存在就沒意義了,能夠考慮按期清理,固然 ,咱們可使用node-schedule來管理定時任務 好比咱們天天掃一次target
,若是文件的修改時間是一個月之前了,就直接刪除把
// 爲了方便測試,我改爲每5秒掃一次, 過時1鐘的刪除作演示
const fse = require('fs-extra')
const path = require('path')
const schedule = require('node-schedule')
// 空目錄刪除
function remove(file,stats){
const now = new Date().getTime()
const offset = now - stats.ctimeMs
if(offset>1000*60){
// 大於60秒的碎片
console.log(file,'過時了,浪費空間的玩意,刪除')
fse.unlinkSync(file)
}
}
async function scan(dir,callback){
const files = fse.readdirSync(dir)
files.forEach(filename=>{
const fileDir = path.resolve(dir,filename)
const stats = fse.statSync(fileDir)
if(stats.isDirectory()){
return scan(fileDir,remove)
}
if(callback){
callback(fileDir,stats)
}
})
}
// * * * * * *
// ┬ ┬ ┬ ┬ ┬ ┬
// │ │ │ │ │ │
// │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
// │ │ │ │ └───── month (1 - 12)
// │ │ │ └────────── day of month (1 - 31)
// │ │ └─────────────── hour (0 - 23)
// │ └──────────────────── minute (0 - 59)
// └───────────────────────── second (0 - 59, OPTIONAL)
let start = function(UPLOAD_DIR){
// 每5秒
schedule.scheduleJob("*/5 * * * * *",function(){
console.log('開始掃描')
scan(UPLOAD_DIR)
})
}
exports.start = start
複製代碼
開始掃描
/upload/target/625c.../625c...-0 過時了,刪除
/upload/target/625c.../625c...-1 過時了,刪除
/upload/target/625c.../625c...-10 過時了,刪除
/upload/target/625c.../625c...-11 過時了,刪除
/upload/target/625c.../625c...-12 過時了,刪除
複製代碼
留幾個思考題,下次寫文章再實現 方便繼續蹭熱度
前半段抄襲了@yeyan1996
的代碼,後面代碼主要爲了講明思路,實現的比較粗糙,求輕噴 github.com/shengxinjin…
有些圖也是我直接從下面鏈接中copy的