上週我在FDConf的分享《讓你的網頁更絲滑》中提到了「時間切片」,因爲時間關係當時並無對時間切片展開更細緻的討論。因此回來後就想着補一篇文章針對「時間切片」展開詳細的討論。javascript
從用戶的輸入,再到顯示器在視覺上給用戶的輸出,這一過程若是超過100ms,那麼用戶會察覺到網頁的卡頓,因此爲了解決這個問題,每一個任務不能超過50ms,W3C性能工做組在LongTask規範中也將超過50ms的任務定義爲長任務。java
關於這50毫秒我在FDConf的分享中進行了很詳細的講解,沒有聽到的小夥伴也不用着急,後續我會針對此次分享的內容補一篇文章。git
在線PPT地址:ppt.baomitu.com/d/b267a4a3github
因此爲了不長任務,一種方案是使用Web Worker,將長任務放在Worker線程中執行,缺點是沒法訪問DOM,而另外一種方案是使用時間切片。瀏覽器
時間切片的核心思想是:若是任務不能在50毫秒內執行完,那麼爲了避免阻塞主線程,這個任務應該讓出主線程的控制權,使瀏覽器能夠處理其餘任務。讓出控制權意味着中止執行當前任務,讓瀏覽器去執行其餘任務,隨後再回來繼續執行沒有執行完的任務。異步
因此時間切片的目的是不阻塞主線程,而實現目的的技術手段是將一個長任務拆分紅不少個不超過50ms的小任務分散在宏任務隊列中執行。函數
上圖能夠看到主線程中有一個長任務,這個任務會阻塞主線程。使用時間切片將它切割成不少個小任務後,以下圖所示。工具
能夠看到如今的主線程有不少密密麻麻的小任務,咱們將它放大後以下圖所示。性能
能夠看到每一個小任務中間是有空隙的,表明着任務執行了一小段時間後,將讓出主線程的控制權,讓瀏覽器執行其餘的任務。學習
使用時間切片的缺點是,任務運行的總時間變長了,這是由於它每處理完一個小任務後,主線程會空閒出來,而且在下一個小任務開始處理以前有一小段延遲。
可是爲了不卡死瀏覽器,這種取捨是頗有必要的。
時間切片是一種概念,也能夠理解爲一種技術方案,它不是某個API的名字,也不是某個工具的名字。
事實上,時間切片充分利用了「異步」,在早期,可使用定時器來實現,例如:
btn.onclick = function () {
someThing(); // 執行了50毫秒
setTimeout(function () {
otherThing(); // 執行了50毫秒
});
};
複製代碼
上面代碼當按鈕被點擊時,本應執行100毫秒的任務如今被拆分紅了兩個50毫秒的任務。
在實際應用中,咱們能夠進行一些封裝,封裝後的使用效果相似下面這樣:
btn.onclick = ts([someThing, otherThing], function () {
console.log('done~');
});
複製代碼
固然,關於ts
這個函數的API的設計並非本文的重點,這裏想說明的是,在早期能夠利用定時器來實現「時間切片」。
ES6帶來了迭代器的概念,並提供了生成器Generator函數用來生成迭代器對象,雖然Generator函數最正統的用法是生成迭代器對象,但這不妨咱們利用它的特性作一些其餘的事情。
Generator函數提供了yield
關鍵字,這個關鍵字可讓函數暫停執行。而後經過迭代器對象的next
方法讓函數繼續執行。
對Generator函數不熟悉的同窗,須要先學習Generator函數的用法。
利用這個特性,咱們能夠設計出更方便使用的時間切片,例如:
btn.onclick = ts(function* () {
someThing(); // 執行了50毫秒
yield;
otherThing(); // 執行了50毫秒
});
複製代碼
能夠看到,咱們只須要使用yield
這個關鍵字就能夠將本應執行100毫秒的任務拆分紅了兩個50毫秒的任務。
咱們甚至能夠將yield關鍵字放在循環裏:
btn.onclick = ts(function* () {
while (true) {
someThing(); // 執行了50毫秒
yield;
}
});
複製代碼
上面代碼咱們寫了一個死循環,但依然不會阻塞主線程,瀏覽器也不會卡死。
經過前面的例子,咱們會發現基於Generator的時間切片很是好用,但其實ts函數的實現原理很是簡單,一個最簡單的ts函數只須要九行代碼。
function ts (gen) {
if (typeof gen === 'function') gen = gen()
if (!gen || typeof gen.next !== 'function') return
return function next() {
const res = gen.next()
if (res.done) return
setTimeout(next)
}
}
複製代碼
代碼雖然所有隻有9行,關鍵代碼只有三、4行,但這幾行代碼充分利用了事件循環機制以及Generator函數的特性。
創造出這樣的代碼我仍是很開心的。
上面代碼核心思想是:經過yield
關鍵字能夠將任務暫停執行,從而讓出主線程的控制權;經過定時器能夠將「未完成的任務」從新放在任務隊列中繼續執行。
使用yield
來切割任務很是方便,但若是切割的粒度特別細,反而效率不高。假設咱們的任務執行100ms
,最好的方式是切割成兩個執行50ms
的任務,而不是切割成100個執行1ms
的任務。假設被切割的任務之間的間隔爲4ms
,那麼切割成100個執行1ms
的任務的總執行時間爲:
(1 + 4) * 100 = 500ms
複製代碼
若是切割成兩個執行時間爲50ms
的任務,那麼總執行時間爲:
(50 + 4) * 2 = 108ms
複製代碼
能夠看到,在不影響用戶體驗的狀況下,下面的總執行時間要比前面的少了4.6倍。
保證切割的任務恰好接近50ms
,能夠在用戶使用yield
時自行評估,也能夠在ts
函數中根據任務的執行時間判斷是否應該一次性執行多個任務。
咱們將ts
函數稍微改進一下:
function ts (gen) {
if (typeof gen === 'function') gen = gen()
if (!gen || typeof gen.next !== 'function') return
return function next() {
const start = performance.now()
let res = null
do {
res = gen.next()
} while(!res.done && performance.now() - start < 25);
if (res.done) return
setTimeout(next)
}
}
複製代碼
如今咱們測試下:
ts(function* () {
const start = performance.now()
while (performance.now() - start < 1000) {
console.log(11)
yield
}
console.log('done!')
})();
複製代碼
這段代碼在以前的版本中,在個人電腦上能夠打印出 215 次 11
,在後面的版本中能夠打印出 6300 次 11
,說明在總時間相同的狀況下,能夠執行更多的任務。
再看另外一個例子:
ts(function* () {
for (let i = 0; i < 10000; i++) {
console.log(11)
yield
}
console.log('done!')
})();
複製代碼
在個人電腦上,這段代碼在以前的版本中,被切割成一萬個小任務,總執行時間爲 46
秒,在以後的版本中,被切割成 52 個小任務,總執行時間爲 1.5
秒。
我將時間切片的代碼放在了個人Github上,感興趣的能夠參觀下:github.com/berwin/time…