寫一個 JavaScript 框架:比 setTimeout 更棒的定時執行
這個系列包含以下幾個章節:
- 項目結構
- 定時執行 (當前章節)
- 沙箱代碼評估
- 數據綁定介紹
- 數據綁定與 ES6 代理
- 自定義元素
- 客戶端路由
非同步代碼執行
你可能比較熟悉 Promise
、process.nextTick()
、setTimeout()
,或許還有 requestAnimationFrame()
這些非同步執行代碼的方式。它們內部都使用了事件循環,但是它們在精確計時方面有一些不同。
在這一章里,我將解釋它們之間的不同,然後給大家演示怎樣在一個類似 NX 這樣的先進框架裡面實現一個定時系統。不用我們重新做一個,我們將使用原生的事件循環來達到我們的目的。
事件循環
事件循環甚至沒有在 ES6 規範里提到。JavaScript 自身只有任務(Job)和任務隊列(job queue)。更加複雜的事件循環是在 NodeJS 和 HTML5 規範里分別定義的,因為這篇是針對前端的,我會在詳細說明後者。
事件循環可以被看做某個條件的循環。它不停的尋找新的任務來運行。這個循環中的一次迭代叫做一個滴答(tick)。在一次滴答期間執行的代碼稱為一次任務(task)。
while (eventLoop.waitForTask()) {
eventLoop.processNextTask()
}
任務是同步代碼,它可以在循環中調度其它任務。一個簡單的調用新任務的方式是 setTimeout(taskFn)
。不管怎樣, 任務可能有很多來源,比如用戶事件、網路或者 DOM 操作。
任務隊列
更複雜一些的是,事件循環可以有多個任務隊列。這裡有兩個約束條件,相同任務源的事件必須在相同的隊列,以及任務必須按插入的順序進行處理。除此之外,瀏覽器可以做任何它想做的事情。例如,它可以決定接下來處理哪個任務隊列。
while (eventLoop.waitForTask()) {
const taskQueue = eventLoop.selectTaskQueue()
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask()
}
}
用這個模型,我們不能精確的控制定時。如果用 setTimeout()
瀏覽器可能決定先運行完其它幾個隊列才運行我們的隊列。
微任務隊列
幸運的是,事件循環還提供了一個叫做微任務(microtask)隊列的單一隊列。當前任務結束的時候,微任務隊列會清空每個滴答里的任務。
while (eventLoop.waitForTask()) {
const taskQueue = eventLoop.selectTaskQueue()
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask()
}
const microtaskQueue = eventLoop.microTaskQueue
while (microtaskQueue.hasNextMicrotask()) {
microtaskQueue.processNextMicrotask()
}
}
最簡單的調用微任務的方法是 Promise.resolve().then(microtaskFn)
。微任務按照插入順序進行處理,並且由於僅存在一個微任務隊列,瀏覽器不會把時間弄亂了。
此外,微任務可以調度新的微任務,它將插入到同一個隊列,並在同一個滴答內處理。
繪製
最後是 繪製 調度,不同於事件處理和分解,繪製並不是在單獨的後台任務完成的。它是一個可以運行在每個循環滴答結束時的演算法。
在這裡瀏覽器又有了許多自由:它可能在每個任務以後繪製,但是它也可能在好幾百個任務都執行了以後也不繪製。
幸運的是,我們有 requestAnimationFrame()
,它在下一個繪製之前執行傳遞的函數。我們最終的事件模型像這樣:
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()
}
}
現在用我們所知道知識來創建定時系統!
利用事件循環
和大多數現代框架一樣,NX 也是基於 DOM 操作和數據綁定的。批量操作和非同步執行以取得更好的性能表現。基於以上理由我們用 Promises
、 MutationObservers
和 requestAnimationFrame()
。
我們所期望的定時器是這樣的:
- 代碼來自於開發者
- 數據綁定和 DOM 操作由 NX 來執行
- 開發者定義事件鉤子
- 瀏覽器進行繪製
步驟 1
NX 寄存器對象基於 ES6 代理 以及 DOM 變動基於MutationObserver (變動觀測器)同步運行(下一節詳細介紹)。 它作為一個微任務延遲直到步驟 2 執行以後才做出反應。這個延遲已經在 Promise.resolve().then(reaction)
進行了對象轉換,並且它將通過變動觀測器自動運行。
步驟 2
來自開發者的代碼(任務)運行完成。微任務由 NX 開始執行所註冊。 因為它們是微任務,所以按序執行。注意,我們仍然在同一個滴答循環中。
步驟 3
開發者通過 requestAnimationFrame(hook)
通知 NX 運行鉤子。這可能在滴答循環後發生。重要的是,鉤子運行在下一次繪製之前和所有數據操作之後,並且 DOM 和 CSS 改變都已經完成。
步驟 4
瀏覽器繪製下一個視圖。這也有可能發生在滴答循環之後,但是絕對不會發生在一個滴答的步驟 3 之前。
牢記在心裡的事情
我們在原生的事件循環之上實現了一個簡單而有效的定時系統。理論上講它運行的很好,但是還是很脆弱,一個輕微的錯誤可能會導致很嚴重的 BUG。
在一個複雜的系統當中,最重要的就是建立一定的規則並在以後保持它們。在 NX 中有以下規則:
- 永遠不用
setTimeout(fn, 0)
來進行內部操作 - 用相同的方法來註冊微任務
- 微任務僅供內部操作
- 不要干預開發者鉤子運行時間
規則 1 和 2
數據反射和 DOM 操作將按照操作順序執行。這樣只要不混合就可以很好的延遲它們的執行。混合執行會出現莫名其妙的問題。
setTimeout(fn, 0)
的行為完全不可預測。使用不同的方法註冊微任務也會發生混亂。例如,下面的例子中 microtask2 不會正確地在 microtask1 之前運行。
Promise.resolve().then().then(microtask1)
Promise.resolve().then(microtask2)
規則 3 和 4
分離開發者的代碼執行和內部操作的時間窗口是非常重要的。混合這兩種行為會導致不可預測的事情發生,並且它會需要開發者了解框架內部。我想很多前台開發者已經有過類似經歷。
結論
如果你對 NX 框架感興趣,可以參觀我們的主頁。還可以在 GIT 上找到我們的源代碼。
在下一節我們再見,我們將討論 沙盒化代碼執行!
你也可以給我們留言。
via: https://blog.risingstack.com/writing-a-javascript-framework-execution-timing-beyond-settimeout/
作者:Bertalan Miklos 譯者:kokialoves 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive