事件循環和異步更新

想方設法——Event Loop 與異步更新策略

Vue 和 React 都實現了異步更新策略。雖然實現的方式不盡相同,但都達到了減小 DOM 操做、避免過分渲染的目的。經過研究框架的運行機制,其設計思路將深化咱們對 DOM 優化的理解,其實現手法將拓寬咱們對 DOM 實踐的認知。ios

本節咱們將基於 Event Loop 機制,對 Vue 的異步更新策略做探討。設計模式

前置知識:Event Loop 中的「渲染時機」

搞懂 Event Loop,是理解 Vue 對 DOM 操做優化的第一步。數組

Micro-Task 與 Macro-Task

事件循環中的異步隊列有兩種:macro(宏任務)隊列和 micro(微任務)隊列。promise

常見的 macro-task 好比: setTimeout、setInterval、 setImmediate、script(總體代碼)、 I/O 操做、UI 渲染等。
常見的 micro-task 好比: process.nextTick、Promise、MutationObserver 等。性能優化

Event Loop 過程解析

基於對 micro 和 macro 的認知,咱們來走一遍完整的事件循環過程。bash

一個完整的 Event Loop 過程,能夠歸納爲如下階段:框架

  • 初始狀態:調用棧空。micro 隊列空,macro 隊列裏有且只有一個 script 腳本(總體代碼)。異步

  • 全局上下文(script 標籤)被推入調用棧,同步代碼執行。在執行的過程當中,經過對一些接口的調用,能夠產生新的 macro-task 與 micro-task,它們會分別被推入各自的任務隊列裏。同步代碼執行完了,script 腳本會被移出 macro 隊列,這個過程本質上是隊列的 macro-task 的執行和出隊的過程函數

  • 上一步咱們出隊的是一個 macro-task,這一步咱們處理的是 micro-task。但須要注意的是:當 macro-task 出隊時,任務是一個一個執行的;而 micro-task 出隊時,任務是一隊一隊執行的(以下圖所示)。所以,咱們處理 micro 隊列這一步,會逐個執行隊列中的任務並把它出隊,直到隊列被清空。oop

  • 執行渲染操做,更新界面(敲黑板劃重點)。

  • 檢查是否存在 Web worker 任務,若是有,則對其進行處理 。

(上述過程循環往復,直到兩個隊列都清空)

咱們總結一下,每一次循環都是一個這樣的過程:

渲染的時機

你們如今思考一個這樣的問題:假如我想要在異步任務裏進行DOM更新,我該把它包裝成 micro 仍是 macro 呢?

咱們先假設它是一個 macro 任務,好比我在 script 腳本中用 setTimeout 來處理它:

// task是一個用於修改DOM的回調
setTimeout(task, 0)

複製代碼

如今 task 被推入的 macro 隊列。但由於 script 腳本自己是一個 macro 任務,因此本次執行完 script 腳本以後,下一個步驟就要去處理 micro 隊列了,再往下就去執行了一次 render,對不對?

但本次render個人目標task其實並無執行,想要修改的DOM也沒有修改,所以這一次的render實際上是一次無效的render。

macro 不 ok,咱們轉向 micro 試試看。我用 Promise 來把 task 包裝成是一個 micro 任務:

Promise.resolve().then(task)

複製代碼

那麼咱們結束了對 script 腳本的執行,是否是緊接着就去處理 micro-task 隊列了?micro-task 處理完,DOM 修改好了,緊接着就能夠走 render 流程了——不須要再消耗多餘的一次渲染,不須要再等待一輪事件循環,直接爲用戶呈現最即時的更新結果。

所以,咱們更新 DOM 的時間點,應該儘量靠近渲染的時機。當咱們須要在異步任務中實現 DOM 修改時,把它包裝成 micro 任務是相對明智的選擇

生產實踐:異步更新策略——以 Vue 爲例

什麼是異步更新?

當咱們使用 Vue 或 React 提供的接口去更新數據時,這個更新並不會當即生效,而是會被推入到一個隊列裏。待到適當的時機,隊列中的更新任務會被批量觸發。這就是異步更新。

異步更新能夠幫助咱們避免過分渲染,是咱們上節提到的「讓 JS 爲 DOM 分壓」的典範之一。

異步更新的優越性

異步更新的特性在於它只看結果,所以渲染引擎不須要爲過程買單

最典型的例子,好比有時咱們會遇到這樣的狀況:

// 任務一
this.content = '第一次測試'
// 任務二
this.content = '第二次測試'
// 任務三
this.content = '第三次測試'

複製代碼

咱們在三個更新任務中對同一個狀態修改了三次,若是咱們採起傳統的同步更新策略,那麼就要操做三次 DOM。但本質上須要呈現給用戶的目標內容其實只是第三次的結果,也就是說只有第三次的操做是有意義的——咱們白白浪費了兩次計算。

但若是咱們把這三個任務塞進異步更新隊列裏,它們會先在 JS 的層面上被批量執行完畢。當流程走到渲染這一步時,它僅僅須要針對有意義的計算結果操做一次 DOM——這就是異步更新的妙處。

Vue狀態更新手法:nextTick

Vue 每次想要更新一個狀態的時候,會先把它這個更新操做給包裝成一個異步操做派發出去。這件事情,在源碼中是由一個叫作 nextTick 的函數來完成的:

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 檢查上一個異步任務隊列(即名爲callbacks的任務數組)是否派發和執行完畢了。pending此處至關於一個鎖
  if (!pending) {
    // 若上一個異步任務隊列已經執行完畢,則將pending設定爲true(把鎖鎖上)
    pending = true
    // 是否要求必定要派發爲macro任務
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      // 若是不說明必定要macro 大家就全都是micro
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

複製代碼

咱們看到,Vue 的異步任務默認狀況下都是用 Promise 來包裝的,也就是是說它們都是 micro-task。這一點和咱們「前置知識」中的渲染時機的分析不謀而合。

爲了帶你們熟悉一下常見的 macro 和 micro 派發方式、加深對 Event Loop 的理解,咱們繼續細化解析一下 macroTimeFunc() 和 microTimeFunc() 兩個方法。

macroTimeFunc() 是這麼實現的:

// macro首選setImmediate 這個兼容性最差
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
    isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]'
  )) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  // 兼容性最好的派發方式是setTimeout
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

複製代碼

microTimeFunc() 是這麼實現的:

// 簡單粗暴 不是ios全都給我去Promise 若是不兼容promise 那麼你只能將就一下變成macro了
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // in problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
} else {
  // 若是沒法派發micro,就退而求其次派發爲macro
  microTimerFunc = macroTimerFunc
}

複製代碼

咱們注意到,不管是派發 macro 任務仍是派發 micro 任務,派發的任務對象都是一個叫作 flushCallbacks 的東西,這個東西作了什麼呢?

flushCallbacks 源碼以下:

function flushCallbacks () {
  pending = false
  // callbacks在nextick中出現過 它是任務數組(隊列)
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 將callbacks中的任務逐個取出執行
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

複製代碼

如今咱們理清楚了:Vue 中每產生一個狀態更新任務,它就會被塞進一個叫 callbacks 的數組(此處是任務隊列的實現形式)中。這個任務隊列在被丟進 micro 或 macro 隊列以前,會先去檢查當前是否有異步更新任務正在執行(即檢查 pending 鎖)。若是確認 pending 鎖是開着的(false),就把它設置爲鎖上(true),而後對當前 callbacks 數組的任務進行派發(丟進 micro 或 macro 隊列)和執行。設置 pending 鎖的意義在於保證狀態更新任務的有序進行,避免發生混亂。

本小節咱們從性能優化的角度出發,經過解析Vue源碼,對異步更新這一高效的 DOM 優化手段有了感性的認知。同時幫助你們進一步熟悉了 micro 與 macro 在生產中的應用,加深了對 Event Loop 的理解。事實上,Vue 源碼中還有許多值得稱道的生產實踐,其設計模式與編碼細節都值得咱們去細細品味。對這個話題感興趣的同窗,課後不妨移步 Vue運行機制解析 進行探索。

小結

以上咱們都在討論「如何減小 DOM 操做」的話題。這個話題比較宏觀——DOM 操做也分不少種,它們帶來的變化各不相同。有的操做只觸發重繪,這時咱們的性能損耗就小一些;有的操做會觸發迴流,這時咱們更「肉疼」一些。那麼如何理解迴流與重繪,如何藉助這些理解去提高頁面渲染效率呢?

相關文章
相關標籤/搜索