渲染篇 4:千方百計(jì)——Event Loop 與異步更新策略

千方百計(jì)——Event Loop與異步更新策略

Vue 和 React 都實(shí)現(xiàn)了異步更新策略。雖然實(shí)現(xiàn)的方式不盡相同,但都達(dá)到了減少 DOM 操作、避免過(guò)度渲染的目的。通過(guò)研究框架的運(yùn)行機(jī)制,其設(shè)計(jì)思路將深化我們對(duì) DOM 優(yōu)化的理解,其實(shí)現(xiàn)手法將拓寬我們對(duì) DOM 實(shí)踐的認(rèn)知。

本節(jié)我們將基于 Event Loop 機(jī)制,對(duì) Vue 的異步更新策略作探討。

前置知識(shí):Event Loop 中的“渲染時(shí)機(jī)”

搞懂 Event Loop,是理解 Vue 對(duì) DOM 操作優(yōu)化的第一步。

Micro-Task 與 Macro-Task

事件循環(huán)中的異步隊(duì)列有兩種:macro(宏任務(wù))隊(duì)列和micro(微任務(wù))隊(duì)列。

常見(jiàn)的 macro-task 比如:setTimeout、setInterval、setImmediate、script(整體代碼)、I/O操作、UI渲染等。
常見(jiàn)的 micro-task 比如:process.nextTick、Promise、MutationObserver 等。


事件循環(huán)

Event Loop 過(guò)程解析

基于對(duì) micro 和 macro 的認(rèn)知,我們來(lái)走一遍完整的事件循環(huán)過(guò)程。

一個(gè)完整的 Event Loop 過(guò)程,可以概括為以下階段:

  • 初始狀態(tài):調(diào)用??铡icro 隊(duì)列空,macro 隊(duì)列里有且只有一個(gè) script 腳本(整體代碼)。

  • 全局上下文(script 標(biāo)簽)被推入調(diào)用棧,同步代碼執(zhí)行。在執(zhí)行的過(guò)程中,通過(guò)對(duì)一些接口的調(diào)用,可以產(chǎn)生新的 macro-task 與 micro-task,它們會(huì)分別被推入各自的任務(wù)隊(duì)列里。同步代碼執(zhí)行完了,script 腳本會(huì)被移出 macro 隊(duì)列,這個(gè)過(guò)程本質(zhì)上是隊(duì)列的 macro-task 的執(zhí)行和出隊(duì)的過(guò)程。

  • 上一步我們出隊(duì)的是一個(gè) macro-task,這一步我們處理的是 micro-task。但需要注意的是:當(dāng) macro-task 出隊(duì)時(shí),任務(wù)是一個(gè)一個(gè)執(zhí)行的;而 micro-task 出隊(duì)時(shí),任務(wù)是一隊(duì)一隊(duì)執(zhí)行的(如下圖1所示)。因此,我們處理 micro 隊(duì)列這一步,會(huì)逐個(gè)執(zhí)行隊(duì)列中的任務(wù)并把它出隊(duì),直到隊(duì)列被清空。

    圖1

  • 執(zhí)行渲染操作,更新界面(敲黑板劃重點(diǎn))。

  • 檢查是否存在 Web worker 任務(wù),如果有,則對(duì)其進(jìn)行處理 。

(上述過(guò)程循環(huán)往復(fù),直到兩個(gè)隊(duì)列都清空)

我們總結(jié)一下,每一次循環(huán)都是一個(gè)這樣的過(guò)程:
圖2

渲染的時(shí)機(jī)

大家現(xiàn)在思考一個(gè)這樣的問(wèn)題:假如我想要在異步任務(wù)里進(jìn)行DOM更新,我該把它包裝成 micro 還是 macro 呢?

我們先假設(shè)它是一個(gè) macro 任務(wù),比如我在 script 腳本中用 setTimeout 來(lái)處理它:

// task是一個(gè)用于修改DOM的回調(diào)
setTimeout(task, 0)

現(xiàn)在 task 被推入的 macro 隊(duì)列。但因?yàn)?script 腳本本身是一個(gè) macro 任務(wù),所以本次執(zhí)行完 script 腳本之后,下一個(gè)步驟就要去處理 micro 隊(duì)列了,再往下就去執(zhí)行了一次 render,對(duì)不對(duì)?

但本次render我的目標(biāo)task其實(shí)并沒(méi)有執(zhí)行,想要修改的DOM也沒(méi)有修改,因此這一次的render其實(shí)是一次無(wú)效的render。

macro 不 ok,我們轉(zhuǎn)向 micro 試試看。我用 Promise 來(lái)把 task 包裝成是一個(gè) micro 任務(wù):

Promise.resolve().then(task)

那么我們結(jié)束了對(duì) script 腳本的執(zhí)行,是不是緊接著就去處理 micro-task 隊(duì)列了?micro-task 處理完,DOM 修改好了,緊接著就可以走 render 流程了——不需要再消耗多余的一次渲染,不需要再等待一輪事件循環(huán),直接為用戶呈現(xiàn)最即時(shí)的更新結(jié)果。

因此,我們更新 DOM 的時(shí)間點(diǎn),應(yīng)該盡可能靠近渲染的時(shí)機(jī)。當(dāng)我們需要在異步任務(wù)中實(shí)現(xiàn) DOM 修改時(shí),把它包裝成 micro 任務(wù)是相對(duì)明智的選擇。

生產(chǎn)實(shí)踐:異步更新策略——以 Vue 為例

什么是異步更新?
當(dāng)我們使用 Vue 或 React 提供的接口去更新數(shù)據(jù)時(shí),這個(gè)更新并不會(huì)立即生效,而是會(huì)被推入到一個(gè)隊(duì)列里。待到適當(dāng)?shù)臅r(shí)機(jī),隊(duì)列中的更新任務(wù)會(huì)被批量觸發(fā)。這就是異步更新。

異步更新可以幫助我們避免過(guò)度渲染,是我們上節(jié)提到的“讓 JS 為 DOM 分壓”的典范之一。

異步更新的優(yōu)越性

異步更新的特性在于它只看結(jié)果,因此渲染引擎不需要為過(guò)程買(mǎi)單。

最典型的例子,比如有時(shí)我們會(huì)遇到這樣的情況:

// 任務(wù)一
this.content = '第一次測(cè)試'
// 任務(wù)二
this.content = '第二次測(cè)試'
// 任務(wù)三
this.content = '第三次測(cè)試'

我們?cè)谌齻€(gè)更新任務(wù)中對(duì)同一個(gè)狀態(tài)修改了三次,如果我們采取傳統(tǒng)的同步更新策略,那么就要操作三次 DOM。但本質(zhì)上需要呈現(xiàn)給用戶的目標(biāo)內(nèi)容其實(shí)只是第三次的結(jié)果,也就是說(shuō)只有第三次的操作是有意義的——我們白白浪費(fèi)了兩次計(jì)算。

但如果我們把這三個(gè)任務(wù)塞進(jìn)異步更新隊(duì)列里,它們會(huì)先在 JS 的層面上被批量執(zhí)行完畢。當(dāng)流程走到渲染這一步時(shí),它僅僅需要針對(duì)有意義的計(jì)算結(jié)果操作一次 DOM——這就是異步更新的妙處。

Vue狀態(tài)更新手法:nextTick

Vue 每次想要更新一個(gè)狀態(tài)的時(shí)候,會(huì)先把它這個(gè)更新操作給包裝成一個(gè)異步操作派發(fā)出去。這件事情,在源碼中是由一個(gè)叫做 nextTick 的函數(shù)來(lái)完成的:

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)
    }
  })
  // 檢查上一個(gè)異步任務(wù)隊(duì)列(即名為callbacks的任務(wù)數(shù)組)是否派發(fā)和執(zhí)行完畢了。pending此處相當(dāng)于一個(gè)鎖
  if (!pending) {
    // 若上一個(gè)異步任務(wù)隊(duì)列已經(jīng)執(zhí)行完畢,則將pending設(shè)定為true(把鎖鎖上)
    pending = true
    // 是否要求一定要派發(fā)為macro任務(wù)
    if (useMacroTask) {
      macroTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

我們看到,Vue 的異步任務(wù)默認(rèn)情況下都是用 Promise 來(lái)包裝的,也就是是說(shuō)它們都是 micro-task。這一點(diǎn)和我們“前置知識(shí)”中的渲染時(shí)機(jī)的分析不謀而合。

為了帶大家熟悉一下常見(jiàn)的 macro 和 micro 派發(fā)方式、加深對(duì) Event Loop 的理解,我們繼續(xù)細(xì)化解析一下 macroTimeFunc() 和 microTimeFunc() 兩個(gè)方法。

macroTimeFunc() 是這么實(shí)現(xiàn)的:

// macro首選setTmmediate 這個(gè)兼容性最差
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 {
  // 兼容性最好的派發(fā)方式是setTimeout
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

microTimeFunc() 是這么實(shí)現(xiàn)的:

// 簡(jiǎn)單粗暴 不是ios全都給我去Promise 如果不兼容promise 那么你只能將就一下變成macro了
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // 在有問(wèn)題的uiwebview中,promise.then不會(huì)完全中斷,但它可能會(huì)陷入一種奇怪的狀態(tài),
    // 即回調(diào)被推到微任務(wù)隊(duì)列中,但隊(duì)列不會(huì)被刷新,直到瀏覽器需要做一些其他工作,例如處
    // 理計(jì)時(shí)器。因此,我們可以通過(guò)添加一個(gè)空計(jì)時(shí)器來(lái)“強(qiáng)制”刷新微任務(wù)隊(duì)列。
    if (isIOS) setTimeout(noop)
  }
} else {
  // 如果無(wú)法派發(fā)micro,就退而求其次派發(fā)為macro
  microTimerFunc = macroTimerFunc
}

我們注意到,無(wú)論是派發(fā) macro 任務(wù)還是派發(fā) micro 任務(wù),派發(fā)的任務(wù)對(duì)象都是一個(gè)叫做 flushCallbacks 的東西,這個(gè)東西做了什么呢?

flushCallbacks 源碼如下:

function flushCallbacks () {
 pending = false
  // callbacks在nextick中出現(xiàn)過(guò) 它是任務(wù)數(shù)組(隊(duì)列)
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 將callbacks中的任務(wù)逐個(gè)取出執(zhí)行
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

現(xiàn)在我們理清楚了:Vue 中每產(chǎn)生一個(gè)狀態(tài)更新任務(wù),它就會(huì)被塞進(jìn)一個(gè)叫 callbacks 的數(shù)組(此處是任務(wù)隊(duì)列的實(shí)現(xiàn)形式)中。這個(gè)任務(wù)隊(duì)列在被丟進(jìn) micro 或 macro 隊(duì)列之前,會(huì)先去檢查當(dāng)前是否有異步更新任務(wù)正在執(zhí)行(即檢查 pending 鎖)。如果確認(rèn) pending 鎖是開(kāi)著的(false),就把它設(shè)置為鎖上(true),然后對(duì)當(dāng)前 callbacks 數(shù)組的任務(wù)進(jìn)行派發(fā)(丟進(jìn) micro 或 macro 隊(duì)列)和執(zhí)行。設(shè)置 pending 鎖的意義在于保證狀態(tài)更新任務(wù)的有序進(jìn)行,避免發(fā)生混亂。

本小節(jié)我們從性能優(yōu)化的角度出發(fā),通過(guò)解析Vue源碼,對(duì)異步更新這一高效的 DOM 優(yōu)化手段有了感性的認(rèn)知。同時(shí)幫助大家進(jìn)一步熟悉了 micro 與 macro 在生產(chǎn)中的應(yīng)用,加深了對(duì) Event Loop 的理解。事實(shí)上,Vue 源碼中還有許多值得稱道的生產(chǎn)實(shí)踐,其設(shè)計(jì)模式與編碼細(xì)節(jié)都值得我們?nèi)ゼ?xì)細(xì)品味。對(duì)這個(gè)話題感興趣的同學(xué),課后不妨移步 Vue運(yùn)行機(jī)制解析 進(jìn)行探索。

小結(jié)

至此,我們的 DOM 優(yōu)化之路才走完了一半。

以上我們都在討論“如何減少 DOM 操作”的話題。這個(gè)話題比較宏觀——DOM 操作也分很多種,它們帶來(lái)的變化各不相同。有的操作只觸發(fā)重繪,這時(shí)我們的性能損耗就小一些;有的操作會(huì)觸發(fā)回流,這時(shí)我們更“肉疼”一些。那么如何理解回流與重繪,如何借助這些理解去提升頁(yè)面渲染效率呢?

結(jié)束了 JS 的征程,我們下面就走進(jìn) CSS 的世界一窺究竟。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容