深入理解javascript中的事件循環event-loop


前面的話

  本文將詳細介紹javascript中的事件循環event-loop

 

線程

  javascript是單線程的語言,也就是說,同一個時間只能做一件事。而這個單線程的特性,與它的用途有關,作為瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?

  為了利用多核CPU的計算能力,HTML5提出Web Worker標准,允許JavaScript腳本創建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標准並沒有改變JavaScript單線程的本質

【排隊】

  單線程就意味着,所有任務需要排隊,前一個任務結束,才會執行后一個任務。如果前一個任務耗時很長,后一個任務就不得不一直等着

var i, t = Date.now()
for (i = 0; i < 100000000; i++) {} console.log(Date.now() - t) // 238

  像上面這樣,如果排隊是因為計算量大,CPU忙不過來,倒也算了

  但是,如果是網絡請求就不合適。因為一個網絡請求的資源什么時候返回是不可預知的,這種情況再排隊等待就不明智了

 

同步和異步

  於是,任務分為同步任務和異步任務

【同步】

  如果在函數返回的時候,調用者就能夠得到預期結果(即拿到了預期的返回值或者看到了預期的效果),那么這個函數就是同步的

Math.sqrt(2);
console.log('Hi');

  第一個函數返回時,就拿到了預期的返回值:2的平方根;第二個函數返回時,就看到了預期的效果:在控制台打印了一個字符串

  所以這兩個函數都是同步的

【異步】

  如果在函數返回的時候,調用者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那么這個函數就是異步的

fs.readFile('foo.txt', 'utf8', function(err, data) {
    console.log(data);
});

  在上面的代碼中,我們希望通過fs.readFile函數讀取文件foo.txt中的內容,並打印出來。但是在fs.readFile函數返回時,我們期望的結果並不會發生,而是要等到文件全部讀取完成之后。如果文件很大的話可能要很長時間

  所以,fs.readFile函數是異步的

  正是由於JavaScript是單線程的,而異步容易實現非阻塞,所以在JavaScript中對於耗時的操作或者時間不確定的操作,使用異步就成了必然的選擇

 

異步詳解

  從上文可以看出,異步函數實際上很快就調用完成了。但是后面還有執行異步操作、通知主線程、主線程調用回調函數等很多步驟。我們把整個過程叫做異步過程。異步函數的調用在整個異步過程中,只是一小部分

  一個異步過程通常是這樣的:主線程發起一個異步請求,異步任務接收請求並告知主線程已收到(異步函數返回);主線程可以繼續執行后面的代碼,同時異步操作開始執行;執行完成后通知主線程;主線程收到通知后,執行一定的動作(調用回調函數)

  因此,一個異步過程包括兩個要素:注冊函數和回調函數,其中注冊函數用來發起異步過程,回調函數用來處理結果

  下面的代碼中,其中的setTimeout就是異步過程的發起函數,fn是回調函數

setTimeout(fn, 1000);

  有一個很重要的問題,如何才算是異步操作執行完成呢?對於不同類型的異步任務,操作完成的標准不同

【異步類型】

  一般而言,異步任務有以下三種類型

  1、普通事件,如click、resize等

  2、資源加載,如load、error等

  3、定時器,包括setInterval、setTimeout等

  下面對這三種類型分別舉例說明,下面代碼中,鼠標點擊div時,就代表任務執行完成了

div.onclick = () => {
  console.log('click')
}

  下面代碼中,XHR對象的readyState值為4,即已經接收到全部響應數據了,代表任務執行完成

xhr.onreadystatechange = function(){
  if(xhr.readyState == 4){
      if(xhr.status == 200){
          //實際操作
          result.innerHTML += xhr.responseText;
      }
  }
}

  下面代碼中,過1s后,代表任務執行完成

setTimeout(() => {
  console.log('timeout')
},1000)

  對於同步任務來說,按順序執行即可;但是,對於異步任務,各任務執行的時間長短不同,執行完成的時間點也不同,主線程如何調控異步任務呢?這就用到了消息隊列

【消息隊列】

  有些文章把消息隊列稱為任務隊列,或者叫事件隊列,總之是和異步任務相關的隊列

  可以確定的是,它是隊列這種先入先出的數據結構,和排隊是類似的,哪個異步操作完成的早,就排在前面。不論異步操作何時開始執行,只要異步操作執行完成,就可以到消息隊列中排隊

  這樣,主線程在空閑的時候,就可以從消息隊列中獲取消息並執行

  消息隊列中放的消息具體是什么東西?消息的具體結構當然跟具體的實現有關。但是為了簡單起見,可以認為:消息就是注冊異步任務時添加的回調函數。

 

可視化描述

  人們把javascript調控同步和異步任務的機制稱為事件循環,首先來看事件循環機制的可視化描述

dataStructure

【棧】

  函數調用形成了一個棧幀

function foo(b) {
  var a = 10; return a + b + 11; } function bar(x) { var y = 3; return foo(x * y); } console.log(bar(7));

  當調用bar時,創建了第一個幀 ,幀中包含了bar的參數和局部變量。當bar調用foo時,第二個幀就被創建,並被壓到第一個幀之上,幀中包含了foo的參數和局部變量。當foo返回時,最上層的幀就被彈出棧(剩下bar函數的調用幀 )。當bar返回的時候,棧就空了

【堆】

  對象被分配在一個堆中,即用以表示一個大部分非結構化的內存區域

【隊列】

  一個 JavaScript 運行時包含了一個待處理的消息隊列。每一個消息都與一個函數相關聯。當棧擁有足夠內存時,從隊列中取出一個消息進行處理。這個處理過程包含了調用與這個消息相關聯的函數(以及因而創建了一個初始堆棧幀)。當棧再次為空的時候,也就意味着消息處理結束

 

事件循環

  下面來詳細介紹事件循環。下圖中,主線程運行的時候,產生堆和棧,棧中的代碼調用各種外部API,異步操作執行完成后,就在消息隊列中排隊。只要棧中的代碼執行完畢,主線程就會去讀取消息隊列,依次執行那些異步任務所對應的回調函數

eventloop

  詳細步驟如下:

  1、所有同步任務都在主線程上執行,形成一個執行棧

  2、主線程之外,還存在一個"消息隊列"。只要異步操作執行完成,就到消息隊列中排隊

  3、一旦執行棧中的所有同步任務執行完畢,系統就會按次序讀取消息隊列中的異步任務,於是被讀取的異步任務結束等待狀態,進入執行棧,開始執行

  4、主線程不斷重復上面的第三步

【循環】

  從代碼執行順序的角度來看,程序最開始是按代碼順序執行代碼的,遇到同步任務,立刻執行;遇到異步任務,則只是調用異步函數發起異步請求。此時,異步任務開始執行異步操作,執行完成后到消息隊列中排隊。程序按照代碼順序執行完畢后,查詢消息隊列中是否有等待的消息。如果有,則按照次序從消息隊列中把消息放到執行棧中執行。執行完畢后,再從消息隊列中獲取消息,再執行,不斷重復。

  由於主線程不斷的重復獲得消息、執行消息、再取消息、再執行。所以,這種機制被稱為事件循環

  用代碼表示大概是這樣:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

  如果當前沒有任何消息queue.waitForMessage 會等待同步消息到達

【事件】

  為什么叫事件循環?而不叫任務循環或消息循環。究其原因是消息隊列中的每條消息實際上都對應着一個事件

  DOM操作對應的是DOM事件,資源加載操作對應的是加載事件,而定時器操作可以看做對應一個“時間到了”的事件

 

實例

  下面以一個實例來解釋事件循環機制

console.log(1)
div.onclick = () => {console.log('click')}
console.log(2)
setTimeout(() => {console.log('timeout')},1000)

  1、執行第一行代碼,第一行是一個同步任務,控制台顯示1

  2、執行第二行代碼,第二行是一個異步任務,發起異步請求,可以在任意時刻執行鼠標點擊的異步操作

  3、執行第三行代碼,第三行是一個同步任務,控制台顯示2

  4、執行第四行代碼,第四行是一個異步任務,發起異步請求,1s后執行定時器任務

  5、假設從執行第四行代碼的1s內,執行了鼠標點擊,則鼠標任務在消息隊列中排到首位

  6、從執行第四行代碼1s后,定時器任務到消息隊列中排到第二位

  7、現在同步任務已經執行完畢,則從消息隊列中按照次序把異步任務放到執行棧中執行

  8、則控制台依次顯示'click‘、'timeout'

  9、過了一段時間后,又執行了一次鼠標點擊,由於消息隊列中已經空了,則鼠標任務在消息隊列中排到首位

  10、同步任務執行完畢后,再從消息隊列中按照次序把異步任務放到執行棧中執行

  11、 則控制台顯示'click'

【異步過程】

  下面以一個實例來解釋一次完整的異步過程

div.onclick = function fn(){console.log('click')}

  1、主線程通過調用異步函數div.onclick發起異步請求

  2、在某一時刻,執行異步操作,即鼠標點擊

  3、接着,回調函數fn到消息隊列中排隊

  4、主線程從消息隊列中讀取fn到執行棧中

  5、然后在執行棧中執行fn里面的代碼console.log('click')

  6、於是,控制台顯示'click'

 

同步變異步

  每一個消息完整的執行后,其它消息才會被執行。這點提供了一些優秀的特性,包括每當一個函數運行時,它就不能被搶占,並且在其他代碼運行之前完全運行

  這個模型的一個缺點在於當一個消息需要太長時間才能完成,Web應用無法處理用戶的交互,例如點擊或滾動

  於是,對於這種情況的常見優化是同步變異步

  一個例子是創建WebQQ的QQ好友列表。列表中通常會有成百上千個好友,如果一個好友用一個節點來表示,在頁面中渲染這個列表的時候,可能要一次性往頁面中創建成百上千個節點

  在短時間內往頁面中大量添加DOM節點顯然也會讓瀏覽器吃不消,看到的結果往往就是瀏覽器的卡頓甚至假死。代碼如下:

var ary = [];
for ( var i = 1; i <= 1000; i++ ){
  ary.push( i );    // 假設 ary 裝載了 1000 個好友的數據
};
var renderFriendList = function( data ){
  for ( var i = 0, l = data.length; i < l; i++ ){
    var div = document.createElement( 'div' );
    div.innerHTML = i;
    document.body.appendChild( div );
  }
};
renderFriendList( ary );

  這個問題的解決方案之一是數組分塊技術,下面的timeChunk函數讓創建節點的工作分批進行,比如把1秒鍾創建1000個節點,改為每隔200毫秒創建8個節點

function chunk(array,process,context){
    setTimeout(function(){
        //取出下一個條目並處理
        var item = array.shift();
        process.call(context,item);
        //若還有條目,再設置另一個定時器
        if(array.length > 0){
            setTimeout(arguments.callee,100);
        }
    },100);    
}
var data = [1,2,3,4,5,6,7,8,9,0];
function printValue(item){
    var div = document.getElementById('myDiv');
    div.innerHTML += item + '<br>';
}
chunk(data.concat(),printValue);

  數組分塊的重要性在於它可以將多個項目的處理在消息隊列上分開,在每個項目處理之后,給予其他的異步任務的執行機會,這樣就可能避免長時間運行腳本的錯誤。一旦某個函數需要花50ms以上的時間完成,那么最好看看能否將任務分割為一系列可以使用定時器的小任務

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM