任務佇列,巨集任務與微任務

蔣禮銳發表於2020-04-05

任務佇列

首先task queue任務佇列是不是一個佇列? 不是.

任務佇列,巨集任務與微任務

他是一個set 集合, 因為不是在取任務的時候不是像佇列那般先進先出就完了, 而是先把最老的任務獲取, 執行, 執行完畢之後才刪除.

微任務是不是task queue? 不是.

任務佇列,巨集任務與微任務

可以這麼說, 微任務是巨集任務的附屬品, 而巨集任務佇列和微任務佇列一起組成了任務佇列

為什麼我們需要對巨集任務和微任務進行區分?就使用一個任務佇列不行嗎?

巨集任務

說說瀏覽器的主執行緒, 也就是每個頁面都會建立的頁面程式, 主要承擔以下任務, 均可作為巨集任務:

  • 渲染(解析 DOM, 計算佈局以及繪製)
  • 使用者互動(點選, 拖動, 觸控,放大縮小)
  • JavaScript的指令碼執行
  • 網路請求完成(網路程式通過IPC來的), 檔案讀寫完成, history API

這些事件或響應應該採取什麼樣的方式? 以什麼樣的順序來呢?此時便引入了訊息佇列和事件機制

渲染程式會維護多個訊息佇列, 比如延遲佇列和普通的訊息佇列. 主執行緒採用一個for迴圈, 不斷從這些任務佇列中取出任務並執行任務. 這些訊息佇列中的任意一個任務就叫做巨集任務

那麼訊息佇列應該怎麼取呢? 這裡就涉及了事件迴圈EventLoop.也就是開篇說到的為什麼task queue不是一個queue

  • 先從多個訊息佇列中選出一個最老的任務, oldestTask
  • 迴圈系統記錄任務開始時間, 並將 oldestTask 記為 currentRunningTask
  • 任務執行完成後, currentRunningTask 設為null, 並從對應task queue中刪除該任務
  • 統計任務執行完成的時長(這是為什麼settimeout未按照預設時間進行回撥時瀏覽器報警的原因)

其實巨集任務已經能滿足大多數的需求,在硬體條件還不夠號的時候的早期瀏覽器實現並沒有微任務這一說.巨集任務時間粒度較大且執行間隔無法準確控制(settimeout的例子) 而且隨著硬體效能提升, 需要對任務的時間精度有更高的需求, 巨集任務就難以勝任.巨集任務效能跟不上. 比如對DOM變化的監聽

微任務

微任務的定義就是在當前巨集任務結束之前(上下文呼叫棧已經只剩window了--這是checkpoint),進行的函式回撥.

微任務的運作方式

當js在執行指令碼時, V8會為其建立一個全域性上下文, 同時建立一個微任務佇列. 在全域性上下文的函式執行過程中, 如果建立了微任務, 就將其放入微任務佇列中. 這個佇列只允許V8引擎的訪問, js無法直接獲取.

微任務的產生時機

主要有兩種方式

  • MutationObserver 監控某個DOM節點的變化, 繫結回撥, 然後通過js來修改這個節點時(包括新增刪除部分子節點)即會將回撥函式放入微任務佇列中
  • Promise 當呼叫Promise.resolve()或Promise.reject()時會產生微任務放入到微任務佇列中(分別對應then的第一個和第二個引數, 以及catch)

微任務佇列的檢查點(checkpoint)

當巨集任務中的js主函式已經執行完畢, js引擎準備退出全域性執行上下文並清空呼叫棧時, js引擎此刻回去查詢微任務佇列. 然後按照順序執行. 除此之外還有其他的檢查點(都不太重要了), 詳情可參考 .此時如果在微任務執行期間產生了微任務會繼續往微任務佇列中進行新增. V8會一直迴圈執行, 直到佇列為空.

舉個例子

console.log(1)
setTimeout(() => {
    Promise.resolve(3).then(console.log)
    console.log(2)
}, 1000)
複製程式碼

顯而易見, 列印順序為 1,2,3.我們來分析一下執行過程

  1. 建立window的全域性執行上下文
  2. 呼叫console列印輸出1
  3. 呼叫setTimeout函式, 往延遲佇列中新增回撥函式, 並記錄定時時長為1s
  4. 等待1s後將回撥函式從任務佇列中取出執行, 此為巨集任務
  5. 遇到Promise.resolve, 建立console.log的微任務放到微任務佇列中
  6. 列印輸出2
  7. 呼叫棧只剩全域性上下文, 此刻檢查微任務佇列不為空, 執行微任務列印輸出3
  8. 微任務為空, 退出當前巨集任務執行

需要注意幾點

  1. 微任務都是與巨集任務繫結的, 每個巨集任務會建立自己的微任務
  2. 微任務的執行時長會影響當前紅任務的時長, 比如1個巨集任務建立了100個微任務, 每個微任務需要10ms , 那麼可以說執行微任務就使得巨集任務的時間延長了1000ms, 所以一定要注意控制微任務的執行時長
  3. 在一個巨集任務中既可以建立微任務也可以建立新的巨集任務, 但是微任務總是早於巨集任務執行

用得最多的可能就是Promise建立的微任務了, 但其實MutationOvserver也值得說道, 它的來歷頗為波折. 他的前身時Mutation Event, 而MutationEvent的來源便是實時監控DOM變化的需求.(在沒有MutationEvent之前都是使用settimeout或setInterval進行輪詢的機制, 但就是做不到實時性). 但MutationEvent是一個同步的回撥, 也就是意味著如果有DOM的更改, 會立即呼叫回撥函式, 渲染引擎需要立即執行JavaScript, 比如在一次巨集任務中更改了1個節點10次, 就會觸發10此回撥, 每個回撥都需要假設100ms, 那麼就是1s的演示, 這樣假設瀏覽器正在執行一個動畫效果, 那麼就會造成卡頓.

正因同步呼叫的效能問題, MutationEvent 終被廢棄, 取而代之的是 MutationObserver, 與MutationEvent很大的不同點是前者為非同步的呼叫, 這樣比如在一個巨集任務中修改了1個節點10次,那麼就指揮觸發一次的非同步呼叫, 這樣即使比較頻繁的操作DOM(當然也應盡力避免), 效能上也不會有很大問題.那麼這個非同步是應該使用巨集任務還是微任務呢? 前面說了, 有實時性的要求, 所以需要在DOM更改後儘快的回撥和執行, 那麼放在微任務中就再適合不過, 所以MutationObserver採用了"非同步+微任務"的策略, 非同步解決阻塞主執行緒的效能問題, 微任務解決實時性的問題.

測試

最後還是來一段程式碼吧, 之前面試的時候有被問到

function executor(resolve, reject) {
    let rand = Math.random()
    console.log(1)
    if(rand > 0.5) resolve()
    reject()
}
const p0 = new Promise(executor)
const p1 = p0.then(_ => {
    console.log('success-0')
    return new Promise(executor)
})
const p2 = p1.then(_=> {
    console.log('success-1')
    return new Promise(executor)
})
p2.catch(_=>{
    console.log('error')
})
console.log(2)
複製程式碼

注意, 以上的過程都是在同一個巨集任務中進行的, 且答案不唯一, 但只要理解了微任務, 基本上都能說明白.

相關文章