精讀Javascript系列(七)事件循環細則 I:微任務、宏任務

前言

對於Javascript異步,我是從其他面向對象編程語言的併發編程層層向下介紹的,在一些細節上並沒有多詳細說明。此次算是補充所缺,在選擇主題時,我茫然了好一陣,決定從微任務和宏任務開始入手,閱讀下文時,儘可能有些Promise的基礎。

開始吧。

事件循環

首先補充一下上次事件循環的更多細節:

  1. 事件循環開啓
  2. 新消息序列設爲當前消息序列
  3. 當前消息序列中取出任務
    消息序列是先入先出結構,也就是說它是按照順序取出的。
  4. 處理任務
    自上而下運行JS代碼
    如果發出異步請求,然後將消息保存到這個新消息序列(若無則新建)中
    新消息序列的任務全部被阻塞,等待下次事件循環迭代處理。
  5. 檢查當前消息序列是否爲空,是則繼續,否則轉至 (3)
  6. 是否觸發UI Rendering事件,是則立即進行視圖渲染。 否則繼續
  7. 是否新增消息序列
    如果是,開始下一輪事件循環,回到(2)
    否則繼續
  8. 確定再無事件,關閉事件循環。線程進入休眠;
    直至有事件發生,新建消息序列並保存消息,轉至(1)。

從上面的過程中,可以得到下面的結論:

  1. 此次事件循環將消息序列進行了細分,即當前的新增的,兩者並不同。
  2. 一次事件循環,處理一個消息序列,而不只是一個消息。
  3. 一個事件循環都只渲染一次。
  4. 此事件循環仍需改進

宏任務

我們常說的任務(task),都是宏任務(Macrotask),由宏任務組成的消息序列,稱作宏任務序列,即 Macrotasks套娃嫌疑確定……),一般都是涉及到 IO操作(包括網絡請求、頁面渲染等)的任務,例如:

  1. scripts: 腳本代碼
  2. Mouse/Key Events : click、onload、input等。
  3. Timers: 定時器,例如setTimeout setInterval等。
  4. 未完待續

注意:Timers工作過程是這樣的:

  1. 調用setTimeout時,將消息(回調函數,即task)放到延遲消息隊列
  2. 延遲消息隊列中的task到期後,放入新Macrotasks中。
  3. 在下次事件循環迭代中等候處理。

示例:
首先搞一個用於生成定時器的函數。

 <div id="text"></div>
 var text = document.querySelector('#text')
   var genTimer = (string,delay=0,cb)=>{
       return ()=>{
           setTimeout(()=>{
           text.innerHTML+=string+'<br/>'
           !cb||cb();
       },delay*1000)
       }
   }    

現在請一直記住腳本代碼和Timer是一個宏任務
並且這個函數會一直用到結束爲止。
(……想必上面的代碼極易理解的吧……)

事件循環與渲染

準備兩個消息序列的任務,
算是模擬兩次事件循環的消息序列。

 var macroTasks = []
 macroTasks[0] = [genTimer('A1: Be honest rather clever.',1),
                  genTimer('A2: Being on sea, sail; being on land, settle.',1)]
   // 兩個隊列的到期時間不一致!               
 macroTasks[1] = [genTimer('B1: Failure is the mother of success.',3),
                 genTimer('B2: The shortest answer is doing.', 3)]
 /*
   macroTask[0] 是 下次的消息序列的任務
   macroTask[1] 是 下下次的消息序列的任務
 */

請注意, macroTasks[0]中的task都在1s後到期。因此下輪事件循環中會處理這些tasks。同理,macroTasks[1]中的tasks都在3s後到期,因此會在下下次事件循環中處理這些tasks。

爲什麼要注意這些區別呢?
因爲它們分兩次事件循環的處理的!

  text.innerHTML +='start......<br>'
  macroTasks[0].forEach(f=>f())
  macroTasks[1].forEach(f=>f())
  
  text.innerHTML +='end......<br>'

輸出如下:
在這裏插入圖片描述
答案很容易就被猜出來的。但是輸出結果並不重要,重要的是現象。
A1A2 以及B1B2彷彿是分成了兩次渲染出來的! 這纔是關鍵

說明如下:

  • 一次事件循環,只會處理一個消息序列。由於A1A2Timer是同時到期的,因此會被劃分到同一個消息序列中,而一次事件循環只渲染一次,所以A1A2同時被渲染。
  • 同理,B1B2也是同樣的情形;但是要注意:B1B2的到期時間與A1A2的並不同,因此它們分爲兩次事件循環處理的。

基本過程如下:
在這裏插入圖片描述注意:事實上這次也渲染了,不過這不是我要講的內容無關,因此略過。

在這裏插入圖片描述處理完畢後,渲染一次。

在這裏插入圖片描述最後一次事件循環, 渲染完畢後結束。

綜上所述可以知道:

同一個消息序列中的task會共享同一次事件循環,並且會等待所有task處理完成後纔會渲染

爲什麼我們要得到這個結論?
繼續吧。

阻塞問題

大多數情況下,我們是不會感知到阻塞的,這一方面是CPU計算能力強悍,另一方面也是JS引擎高性能的原因。

不過偶爾也會出現例外,事實上,我們所說的宏任務基本上都是工作量較大的任務,例如我們的JS代碼文件(少說也要有2000行代碼吧),如果處理不好,就很容易阻塞(即響應時間超長)。

現在模擬一個阻塞任務,例如:

   var macroTasks =[]
 // 在下次事件循環中,新增一個普通任務,
 macroTasks.push(genTimer('01:Better to light one candle \
                           than to curse the darkness.',0))
// 新增一個超長事件的阻塞任務。
// 阻塞時長3s
 macroTasks.push(genTimer('XXXXXblocking long time!', 
           0,
            ()=>{
               console.log('i am running!');
               let c = Date.now();
               while((Date.now()-c)<=3000);
            }))
 text.innerHTML +='hello<br>'
 macroTasks.forEach(f=>f())
 
 text.innerHTML +='byebye!<br>'
   

注意:macroTasks中所有任務都是同時到期的,因此可知它們會被劃分到同一個事件循環中;

然後輸出如下(請耐心看下去):
在這裏插入圖片描述在上面的示例中,儘管將 microtasks中的所有內容都分到了統一個時間循環中,但它們並沒有如我們所想的那般在 0s後輸出。而是同時阻塞了3s。 這是又爲何?

說明:

  • 因爲microtask中的所有任務共享一次事件循環,並且只有事件循環的所有任務都處理完畢時纔會發生渲染事件
  • 所以可以知道,microtask[0]microtask[1]只有都被處理完成後才能夠渲染!但是由於microtask[1]產生了阻塞,最終導致了卡頓

所以可以得到下面的結論:

  • 如果消息序列中有一個task陷入阻塞,那麼就會導致整個事件循環陷入阻塞,最終導致卡頓
    事實上,一旦事件循環陷入阻塞,也會影響到下次事件循環的運行。

接下來,當做我們不知道 macrotasks[1] 是阻塞任務。

上面的代碼總是Hello之後就ByeBye!!了,內容完全沒輸出,這是沒道理的。所以姑且爲了用戶體驗着想,代碼改成這樣:

   var macroTasks =[]
   macroTasks.push(genTimer('XXXXXblocking long time!', 
           0.5,
            ()=>{
               console.log('i am running!');
               let c = Date.now();
               while((Date.now()-c)<=3000);
            }))
   macroTasks.push(genTimer('01:Be honest rather clever',1))
   macroTasks.push(genTimer('ByeBye',1))
   text.innerHTML +='hello<br>'
   macroTasks.forEach(f=>f())
 

注意:上面的代碼中,macrotasks[0]macrotasks[1]以及macrotasks[2]的事件循環不同,它們已經被錯開了。但是仍然被硬生生卡到3s後才輸出。原因很簡單,因爲當前事件循環仍在處理中,所以就推遲了進入下次事件循環的時間

因此總結一條:永遠不要阻塞事件循環,它是所有異步模型的黃金鐵律。因爲它不僅導致嚴重的卡頓,而且極其影響用戶體驗,更重要的是:事件循環阻塞就意味着更大的性能開銷

因此我們只能在阻塞任務之前處理所有任務,但通常情況下仍不可避免的受其影響,例如阻塞任務的延遲時間爲0s時,那麼任何宏任務都會受阻塞影響, 惹怒用戶第一步,循環阻塞想嘔吐

微任務便是上面的一種解決方案(當然最直接的處理辦法就把阻塞任務給Pass掉,但是大多數情況下,這種任務偏偏就很重要。)

使用微任務

微任務Microtask簡單來說是能夠快速完成的任務,並且它保證所有的tasks處理完成後(但仍然在UI Rendering前)進行處理完成。在ES8規範中,微任務用 Job 表示,嘛,不過喜歡 microtask的人更多些,兩個術語表達的意思都是相同的。

最經常使用的微任務是Promise

例如下面代碼:

    var macroTasks =[]
   macroTasks.push(genTimer('XXXXXblocking long time!', 
           0 ,
            ()=>{
               console.log('i am running!');
               let c = Date.now();
               while((Date.now()-c)<=3000);
            }))
   macroTasks.push(()=>{
       return new Promise(res=>{
           
          text.innerHTML+='lark in the clear air<br/>'
          res('success!');
       })
   })
   macroTasks.push(()=>{
       return new Promise(res=>{
          text.innerHTML+='ByeByeBye!<br/>'
       })
   })
   text.innerHTML +='hello<br>'
   macroTasks.forEach(f=>f()) 
  

輸出:
在這裏插入圖片描述

過程圖如下:
在這裏插入圖片描述
(注意: 上面的macroTasks中混入了兩個微任務

雖然現在仍然還是受阻塞影響,但是至少表面上沒什麼卡頓。當然這只是一種實驗;生產環境下無論如何也不要這樣做。自此不再贅述。

微任務在瀏覽器環境下有三個:

  1. queueMicrotask: 微任務回調函數。
  2. Promise: Promise,最常用的
  3. MutationObserver: DOM樹監聽

這裏面除了Promise其他都不怎麼常用,有興趣的可以去了解一下。不過微任務給人的感覺,就像是一個可以追加到宏任務後面的同步代碼,微任務定義不重要,重要的是,微任務儘可能是體積較小的任務代碼,不要嘗試阻塞微任務,否則就失去了微任務的本來含義

將上面的代碼再進一步改寫:

 var text = document.querySelector('#text')
 function genMicrotask(str){
     return async ()=>{
         text.innerHTML += str + '<br>'
         return str ;
     }
 }
 
 var microTasks = [genMicrotask('01: For man is man and master of his fate.'),
                   genMicrotask('ByeBye!!!')]
     
 text.innerHTML='hello!!!<br>'
 microTasks.forEach(f=>f())

輸出:

hello!!!
01: For man is man and master of his fate.
ByeBye!!!

很完美,至少比上次的看起來清爽了許多。
注意:async函數最終也返回一個settled的Promise

好了,微任務和宏任務就先到這裏。
(? Promise放後面吧,相信看的人也不是零基礎,總知道用法吧……)


更新事件循環模型

根據上面所有內容,加入macroTasksmicroTasks等元素,就是:

  1. 事件循環開啓
  2. 新增Macrotasks設爲當前Macrotasks
  3. 當前Macrotasks中按順序取出任務
  4. 處理任務
    自上而下運行JS代碼
    如果發出異步請求,然後將消息保存到新Macrotasks(若無則新建)中;
    如果存在Microtasks,那麼:
    1. Microtasks中取出任務
    2. 運行代碼
    3. 如果發出異步請求,然後將消息保存到新Macrotasks(若無則新建)中
    4. 如果存在Microtask,仍然將其添加到當前macrotask的Microtasks
  5. 檢查當前Macrotasks是否爲空,是則繼續,否則轉至 (3)
  6. 是否觸發UI Rendering事件,是則立即進行視圖渲染。 否則繼續
  7. 是否有新增MacroTasks
    如果是,開始下一輪事件循環, 回到(2);否則繼續。
  8. 確定再無事件,關閉事件循環。線程進入休眠;
    直至有事件發生,新建消息序列並保存消息,轉至(1)。

其實關於事件循環可以簡單記作爲:

  1. 任何事件循環都是以 Macrotasks --> MicroTasks --> UI Rendering 順序進行的。
  2. 新增的任何 Microtask 只會保存在 當前事件循環的 Microtasks中。
  3. 新增的任何 Macrotask 只會保存在 新增Macrotasks 中,它是下一輪事件循環的 Macrotasks

最後

原來我心想能帶入 NodeJS 的東西, 但是未曾想 NodeJS的底層細節如此複雜,遠不是Javascript事件循環模型能概括得了的。對於不明白的原理,小生不敢自以爲是,因此只能稍作安排。libuv好難啊。。。

限於篇幅,只能說這麼多……但是關於這部分內容涉及知識量極大,有謬誤之處,還請慷慨指正,不勝感激。

同步轉發:
https://juejin.im/post/5ecfa657f265da76ed484fd9

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章