DOM更新與瀏覽器事件循環
通常我們說DOM更新發生在一個宏任務執行完之後,但並不是每次宏任務執行後都會進行DOM更新。瀏覽器會維護一個相對穩定的幀率,根據硬件條件和頁面性能表現。那麼事件循環如何判斷是否應該更新DOM呢?
先上一波HTML規範指定的事件循環處理章程:
- 從事件循環的任務隊列中取一個任務隊列taskQueue,這裡的任務隊列指的是宏任務隊列。注意,一個事件循環可能並不只有一個宏任務隊列。具體有哪些以及這裡怎麼選由用戶代理決定。
- taskQueue中出隊一個(宏)任務oldestTask,並設置事件循環的當前執行的任務為taskQueue。
- 執行taskQueue。
- 設置事件循環的當前執行的任務為null。
- 執行微任務隊列中的(task)。
- 處理DOM更新。
上面的描述省略了一些步驟,完整版參考規範。注意worker環境的事件循環不在本文討論範疇之內。
可以發現DOM更新確實發生在兩個宏任務執行之間,但如果我們深入DOM更新步驟,會發現還有個渲染機會(rendering opportunity)的概念。DOM更新時會忽略沒有渲染機會的文檔。
那麼什麼時候一個DOM文檔(準確說是其瀏覽上下文)擁有渲染機會呢?前面說過,瀏覽器會維護一個相對穩定的幀率,例如30fps,那麼1秒內應該最多擁有30次渲染機會。瀏覽器根據這些條件和是否有DOM更新(DOM操作,動畫等),決定文檔是否應該擁有渲染機會。相對穩定的意思是,當硬件條件或者頁面性能導致無法維持較高幀率時可能會降幀。雖然這並不是規範規定的,但規範中以此為例,而且主流瀏覽器應該都是這麼實現的。
了解這些有什麼用呢?我們可以更精細地把握自己的代碼。考慮以下代碼:
setTimeout(() => {
//依赖DOM的操作
}, 0);
我們可能會用定時器來等待dom更新後執行一些操作,因為定時器創建宏任務,而DOM會在當前宏任務執行結束後更新(錯誤)。
有趣的是,這往往也不會出大問題。一來一般這時候已經執行了不少代碼,可能正好瀏覽器在當前宏任務執行完後更新了DOM,二來DOM操作往往會導致DOM強制更新(如讀取DOM元素幾何相關的數值等,但不絕對觸發強制更新),也可能會得到想要的結果。
然後結果是,bug若有若無的,全看人品。啊,我測試的時候好好的,演示的時候怎麼就...😅
上面的例子不夠具體,來個更實際的。假設要實現一個DOM元素從遠處飛回來的動畫,我們不用css animation,用transform。
首先我們需要給DOM元素一個初始偏移量,否則起點就是終點,就沒有動畫效果了。然後給它設置transition樣式,並把偏移量還原。
// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
// ...???
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
為了讓動畫從初始偏移量開始,我們需要在???前後代碼執行之間觸發一次DOM更新。
最簡單的就是觸發DOM強制更新。
// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
el.getBoundingClientRect();
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
通過getBoundingClientRect()讀取元素的幾何信息,迫使DOM重新計算元素的屬性。但這可能造成額外的運算,帶來性能損失。雖然一般場景下問題不大。
如果我們想將???後面的代碼延遲到下一幀之後執行呢?靠setTimeout()的話,timeout設置小了就回到bug與人品的故事了,設置大了又會產生卡頓影響體驗。這時候我們可以通過一個神奇的API來解決:requestAnimationFrame。
// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
requestAnimationFrame(() => {
setTimeout(() => {
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
}, 0);
});
首先,requestAnimationFrame()的回調會在下一幀繪制之前執行。我的理解是,執行requestAnimationFrame的回調函數列表作為一個宏任務被調度,並且此時該DOM文檔獲得渲染機會,因此回調執行結束後DOM將更新,而當回調函數中設置的定時器任務被執行的時候已經處於新的宏任務中,此時DOM文檔已更新。
注意:對於處理一個瀏覽上下文的requestAnimationFrame回調時的宏任務和下一個宏任務之間,該瀏覽上下文一定獲得會渲染機會是否成立,尚未找到規範證明,僅作參考。
本文鏈接:https://blog.mattuy.top/archives/dom-update-and-browser-event-loop