千方百計——Event Loop與異步更新策略
Vue 和 React 都實現(xiàn)了異步更新策略臣咖。雖然實現(xiàn)的方式不盡相同跃捣,但都達到了減少 DOM 操作、避免過度渲染的目的夺蛇。通過研究框架的運行機制疚漆,其設(shè)計思路將深化我們對 DOM 優(yōu)化的理解,其實現(xiàn)手法將拓寬我們對 DOM 實踐的認知刁赦。
本節(jié)我們將基于 Event Loop 機制娶聘,對 Vue 的異步更新策略作探討。
前置知識:Event Loop 中的“渲染時機”
搞懂 Event Loop甚脉,是理解 Vue 對 DOM 操作優(yōu)化的第一步丸升。
Micro-Task 與 Macro-Task
事件循環(huán)中的異步隊列有兩種:macro(宏任務(wù))隊列和micro(微任務(wù))隊列。
常見的 macro-task 比如:setTimeout牺氨、setInterval狡耻、setImmediate、script(整體代碼)猴凹、I/O操作夷狰、UI渲染等。
常見的 micro-task 比如:process.nextTick郊霎、Promise沼头、MutationObserver 等。
Event Loop 過程解析
基于對 micro 和 macro 的認知,我們來走一遍完整的事件循環(huán)過程进倍。
一個完整的 Event Loop 過程土至,可以概括為以下階段:
初始狀態(tài):調(diào)用棧空猾昆。micro 隊列空毙籽,macro 隊列里有且只有一個 script 腳本(整體代碼)。
全局上下文(script 標(biāo)簽)被推入調(diào)用棧毡庆,同步代碼執(zhí)行坑赡。在執(zhí)行的過程中,通過對一些接口的調(diào)用么抗,可以產(chǎn)生新的 macro-task 與 micro-task毅否,它們會分別被推入各自的任務(wù)隊列里。同步代碼執(zhí)行完了蝇刀,script 腳本會被移出 macro 隊列螟加,這個過程本質(zhì)上是隊列的 macro-task 的執(zhí)行和出隊的過程。
-
上一步我們出隊的是一個 macro-task吞琐,這一步我們處理的是 micro-task捆探。但需要注意的是:當(dāng) macro-task 出隊時,任務(wù)是一個一個執(zhí)行的站粟;而 micro-task 出隊時黍图,任務(wù)是一隊一隊執(zhí)行的(如下圖1所示)。因此奴烙,我們處理 micro 隊列這一步助被,會逐個執(zhí)行隊列中的任務(wù)并把它出隊,直到隊列被清空切诀。
圖1 執(zhí)行渲染操作揩环,更新界面(敲黑板劃重點)。
檢查是否存在 Web worker 任務(wù)幅虑,如果有丰滑,則對其進行處理 。
(上述過程循環(huán)往復(fù)倒庵,直到兩個隊列都清空)
我們總結(jié)一下褒墨,每一次循環(huán)都是一個這樣的過程:渲染的時機
大家現(xiàn)在思考一個這樣的問題:假如我想要在異步任務(wù)里進行DOM更新,我該把它包裝成 micro 還是 macro 呢哄芜?
我們先假設(shè)它是一個 macro 任務(wù)貌亭,比如我在 script 腳本中用 setTimeout 來處理它:
// task是一個用于修改DOM的回調(diào)
setTimeout(task, 0)
現(xiàn)在 task 被推入的 macro 隊列柬唯。但因為 script 腳本本身是一個 macro 任務(wù)认臊,所以本次執(zhí)行完 script 腳本之后,下一個步驟就要去處理 micro 隊列了锄奢,再往下就去執(zhí)行了一次 render失晴,對不對剧腻?
但本次render我的目標(biāo)task其實并沒有執(zhí)行,想要修改的DOM也沒有修改涂屁,因此這一次的render其實是一次無效的render书在。
macro 不 ok,我們轉(zhuǎn)向 micro 試試看拆又。我用 Promise 來把 task 包裝成是一個 micro 任務(wù):
Promise.resolve().then(task)
那么我們結(jié)束了對 script 腳本的執(zhí)行儒旬,是不是緊接著就去處理 micro-task 隊列了?micro-task 處理完帖族,DOM 修改好了栈源,緊接著就可以走 render 流程了——不需要再消耗多余的一次渲染,不需要再等待一輪事件循環(huán)竖般,直接為用戶呈現(xiàn)最即時的更新結(jié)果甚垦。
因此,我們更新 DOM 的時間點涣雕,應(yīng)該盡可能靠近渲染的時機艰亮。當(dāng)我們需要在異步任務(wù)中實現(xiàn) DOM 修改時,把它包裝成 micro 任務(wù)是相對明智的選擇挣郭。
生產(chǎn)實踐:異步更新策略——以 Vue 為例
什么是異步更新迄埃?
當(dāng)我們使用 Vue 或 React 提供的接口去更新數(shù)據(jù)時,這個更新并不會立即生效兑障,而是會被推入到一個隊列里调俘。待到適當(dāng)?shù)臅r機,隊列中的更新任務(wù)會被批量觸發(fā)旺垒。這就是異步更新彩库。
異步更新可以幫助我們避免過度渲染,是我們上節(jié)提到的“讓 JS 為 DOM 分壓”的典范之一先蒋。
異步更新的優(yōu)越性
異步更新的特性在于它只看結(jié)果骇钦,因此渲染引擎不需要為過程買單。
最典型的例子竞漾,比如有時我們會遇到這樣的情況:
// 任務(wù)一
this.content = '第一次測試'
// 任務(wù)二
this.content = '第二次測試'
// 任務(wù)三
this.content = '第三次測試'
我們在三個更新任務(wù)中對同一個狀態(tài)修改了三次眯搭,如果我們采取傳統(tǒng)的同步更新策略,那么就要操作三次 DOM业岁。但本質(zhì)上需要呈現(xiàn)給用戶的目標(biāo)內(nèi)容其實只是第三次的結(jié)果鳞仙,也就是說只有第三次的操作是有意義的——我們白白浪費了兩次計算。
但如果我們把這三個任務(wù)塞進異步更新隊列里笔时,它們會先在 JS 的層面上被批量執(zhí)行完畢棍好。當(dāng)流程走到渲染這一步時,它僅僅需要針對有意義的計算結(jié)果操作一次 DOM——這就是異步更新的妙處。
Vue狀態(tài)更新手法:nextTick
Vue 每次想要更新一個狀態(tài)的時候借笙,會先把它這個更新操作給包裝成一個異步操作派發(fā)出去扒怖。這件事情,在源碼中是由一個叫做 nextTick 的函數(shù)來完成的:
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)
}
})
// 檢查上一個異步任務(wù)隊列(即名為callbacks的任務(wù)數(shù)組)是否派發(fā)和執(zhí)行完畢了业稼。pending此處相當(dāng)于一個鎖
if (!pending) {
// 若上一個異步任務(wù)隊列已經(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ù)默認情況下都是用 Promise 來包裝的低散,也就是是說它們都是 micro-task俯邓。這一點和我們“前置知識”中的渲染時機的分析不謀而合。
為了帶大家熟悉一下常見的 macro 和 micro 派發(fā)方式熔号、加深對 Event Loop 的理解看成,我們繼續(xù)細化解析一下 macroTimeFunc() 和 microTimeFunc() 兩個方法。
macroTimeFunc() 是這么實現(xiàn)的:
// macro首選setTmmediate 這個兼容性最差
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() 是這么實現(xiàn)的:
// 簡單粗暴 不是ios全都給我去Promise 如果不兼容promise 那么你只能將就一下變成macro了
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
// 在有問題的uiwebview中跨嘉,promise.then不會完全中斷川慌,但它可能會陷入一種奇怪的狀態(tài),
// 即回調(diào)被推到微任務(wù)隊列中祠乃,但隊列不會被刷新梦重,直到瀏覽器需要做一些其他工作,例如處
// 理計時器亮瓷。因此琴拧,我們可以通過添加一個空計時器來“強制”刷新微任務(wù)隊列。
if (isIOS) setTimeout(noop)
}
} else {
// 如果無法派發(fā)micro嘱支,就退而求其次派發(fā)為macro
microTimerFunc = macroTimerFunc
}
我們注意到蚓胸,無論是派發(fā) macro 任務(wù)還是派發(fā) micro 任務(wù),派發(fā)的任務(wù)對象都是一個叫做 flushCallbacks 的東西除师,這個東西做了什么呢沛膳?
flushCallbacks 源碼如下:
function flushCallbacks () {
pending = false
// callbacks在nextick中出現(xiàn)過 它是任務(wù)數(shù)組(隊列)
const copies = callbacks.slice(0)
callbacks.length = 0
// 將callbacks中的任務(wù)逐個取出執(zhí)行
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
現(xiàn)在我們理清楚了:Vue 中每產(chǎn)生一個狀態(tài)更新任務(wù),它就會被塞進一個叫 callbacks 的數(shù)組(此處是任務(wù)隊列的實現(xiàn)形式)中汛聚。這個任務(wù)隊列在被丟進 micro 或 macro 隊列之前锹安,會先去檢查當(dāng)前是否有異步更新任務(wù)正在執(zhí)行(即檢查 pending 鎖)。如果確認 pending 鎖是開著的(false)倚舀,就把它設(shè)置為鎖上(true)叹哭,然后對當(dāng)前 callbacks 數(shù)組的任務(wù)進行派發(fā)(丟進 micro 或 macro 隊列)和執(zhí)行。設(shè)置 pending 鎖的意義在于保證狀態(tài)更新任務(wù)的有序進行痕貌,避免發(fā)生混亂风罩。
本小節(jié)我們從性能優(yōu)化的角度出發(fā),通過解析Vue源碼舵稠,對異步更新這一高效的 DOM 優(yōu)化手段有了感性的認知超升。同時幫助大家進一步熟悉了 micro 與 macro 在生產(chǎn)中的應(yīng)用入宦,加深了對 Event Loop 的理解。事實上廓俭,Vue 源碼中還有許多值得稱道的生產(chǎn)實踐,其設(shè)計模式與編碼細節(jié)都值得我們?nèi)ゼ毤毱肺栋ぁ@個話題感興趣的同學(xué)研乒,課后不妨移步 Vue運行機制解析 進行探索。
小結(jié)
至此淋硝,我們的 DOM 優(yōu)化之路才走完了一半雹熬。
以上我們都在討論“如何減少 DOM 操作”的話題。這個話題比較宏觀——DOM 操作也分很多種谣膳,它們帶來的變化各不相同竿报。有的操作只觸發(fā)重繪,這時我們的性能損耗就小一些继谚;有的操作會觸發(fā)回流烈菌,這時我們更“肉疼”一些。那么如何理解回流與重繪花履,如何借助這些理解去提升頁面渲染效率呢芽世?
結(jié)束了 JS 的征程,我們下面就走進 CSS 的世界一窺究竟诡壁。