前言
最近需要做一個瀏覽器的, 支援大體積檔案上傳且要支援斷點續傳的上傳元件, 本來以為很容易的事情, 結果碰到了一個有意思的問題:
迴圈執行連續的非同步任務, 且後一個任務需要等待前一個任務的執行狀態
這麼說可能有點空泛, 以我做的元件舉例:
這個元件本意是為了上傳大體積視訊, 和支援斷點續傳, 因為動輒幾個G的視訊不可能直接把檔案讀進記憶體, 只能分片傳送(考慮到實際網路狀態, 每次傳送大小定在了4MB), 而且這麼做也符合斷點續傳的思路.
元件工作流程如下:
- 選定上傳檔案後, 從H5原生upload元件裡取得檔案的blob物件 (同步)
- 通過blob物件的slice方法把檔案切片 (同步)
- 新建一個Filereader物件, 通過Filereader的readAsArrayBuffer方法讀取步驟2中生成的slice (非同步)
- 如果步驟3的buffer讀取成功(通過監控Filereader的onload事件), 則ajax傳送步驟3中的buffer (非同步)
- 如果ajax傳送成功, 且伺服器儲存完成, 會向客戶端發回一個成功狀態碼, 如果ajax的response中存在這個狀態碼, 則進行下一次切片傳送 (非同步)
從元件工作流程可以發現, 3,4,5中的連續非同步任務, 必須要按順序進行, 且每一步任務間存在相互依賴, 最後還要對這些步驟進行多次迴圈.
如果只是處理單次的連續非同步任務, 通過promise鏈式呼叫即可, 但是要迴圈執行這樣的連續非同步任務讓我想了很久.
後來google了很久也沒發現解決方案, 無奈下閉門造車了2天, 想出了3套方案, 權當拋磚引玉, 希望各位給出更好建議
3套方案的核心思想相同, 類似觀察者模式, 來控制迴圈的進行, 區別在於迴圈的實現不同, 實際上這3套方案也是我自我否定的過程, 不斷思考更好的方法, 整個元件程式碼略長, 在此只挑出問題相關部分, 且省略錯誤處理部分
方案1
依然以上傳元件舉例
//迴圈狀態標記,0為初始狀態,1為正常,2為出錯
let status = 0;
/* 新建Filereader,讀取檔案切片,返回一個promise
* 把讀取成功的arraybuffer通過reslove傳出
*/
const createReader = ()=> {
return new Promise ((reslove, reject)=> {
let reader = new Filereader();
...
reader.onload = ()=> {
reslove(reader.result)
}
reader.onerror = ()=> reject()
})
}
// ajax傳送createReader方法讀取到的Buff
const createXhr = ()=> {
const xhr= new XMLHttpRequest();
return new Promise ((reslove, reject)=> {
...
xhr.onreadystatechange= ()=> {
...
//如果readyState == 4,status == 200且伺服器的狀態碼存在,更改全域性標記為1
status = 1;
reslove()
}
})
}
//每一輪迴圈開始前都檢查一次全域性狀態標記
const checkStatus = ()=> {
...
if (status == 1) {
loop()
}
}
//迴圈過程的鏈式呼叫
const loop = ()=> {
createReader().then(()=> createXhr()).then(()=> checkStatus());
}
複製程式碼
方案1是基於初見問題的'想當然'解決方法, 碰到非同步任務就promise, 這樣的迴圈長鏈呼叫, 寫法不優雅, 且錯誤除錯異常麻煩, 更爆炸的是因為閉包問題, 在迴圈執行中這些記憶體難以回收, 記憶體消耗急劇增加, 只能等待迴圈執行完成
方案2
徹底引入觀察者模式, 構造一個簡單的EventEmitter, 通過event.on, event.emit的形式完成迴圈
//模仿node.js的EventEmitter
class EventEmitter {
constructor() {
this.handler = {};
}
on(eventName, callback) {
if (!this.handles){
this.handles = {};
}
if (!this.handles[eventName]) {
this.handles[eventName] = [];
}
this.handles[eventName].push(callback);
}
emit(eventName,...arg) {
if (this.handles[eventName]) {
for (var i=0;i<this.handles[eventName].length;i++) {
this.handles[eventName][i](...arg);
}
}
}
}
let ev= new EventEmitter();
...
//監聽createReader事件,如果讀取buffer成功就觸發toajax事件來上傳切片
ev.on('createReader', ()=> {
let reader = new Filereader();
...
reader.onload = ()=> {
ev.emit('toajax')
}
})
//監聽toajax事件,如果上傳成功,就觸發createReader事件開始讀取下一切片
ev.on('toajax', ()=> {
let xhr= new XMLHttpRequest();
...
xhr.onreadystatechange = ()=> {
//如果readyState == 4,status == 200且伺服器的狀態碼存在
ev.emit('createReader')
}
})
複製程式碼
方案2徹底貫徹'事件', 程式碼語義更自然, 錯誤除錯也比方案1更為簡單, 但記憶體洩漏問題依然存在
方案3
方案3, 迴歸方案1的狀態管理方式, 但是通過setInterval方法來實現迴圈.
//全域性狀態標記
let status = 0;
//讀取切片
const createReader = ()=> {
let reader = new Filereader();
...
reader.onload = ()=>status = 1
}
//上傳切片
const createXhr = ()=> {
let xhr= new XMLHttpRequest();
...
xhr.onreadystatechange = ()=> {
...
//如果readyState == 4,status == 200且伺服器的狀態碼存在
status = 2
}
}
/* 設定一個間隔時間極短的計時器,根據status決定下一步的任務,
* 上傳完成後定時器自動清除自己
* 另外有判斷檔案是否上傳完成的方法,這裡就不寫了
*/
let timer = setInterval(()=> {
if (status == 2) {
createReader();
} else if (status == 1) {
createXhr();
} else if (status == 3) {
clearInterval(timer);
}
},10)
複製程式碼
不可否認, 方案3看上去很low, 如果追求極致的執行效率, 方案3無疑是最蠢的辦法, 但是方案三相當於把非同步任務轉化為了同步任務, 語義簡潔, 且沒有上面2種方法的記憶體洩漏問題.
方案3本質上是把while (true)改寫成了setInterval, 因為while true會阻塞執行緒, 各種非同步事件的回撥也會被一同阻塞, 所以選擇了setInterval
總結
當時還嘗試過使用Object.defineProperty方法給status 綁一個set方法, 通過每次給status set新值的時候來判斷迴圈, 但是發現這樣做依然像是鏈式呼叫, 一樣存在記憶體洩漏問題, 這裡就不寫了.
說實話, 這3個方案感覺都有很大缺陷, 甚至可以說粗淺, 本人入坑前端2個月, 眼界有限無可避免, google無門後, 想到社群來求助, 希望老哥們提供更好的思路.
最後掛上文中提到的上傳外掛, 因為感覺還有缺陷就沒封裝, 只做了個demo(前端上傳外掛用的方案2, 後端拼接檔案切片用的方案3)