從event loop規範探究javaScript異步及瀏覽器更新渲染時機

本文轉自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翻譯出來就是事件循環,能夠理解爲實現異步的一種方式,咱們來看看event loopHTML Standard中的定義章節:

第一句話:

爲了協調事件,用戶交互,腳本,渲染,網絡等,用戶代理必須使用本節所述的event loop

事件,用戶交互,腳本,渲染,網絡這些都是咱們所熟悉的東西,他們都是由event loop協調的。觸發一個click事件,進行一次ajax請求,背後都有event loop在運做。

task

一個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任務源很是寬泛,好比ajaxonloadclick事件,基本上咱們常常綁定的各類事件都是task任務源,還有數據庫操做(IndexedDB ),須要注意的是setTimeoutsetIntervalsetImmediate也是task任務源。總結來講task任務源:

  • setTimeout

  • setInterval

  • setImmediate

  • I/O

  • UI rendering

microtask

每個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

有兩種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 loopbrowsing contextsweb workers就是相互獨立的。

  • 全部同源的browsing contexts能夠共用event loop,這樣它們之間就能夠相互通訊。

event loop的處理過程(Processing model)

在規範的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的頻率更新視圖)

microtasks檢查點(microtask checkpoint)

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呢?

執行棧(JavaScript execution context stack)

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就是把原料放上流水線的工人。只要已經放在流水線上的,它們會被依次處理,稱爲同步任務。一些待處理的原料,工人會按照它們的種類排序,在適當的時機放上流水線,這些稱爲異步任務

過程圖:

eventLoop示意圖

舉個簡單的例子,假設一個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隊列:】

  1. 從task隊列中取出script任務,推入棧中執行。

  2. promise1列爲microtask,setTimeout1列爲task,setTimeout2列爲task。

  • 【task隊列:setTimeout1 setTimeout2;microtask隊列:promise1】

  1. script任務執行完畢,執行microtask checkpoint,取出microtask隊列的promise1執行。

循環2:

  • 【task隊列:setTimeout1 setTimeout2;microtask隊列:】

  1. 從task隊列中取出setTimeout1,推入棧中執行,將promise2列爲microtask。

  • 【task隊列:setTimeout2;microtask隊列:promise2】

  1. 執行microtask checkpoint,取出microtask隊列的promise2執行。

循環3:

  • 【task隊列:setTimeout2;microtask隊列:】

  1. 從task隊列中取出setTimeout2,推入棧中執行。

  2. setTimeout2任務執行完畢,執行microtask checkpoint。

  • 【task隊列:;microtask隊列:】

event loop中的Update the rendering(更新渲染)

這是event loop中很重要部分,在第7步會進行Update the rendering(更新渲染),規範容許瀏覽器本身選擇是否更新視圖。也就是說可能不是每輪事件循環都去更新視圖,只在有必要的時候才更新視圖。

咱們都知道javaScript是單線程,渲染計算和腳本運行共用同一線程(網絡請求會有其餘線程),致使腳本運行會阻塞渲染。

https://www.html5rocks.com/zh... 這篇文章較詳細的講解了渲染機制。

渲染的基本流程:
渲染的基本流程

  1. 處理 HTML 標記並構建 DOM 樹。

  2. 處理 CSS 標記並構建 CSSOM 樹, 將 DOM 與 CSSOM 合併成一個渲染樹。

  3. 根據渲染樹來佈局,以計算每一個節點的幾何信息。

  4. 將各個節點繪製到屏幕上。

Note: 能夠看到渲染樹的一個重要組成部分是CSSOM樹,繪製會等待css樣式所有加載完成才進行,因此css樣式加載的快慢是首屏呈現快慢的關鍵點。

下面討論一下渲染的時機。規範定義在一次循環中,Update the rendering會在第六步Microtasks: Perform a microtask checkpoint 後運行。

驗證更新渲染(Update the rendering)的時機

不一樣機子測試可能會獲得不一樣的結果,這取決於瀏覽器,cpu、gpu性能以及它們當時的狀態。

例子1

咱們作一個簡單的測試

<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。

例子1

在這一輪事件循環中,setTimeout1是做爲task運行的,能夠看到paint確實是在task運行完後才進行的。

例子2

如今換成一個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>

例子2

和上一個例子很像,不一樣的是這一輪事件循環的task是click的回調函數,Promise1則是microtask,paint一樣是在他們以後完成。

標準就是那麼定義的,答案彷佛顯而易見,咱們把例子變得稍微複雜一些。

例子3

<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的部分。

例子3

咱們修改了兩次textContent,奇怪的是setTimeout一、setTimeout2之間沒有paint,瀏覽器只繪製了textContent=1,難道setTimeout一、setTimeout2在同一次event loop中嗎?

例子4

在兩個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>

例子4

從run microtasks中能夠看出來,setTimeout一、setTimeout2應該運行在兩次event loop中,textContent = 0的修改被跳過了。

setTimeout一、setTimeout2的運行間隔很短,在setTimeout1完成以後,setTimeout2立刻就開始執行了,咱們知道瀏覽器會盡可能保持每秒60幀的刷新頻率(大約16.7ms每幀),是否是隻有兩次event loop間隔大於16.7ms纔會進行繪製呢?

例子5

將時間間隔加大一些。

<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>

例子5

兩塊黃色的區域就是 setTimeout,在1224ms處綠色部分,瀏覽器對con.textContent = 0的變更進行了繪製。在1234ms處綠色部分,繪製了con.textContent = 1。

能否認爲相鄰的兩次event loop的間隔很短,瀏覽器就不會去更新渲染了呢?繼續咱們的實驗

例子6

咱們在同一時間執行多個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>

例子6

圖中一共繪製了兩幀,第一幀4.4ms,第二幀9.3ms,都遠遠高於每秒60HZ(16.7ms)的頻率,第一幀繪製的是con.textContent = 4,第二幀繪製的是 con.textContent = 6。因此兩次event loop的間隔很短一樣會進行繪製。

例子7

有說法是一輪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:
例子7-1

能夠看到一大塊黃色區域,上半部分有一根綠線就是點擊後的第一次繪製,腳本的運行耗費大量的時間,而且阻塞了渲染。

看看setTimeout2的運行狀況。
例子7-2
能夠看到setTimeout2這輪event loop沒有run microtasks,microtasks在setTimeout1被所有執行完了。

25000個microtasks不能說明event loop對microtasks數量沒有限制,有可能這個限制數很高,遠超25000,但平常使用基本不會使用那麼多了。

對microtasks增長數量限制,一個很大的做用是防止腳本運行時間過長,阻塞渲染。

例子8

使用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:
例子8-1
點擊後繪製了3幀,把每次變更都繪製了。

看看單個 requestAnimationFrame的Timeline:
例子8-2

和setTimeout很類似,能夠看出requestAnimationFrame也是一個task,在它完成以後會運行run microtasks。

例子9

驗證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:

例子9

第一個黃塊是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的大體循環過程,能夠用下邊的圖表示:

event loop 過程

假設如今執行到currently running task,咱們對批量的dom進行異步修改,咱們將此任務插進task:
event loop 插入dom修改2

此任務插進microtasks:
event loop 插入dom修改3

能夠看到若是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涉及到的東西不少,本文有誤的地方請指正。

references

相關文章
相關標籤/搜索