寫一個(gè)JavaScript框架:比setTimeout更棒的定時(shí)執(zhí)行
這是 JavaScript 框架系列的第二章。在這一章里,我打算講一下在瀏覽器里的異步代碼不同執(zhí)行方式。你將了解定時(shí)器和事件循環(huán)之間的不同差異,比如 setTimeout 和 Promises。
這個(gè)系列是關(guān)于一個(gè)開源的客戶端框架,叫做 NX。在這個(gè)系列里,我主要解釋一下寫該框架不得不克服的主要困難。如果你對(duì) NX 感興趣可以參觀我們的 主頁(yè)。
這個(gè)系列包含以下幾個(gè)章節(jié):
- 項(xiàng)目結(jié)構(gòu)
- 定時(shí)執(zhí)行 (當(dāng)前章節(jié))
- 沙箱代碼評(píng)估
- 數(shù)據(jù)綁定介紹
- 數(shù)據(jù)綁定與 ES6 代理
- 自定義元素
- 客戶端路由
異步代碼執(zhí)行
你可能比較熟悉 Promise、process.nextTick()、setTimeout(),或許還有 requestAnimationFrame() 這些異步執(zhí)行代碼的方式。它們內(nèi)部都使用了事件循環(huán),但是它們?cè)诰_計(jì)時(shí)方面有一些不同。
在這一章里,我將解釋它們之間的不同,然后給大家演示怎樣在一個(gè)類似 NX 這樣的先進(jìn)框架里面實(shí)現(xiàn)一個(gè)定時(shí)系統(tǒng)。不用我們重新做一個(gè),我們將使用原生的事件循環(huán)來達(dá)到我們的目的。
事件循環(huán)
事件循環(huán)甚至沒有在 ES6 規(guī)范里提到。JavaScript 自身只有任務(wù)(Job)和任務(wù)隊(duì)列(job queue)。更加復(fù)雜的事件循環(huán)是在 NodeJS 和 HTML5 規(guī)范里分別定義的,因?yàn)檫@篇是針對(duì)前端的,我會(huì)在詳細(xì)說明后者。
事件循環(huán)可以被看做某個(gè)條件的循環(huán)。它不停的尋找新的任務(wù)來運(yùn)行。這個(gè)循環(huán)中的一次迭代叫做一個(gè)滴答(tick)。在一次滴答期間執(zhí)行的代碼稱為一次任務(wù)(task)。
- while (eventLoop.waitForTask()) {
- eventLoop.processNextTask()
- }
任務(wù)是同步代碼,它可以在循環(huán)中調(diào)度其它任務(wù)。一個(gè)簡(jiǎn)單的調(diào)用新任務(wù)的方式是 setTimeout(taskFn)。不管怎樣, 任務(wù)可能有很多來源,比如用戶事件、網(wǎng)絡(luò)或者 DOM 操作。
任務(wù)隊(duì)列
更復(fù)雜一些的是,事件循環(huán)可以有多個(gè)任務(wù)隊(duì)列。這里有兩個(gè)約束條件,相同任務(wù)源的事件必須在相同的隊(duì)列,以及任務(wù)必須按插入的順序進(jìn)行處理。除此之外,瀏覽器可以做任何它想做的事情。例如,它可以決定接下來處理哪個(gè)任務(wù)隊(duì)列。
- while (eventLoop.waitForTask()) {
- const taskQueue = eventLoop.selectTaskQueue()
- if (taskQueue.hasNextTask()) {
- taskQueue.processNextTask()
- }
- }
用這個(gè)模型,我們不能精確的控制定時(shí)。如果用 setTimeout()瀏覽器可能決定先運(yùn)行完其它幾個(gè)隊(duì)列才運(yùn)行我們的隊(duì)列。
微任務(wù)隊(duì)列
幸運(yùn)的是,事件循環(huán)還提供了一個(gè)叫做微任務(wù)(microtask)隊(duì)列的單一隊(duì)列。當(dāng)前任務(wù)結(jié)束的時(shí)候,微任務(wù)隊(duì)列會(huì)清空每個(gè)滴答里的任務(wù)。
- while (eventLoop.waitForTask()) {
- const taskQueue = eventLoop.selectTaskQueue()
- if (taskQueue.hasNextTask()) {
- taskQueue.processNextTask()
- }
- const microtaskQueue = eventLoop.microTaskQueue
- while (microtaskQueue.hasNextMicrotask()) {
- microtaskQueue.processNextMicrotask()
- }
- }
最簡(jiǎn)單的調(diào)用微任務(wù)的方法是 Promise.resolve().then(microtaskFn)。微任務(wù)按照插入順序進(jìn)行處理,并且由于僅存在一個(gè)微任務(wù)隊(duì)列,瀏覽器不會(huì)把時(shí)間弄亂了。
此外,微任務(wù)可以調(diào)度新的微任務(wù),它將插入到同一個(gè)隊(duì)列,并在同一個(gè)滴答內(nèi)處理。
繪制Rendering
***是繪制Rendering調(diào)度,不同于事件處理和分解,繪制并不是在單獨(dú)的后臺(tái)任務(wù)完成的。它是一個(gè)可以運(yùn)行在每個(gè)循環(huán)滴答結(jié)束時(shí)的算法。
在這里瀏覽器又有了許多自由:它可能在每個(gè)任務(wù)以后繪制,但是它也可能在好幾百個(gè)任務(wù)都執(zhí)行了以后也不繪制。
幸運(yùn)的是,我們有 requestAnimationFrame(),它在下一個(gè)繪制之前執(zhí)行傳遞的函數(shù)。我們最終的事件模型像這樣:
- while (eventLoop.waitForTask()) {
- const taskQueue = eventLoop.selectTaskQueue()
- if (taskQueue.hasNextTask()) {
- taskQueue.processNextTask()
- }
- const microtaskQueue = eventLoop.microTaskQueue
- while (microtaskQueue.hasNextMicrotask()) {
- microtaskQueue.processNextMicrotask()
- }
- if (shouldRender()) {
- applyScrollResizeAndCSS()
- runAnimationFrames()
- render()
- }
- }
現(xiàn)在用我們所知道知識(shí)來創(chuàng)建定時(shí)系統(tǒng)!
利用事件循環(huán)
和大多數(shù)現(xiàn)代框架一樣,NX 也是基于 DOM 操作和數(shù)據(jù)綁定的。批量操作和異步執(zhí)行以取得更好的性能表現(xiàn)?;谝陨侠碛晌覀冇?Promises、 MutationObservers 和 requestAnimationFrame()。
我們所期望的定時(shí)器是這樣的:
- 代碼來自于開發(fā)者
- 數(shù)據(jù)綁定和 DOM 操作由 NX 來執(zhí)行
- 開發(fā)者定義事件鉤子
- 瀏覽器進(jìn)行繪制
步驟 1
NX 寄存器對(duì)象基于 ES6 代理 以及 DOM 變動(dòng)基于MutationObserver (變動(dòng)觀測(cè)器)同步運(yùn)行(下一節(jié)詳細(xì)介紹)。 它作為一個(gè)微任務(wù)延遲直到步驟 2 執(zhí)行以后才做出反應(yīng)。這個(gè)延遲已經(jīng)在Promise.resolve().then(reaction) 進(jìn)行了對(duì)象轉(zhuǎn)換,并且它將通過變動(dòng)觀測(cè)器自動(dòng)運(yùn)行。
步驟 2
來自開發(fā)者的代碼(任務(wù))運(yùn)行完成。微任務(wù)由 NX 開始執(zhí)行所注冊(cè)。 因?yàn)樗鼈兪俏⑷蝿?wù),所以按序執(zhí)行。注意,我們?nèi)匀辉谕粋€(gè)滴答循環(huán)中。
步驟 3
開發(fā)者通過 requestAnimationFrame(hook) 通知 NX 運(yùn)行鉤子。這可能在滴答循環(huán)后發(fā)生。重要的是,鉤子運(yùn)行在下一次繪制之前和所有數(shù)據(jù)操作之后,并且 DOM 和 CSS 改變都已經(jīng)完成。
步驟 4
瀏覽器繪制下一個(gè)視圖。這也有可能發(fā)生在滴答循環(huán)之后,但是絕對(duì)不會(huì)發(fā)生在一個(gè)滴答的步驟 3 之前。
牢記在心里的事情
我們?cè)谠氖录h(huán)之上實(shí)現(xiàn)了一個(gè)簡(jiǎn)單而有效的定時(shí)系統(tǒng)。理論上講它運(yùn)行的很好,但是還是很脆弱,一個(gè)輕微的錯(cuò)誤可能會(huì)導(dǎo)致很嚴(yán)重的 BUG。
在一個(gè)復(fù)雜的系統(tǒng)當(dāng)中,最重要的就是建立一定的規(guī)則并在以后保持它們。在 NX 中有以下規(guī)則:
- 永遠(yuǎn)不用 setTimeout(fn, 0) 來進(jìn)行內(nèi)部操作
- 用相同的方法來注冊(cè)微任務(wù)
- 微任務(wù)僅供內(nèi)部操作
- 不要干預(yù)開發(fā)者鉤子運(yùn)行時(shí)間
規(guī)則 1 和 2
數(shù)據(jù)反射和 DOM 操作將按照操作順序執(zhí)行。這樣只要不混合就可以很好的延遲它們的執(zhí)行?;旌蠄?zhí)行會(huì)出現(xiàn)莫名其妙的問題。
setTimeout(fn, 0) 的行為完全不可預(yù)測(cè)。使用不同的方法注冊(cè)微任務(wù)也會(huì)發(fā)生混亂。例如,下面的例子中 microtask2 不會(huì)正確地在 microtask1 之前運(yùn)行。
- Promise.resolve().then().then(microtask1)
- Promise.resolve().then(microtask2)
分離開發(fā)者的代碼執(zhí)行和內(nèi)部操作的時(shí)間窗口是非常重要的?;旌线@兩種行為會(huì)導(dǎo)致不可預(yù)測(cè)的事情發(fā)生,并且它會(huì)需要開發(fā)者了解框架內(nèi)部。我想很多前臺(tái)開發(fā)者已經(jīng)有過類似經(jīng)歷。
結(jié)論
如果你對(duì) NX 框架感興趣,可以參觀我們的主頁(yè)。還可以在 GIT 上找到我們的源代碼。
在下一節(jié)我們?cè)僖?,我們將討?沙盒化代碼執(zhí)行!
你也可以給我們留言。