本文轉自blogjavascript
轉載請註明出處css
event loops隱藏得比較深,不少人對它很陌生。但提起異步,相信每一個人都知道。異步背後的「靠山」就是event loops。這裏的異步準確的說應該叫瀏覽器的event loops或者說是javaScript運行環境的event loops,由於ECMAScript中沒有event loops,event loops是在HTML Standard定義的。html
event loops規範中定義了瀏覽器什麼時候進行渲染更新,瞭解它有助於性能優化。html5
思考下邊的代碼運行順序:java
console.log('start') setTimeout( function () { console.log('setTimeout') }, 0 ) Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('end') // start // end // promise1 // promise2 // setTimeout
上面的順序是在chrome運行得出的,有趣的是在safari 9.1.2中測試,promise1 promise2會在setTimeout的後邊,而在safari 10.0.1中獲得了和chrome同樣的結果。爲什麼瀏覽器有不一樣的表現,瞭解tasks, microtasks隊列就能夠解答這個問題。node
不少框架和庫都會使用相似下面函數:git
function flush() { ... } function useMutationObserver() { var iterations = 0; var observer = new MutationObserver(flush); var node = document.createTextNode(''); observer.observe(node, { characterData: true }); return function () { node.data = iterations = ++iterations % 2; }; }
初次看這個useMutationObserver函數總會頗有疑惑,MutationObserver
不是用來觀察dom的變化的嗎,這樣憑空造出一個節點來反覆修改它的內容,來觸發觀察的回調函數有何意義?github
答案就是使用Mutation事件
能夠異步執行操做(例子中的flush函數),一是能夠儘快響應變化,二是能夠去除重複的計算。可是setTimeout(flush, 0)
一樣也能夠執行異步操做,要知道其中的差別和選擇哪一種異步方法,就得了解event loop。web
先看看它們在規範中的定義。ajax
Note:本文的引用部分,就是對規範的翻譯,有的部分會歸納或者省略的翻譯,有誤請指正。
event loop翻譯出來就是事件循環,能夠理解爲實現異步的一種方式,咱們來看看event loop在HTML Standard中的定義章節:
第一句話:
爲了協調事件,用戶交互,腳本,渲染,網絡等,用戶代理必須使用本節所述的
event loop
。
事件,用戶交互,腳本,渲染,網絡這些都是咱們所熟悉的東西,他們都是由event loop協調的。觸發一個click
事件,進行一次ajax
請求,背後都有event loop
在運做。
一個event loop有一個或者多個task隊列。
當用戶代理安排一個任務,必須將該任務增長到相應的event loop的一個tsak隊列中。
每個task都來源於指定的任務源,好比能夠爲鼠標、鍵盤事件提供一個task隊列,其餘事件又是一個單獨的隊列。能夠爲鼠標、鍵盤事件分配更多的時間,保證交互的流暢。
task也被稱爲macrotask,task隊列仍是比較好理解的,就是一個先進先出的隊列,由指定的任務源去提供任務。
哪些是task任務源呢?
規範在Generic task sources中有說起:
DOM操做任務源:
此任務源被用來相應dom操做,例如一個元素以非阻塞的方式插入文檔。
用戶交互任務源:
此任務源用於對用戶交互做出反應,例如鍵盤或鼠標輸入。響應用戶操做的事件(例如click)必須使用task隊列。
網絡任務源:
網絡任務源被用來響應網絡活動。
history traversal任務源:
當調用history.back()等相似的api時,將任務插進task隊列。
task任務源很是寬泛,好比ajax
的onload
,click
事件,基本上咱們常常綁定的各類事件都是task任務源,還有數據庫操做(IndexedDB ),須要注意的是setTimeout
、setInterval
、setImmediate
也是task任務源。總結來講task任務源:
setTimeout
setInterval
setImmediate
I/O
UI rendering
每個event loop都有一個microtask隊列,一個microtask會被排進microtask隊列而不是task隊列。
有兩種microtasks:分別是solitary callback microtasks和compound microtasks。規範值只覆蓋solitary callback microtasks。
若是在初期執行時,spin the event loop,microtasks有可能被移動到常規的task隊列,在這種狀況下,microtasks任務源會被task任務源所用。一般狀況,task任務源和microtasks是不相關的。
microtask 隊列和task 隊列有些類似,都是先進先出的隊列,由指定的任務源去提供任務,不一樣的是一個
event loop裏只有一個microtask 隊列。
HTML Standard沒有具體指明哪些是microtask任務源,一般認爲是microtask任務源有:
process.nextTick
promises
Object.observe
MutationObserver
NOTE:
Promise的定義在 ECMAScript規範而不是在HTML規範中,可是ECMAScript規範中有一個jobs的概念和microtasks很類似。在Promises/A+規範的Notes 3.1中說起了promise的then方法能夠採用「宏任務(macro-task)」機制或者「微任務(micro-task)」機制來實現。因此開頭說起的promise在不一樣瀏覽器的差別正源於此,有的瀏覽器將then
放入了macro-task隊列,有的放入了micro-task 隊列。在jake的博文Tasks, microtasks, queues and schedules中說起了一個討論vague mailing list discussions,一個廣泛的共識是promises屬於microtasks隊列。
知道了event loops
大體作什麼的,咱們再深刻了解下event loops
。
有兩種event loops,一種在瀏覽器上下文,一種在workers中。
每個用戶代理必須至少有一個瀏覽器上下文event loop,可是每一個單元的類似源瀏覽器上下文至多有一個event loop。
event loop 老是具備至少一個瀏覽器上下文,當一個event loop的瀏覽器上下文全都銷燬的時候,event loop也會銷燬。一個瀏覽器上下文總有一個event loop去協調它的活動。
Worker的event loop相對簡單一些,一個worker對應一個event loop,worker進程模型管理event loop的生命週期。
反覆提到的一個詞是browsing contexts(瀏覽器上下文)。
瀏覽器上下文是一個將 Document 對象呈現給用戶的環境。在一個 Web 瀏覽器內,一個標籤頁或窗口常包含一個瀏覽上下文,如一個 iframe 或一個 frameset 內的若干 frame。
結合一些資料,對上邊規範給出一些理解(有誤請指正):
每一個線程都有本身的event loop
。
瀏覽器能夠有多個event loop
,browsing contexts
和web workers
就是相互獨立的。
全部同源的browsing contexts
能夠共用event loop
,這樣它們之間就能夠相互通訊。
在規範的Processing model定義了event loop
的循環過程:
一個event loop只要存在,就會不斷執行下邊的步驟:
1.在tasks隊列中選擇最老的一個task,用戶代理能夠選擇任何task隊列,若是沒有可選的任務,則跳到下邊的microtasks步驟。
2.將上邊選擇的task設置爲正在運行的task。
3.Run: 運行被選擇的task。
4.將event loop的currently running task變爲null。
5.從task隊列裏移除前邊運行的task。
6.Microtasks: 執行microtasks任務檢查點。(也就是執行microtasks隊列裏的任務)
7.更新渲染(Update the rendering)...
8.若是這是一個worker event loop,可是沒有任務在task隊列中,而且WorkerGlobalScope對象的closing標識爲true,則銷燬event loop,停止這些步驟,而後進行定義在Web workers章節的run a worker。
9.返回到第一步。
event loop會不斷循環上面的步驟,歸納說來:
event loop
會不斷循環的去取tasks
隊列的中最老的一個任務推入棧中執行,並在當次循環裏依次執行並清空microtask
隊列裏的任務。
執行完microtask
隊列裏的任務,有可能會渲染更新。(瀏覽器很聰明,在一幀之內的屢次dom變更瀏覽器不會當即響應,而是會積攢變更以最高60HZ的頻率更新視圖)
event loop
運行的第6步,執行了一個microtask checkpoint
,看看規範如何描述microtask checkpoint
:
當用戶代理去執行一個microtask checkpoint,若是microtask checkpoint的flag(標識)爲false,用戶代理必須運行下面的步驟:
1.將microtask checkpoint的flag設爲true。
2.Microtask queue handling: 若是event loop的microtask隊列爲空,直接跳到第八步(Done)。
3.在microtask隊列中選擇最老的一個任務。
4.將上一步選擇的任務設爲event loop的currently running task。
5.運行選擇的任務。
6.將event loop的currently running task變爲null。
7.將前面運行的microtask從microtask隊列中刪除,而後返回到第二步(Microtask queue handling)。
8.Done: 每個environment settings object它們的 responsible event loop就是當前的event loop,會給environment settings object發一個 rejected promises 的通知。
9.清理IndexedDB的事務。
10.將microtask checkpoint的flag設爲flase。
microtask checkpoint
所作的就是執行microtask隊列裏的任務。何時會調用microtask checkpoint
呢?
在event loop的第六步(Microtasks: Perform a microtask checkpoint)執行checkpoint,也就是在運行task以後,更新渲染以前。
task和microtask都是推入棧中執行的,要完整了解event loops還須要認識JavaScript execution context stack,它的規範位於https://tc39.github.io/ecma26...。
javaScript是單線程,也就是說只有一個主線程,主線程有一個棧,每個函數執行的時候,都會生成新的execution context(執行上下文)
,執行上下文會包含一些當前函數的參數、局部變量之類的信息,它會被推入棧中, running execution context(正在執行的上下文)始終處於棧的頂部。當函數執行完後,它的執行上下文會從棧彈出。
舉個簡單的例子:
function bar() { console.log('bar'); } function foo() { console.log('foo'); bar(); } foo();
執行過程當中棧的變化:
規範晦澀難懂,作一個形象的比喻:
主線程相似一個加工廠,它只有一條流水線,待執行的任務就是流水線上的原料,只有前一個加工完,後一個才能進行。event loops就是把原料放上流水線的工人。只要已經放在流水線上的,它們會被依次處理,稱爲同步任務。一些待處理的原料,工人會按照它們的種類排序,在適當的時機放上流水線,這些稱爲異步任務。
過程圖:
舉個簡單的例子,假設一個script標籤的代碼以下:
Promise.resolve().then(function promise1 () { console.log('promise1'); }) setTimeout(function setTimeout1 (){ console.log('setTimeout1') Promise.resolve().then(function promise2 () { console.log('promise2'); }) }, 0) setTimeout(function setTimeout2 (){ console.log('setTimeout2') }, 0)
運行過程:
script裏的代碼被列爲一個task,放入task隊列。
循環1:
【task隊列:script ;microtask隊列:】
從task隊列中取出script任務,推入棧中執行。
promise1列爲microtask,setTimeout1列爲task,setTimeout2列爲task。
【task隊列:setTimeout1 setTimeout2;microtask隊列:promise1】
script任務執行完畢,執行microtask checkpoint,取出microtask隊列的promise1執行。
循環2:
【task隊列:setTimeout1 setTimeout2;microtask隊列:】
從task隊列中取出setTimeout1,推入棧中執行,將promise2列爲microtask。
【task隊列:setTimeout2;microtask隊列:promise2】
執行microtask checkpoint,取出microtask隊列的promise2執行。
循環3:
【task隊列:setTimeout2;microtask隊列:】
從task隊列中取出setTimeout2,推入棧中執行。
setTimeout2任務執行完畢,執行microtask checkpoint。
【task隊列:;microtask隊列:】
這是event loop中很重要部分,在第7步會進行Update the rendering(更新渲染),規範容許瀏覽器本身選擇是否更新視圖。也就是說可能不是每輪事件循環都去更新視圖,只在有必要的時候才更新視圖。
咱們都知道javaScript是單線程,渲染計算和腳本運行共用同一線程(網絡請求會有其餘線程),致使腳本運行會阻塞渲染。
https://www.html5rocks.com/zh... 這篇文章較詳細的講解了渲染機制。
渲染的基本流程:
處理 HTML 標記並構建 DOM 樹。
處理 CSS 標記並構建 CSSOM 樹, 將 DOM 與 CSSOM 合併成一個渲染樹。
根據渲染樹來佈局,以計算每一個節點的幾何信息。
將各個節點繪製到屏幕上。
Note: 能夠看到渲染樹的一個重要組成部分是CSSOM樹,繪製會等待css樣式所有加載完成才進行,因此css樣式加載的快慢是首屏呈現快慢的關鍵點。
下面討論一下渲染的時機。規範定義在一次循環中,Update the rendering會在第六步Microtasks: Perform a microtask checkpoint 後運行。
不一樣機子測試可能會獲得不一樣的結果,這取決於瀏覽器,cpu、gpu性能以及它們當時的狀態。
咱們作一個簡單的測試
<div id='con'>this is con</div> <script> var t = 0; var con = document.getElementById('con'); con.onclick = function () { setTimeout(function setTimeout1 () { con.textContent = t; }, 0) }; </script>
用chrome的Developer tools的Timeline查看各部分運行的時間點。
當咱們點擊這個div的時候,下圖截取了部分時間線,黃色部分是腳本運行,紫色部分是更新render樹、計算佈局,綠色部分是繪製。
綠色和紫色部分能夠認爲是Update the rendering。
在這一輪事件循環中,setTimeout1是做爲task運行的,能夠看到paint確實是在task運行完後才進行的。
如今換成一個microtask任務,看看有什麼變化
<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () { Promise.resolve().then(function Promise1 () { con.textContext = 0; }) }; </script>
和上一個例子很像,不一樣的是這一輪事件循環的task是click的回調函數,Promise1則是microtask,paint一樣是在他們以後完成。
標準就是那麼定義的,答案彷佛顯而易見,咱們把例子變得稍微複雜一些。
<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function click1() { setTimeout(function setTimeout1() { con.textContent = 0; }, 0) setTimeout(function setTimeout2() { con.textContent = 1; }, 0) }; </script>
當點擊後,一共產生3個task,分別是click一、setTimeout一、setTimeout2,因此會分別在3次event loop中進行。
下面截取的是setTimeout一、setTimeout2的部分。
咱們修改了兩次textContent,奇怪的是setTimeout一、setTimeout2之間沒有paint,瀏覽器只繪製了textContent=1,難道setTimeout一、setTimeout2在同一次event loop中嗎?
在兩個setTimeout中增長microtask。
<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () { setTimeout(function setTimeout1() { con.textContent = 0; Promise.resolve().then(function Promise1 () { console.log('Promise1') }) }, 0) setTimeout(function setTimeout2() { con.textContent = 1; Promise.resolve().then(function Promise2 () { console.log('Promise2') }) }, 0) }; </script>
從run microtasks中能夠看出來,setTimeout一、setTimeout2應該運行在兩次event loop中,textContent = 0的修改被跳過了。
setTimeout一、setTimeout2的運行間隔很短,在setTimeout1完成以後,setTimeout2立刻就開始執行了,咱們知道瀏覽器會盡可能保持每秒60幀的刷新頻率(大約16.7ms每幀),是否是隻有兩次event loop間隔大於16.7ms纔會進行繪製呢?
將時間間隔加大一些。
<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () { setTimeout(function setTimeout1() { con.textContent = 0; }, 0); setTimeout(function setTimeout2() { con.textContent = 1; }, 16.7); }; </script>
兩塊黃色的區域就是 setTimeout,在1224ms處綠色部分,瀏覽器對con.textContent = 0的變更進行了繪製。在1234ms處綠色部分,繪製了con.textContent = 1。
能否認爲相鄰的兩次event loop的間隔很短,瀏覽器就不會去更新渲染了呢?繼續咱們的實驗
咱們在同一時間執行多個setTimeout來模擬執行間隔很短的task。
<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () { setTimeout(function(){ con.textContent = 0; },0) setTimeout(function(){ con.textContent = 1; },0) setTimeout(function(){ con.textContent = 2; },0) setTimeout(function(){ con.textContent = 3; },0) setTimeout(function(){ con.textContent = 4; },0) setTimeout(function(){ con.textContent = 5; },0) setTimeout(function(){ con.textContent = 6; },0) }; </script>
圖中一共繪製了兩幀,第一幀4.4ms,第二幀9.3ms,都遠遠高於每秒60HZ(16.7ms)的頻率,第一幀繪製的是con.textContent = 4,第二幀繪製的是 con.textContent = 6。因此兩次event loop的間隔很短一樣會進行繪製。
有說法是一輪event loop執行的microtask有數量限制(多是1000),多餘的microtask會放到下一輪執行。下面例子將microtask的數量增長到25000。
<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () { setTimeout(function setTimeout1() { con.textContent = 'task1'; for(var i = 0; i < 250000; i++){ Promise.resolve().then(function(){ con.textContent = i; }); } }, 0); setTimeout(function setTimeout2() { con.textContent = 'task2'; }, 0); }; </script>
整體的timeline:
能夠看到一大塊黃色區域,上半部分有一根綠線就是點擊後的第一次繪製,腳本的運行耗費大量的時間,而且阻塞了渲染。
看看setTimeout2的運行狀況。
能夠看到setTimeout2這輪event loop沒有run microtasks,microtasks在setTimeout1被所有執行完了。
25000個microtasks不能說明event loop對microtasks數量沒有限制,有可能這個限制數很高,遠超25000,但平常使用基本不會使用那麼多了。
對microtasks增長數量限制,一個很大的做用是防止腳本運行時間過長,阻塞渲染。
使用requestAnimationFrame。
<div id='con'>this is con</div> <script> var con = document.getElementById('con'); var i = 0; var raf = function(){ requestAnimationFrame(function() { con.textContent = i; Promise.resolve().then(function(){ i++; if(i < 3) raf(); }); }); } con.onclick = function () { raf(); }; </script>
整體的Timeline:
點擊後繪製了3幀,把每次變更都繪製了。
看看單個 requestAnimationFrame的Timeline:
和setTimeout很類似,能夠看出requestAnimationFrame也是一個task,在它完成以後會運行run microtasks。
驗證postMessage是不是task
setTimeout(function setTimeout1(){ console.log('setTimeout1') }, 0) var channel = new MessageChannel(); channel.port1.onmessage = function onmessage1 (){ console.log('postMessage') Promise.resolve().then(function promise1 (){ console.log('promise1') }) }; channel.port2.postMessage(0); setTimeout(function setTimeout2(){ console.log('setTimeout2') }, 0) console.log('sync') }
執行順序:
sync postMessage promise1 setTimeout1 setTimeout2
timelime:
第一個黃塊是onmessage1,第二個是setTimeout1,第三個是setTimeout2。顯而易見,postMessage屬於task,由於setTimeout的4ms標準化了,因此這裏的postMessage會優先setTimeout運行。
上邊的例子能夠得出一些結論:
在一輪event loop中屢次修改同一dom,只有最後一次會進行繪製。
渲染更新(Update the rendering)會在event loop中的tasks和microtasks完成後進行,但並非每輪event loop都會更新渲染,這取決因而否修改了dom和瀏覽器以爲是否有必要在此時當即將新狀態呈現給用戶。若是在一幀的時間內(時間並不肯定,由於瀏覽器每秒的幀數總在波動,16.7ms只是估算並不許確)修改了多處dom,瀏覽器可能將變更積攢起來,只進行一次繪製,這是合理的。
若是但願在每輪event loop都即時呈現變更,可使用requestAnimationFrame。
event loop的大體循環過程,能夠用下邊的圖表示:
假設如今執行到currently running task,咱們對批量的dom進行異步修改,咱們將此任務插進task:
此任務插進microtasks:
能夠看到若是task隊列若是有大量的任務等待執行時,將dom的變更做爲microtasks而不是task能更快的將變化呈現給用戶。
對於一些簡單的場景,同步徹底能夠勝任,若是得對dom反覆修改或者進行大量計算時,使用異步能夠做爲緩衝,優化性能。
舉個小例子:
如今有一個簡單的元素,用它展現咱們的計算結果:
<div id='result'>this is result</div>
有一個計算平方的函數,而且會將結果響應到對應的元素
function bar (num, id) { var product = num * num; var resultEle = document.getElementById( id ); resultEle.textContent = product; }
如今咱們製造些問題,假設如今不少同步函數引用了bar,在一輪event loop裏,可能bar會被調用屢次,而且其中有幾個是對id='result'的元素進行操做。就像下邊同樣:
... bar( 2, 'result' ) ... bar( 4, 'result' ) ... bar( 5, 'result' ) ...
彷佛這樣的問題也不大,可是當計算變得複雜,操做不少dom的時候,這個問題就不容忽視了。
用咱們上邊講的event loop知識,修改一下bar。
var store = {}, flag = false; function bar (num, id) { store[ id ] = num; if(!flag){ Promise.resolve().then(function () { for( var k in store ){ var num = store[k]; var product = num * num; var resultEle = document.getElementById( k ); resultEle.textContent = product; } }); flag = true; } }
如今咱們用一個store去存儲參數,統一在microtasks階段執行,過濾了多餘的計算,即便同步過程當中屢次對一個元素修改,也只會響應最後一次。
寫了個簡單插件asyncHelper,能夠幫助咱們異步的插入task和microtask。
例如:
//生成task var myTask = asyncHelper.task(function () { console.log('this is task') }); //生成microtask var myMicrotask = asyncHelper.mtask(function () { console.log('this is microtask') }); //插入task myTask() //插入microtask myMicrotask();
對以前的例子的使用asyncHelper:
var store = {}; //生成一個microtask var foo = asyncHelper.mtask(function () { for( var k in store ){ var num = store[k]; var product = num * num; var resultEle = document.getElementById( k ); resultEle.textContent = product; } }, {callMode: 'last'}); function bar (num, id) { store[ id ] = num; foo(); }
若是不支持microtask將回退成task。
event loop涉及到的東西不少,本文有誤的地方請指正。