探索 MutationObserver API 與傳統輪詢等待最終被建立的節點方法相比的優劣。
有時候,您需要操作尚未存在的 DOM 的某個部分。
出現這種需求的原因有很多,但你最常看到的是在處理第三方指令碼時,這些指令碼會非同步地將標記注入頁面。舉個例子,我最近需要在使用者關閉Google reCAPTCHA的挑戰時更新UI。諸如blur
事件的響應並沒有得到工具的正式支援,所以我打算自己來設計一個事件監聽器。然而,透過像.querySelector()
這樣的方法來嘗試訪問節點會返回null
,因為此時節點還沒有被瀏覽器渲染,並且我也不知道究竟什麼時候會被渲染。
為了更深入地探討這個問題,我設計了一個按鈕,讓它在隨機的時間內(0到5秒之間)被掛載到DOM中。如果我試圖從一開始就給這個按鈕新增一個事件監聽器,我就會得到一個異常。
// Simulating lazily-rendered HTML:
setTimeout(() => {
const button = document.createElement('button');
button.id = 'button';
button.innerText = 'Do Something!';
document.body.append(button);
}, randomBetweenMs(1000, 5000));
document.querySelector('#button').addEventListener('click', () => {
alert('clicked!')
});
// Error: Cannot read properties of null (reading 'addEventListener')
真的是毫無意外。你看到的所有程式碼都會被丟進呼叫棧並立即執行(當然,除了setTimeout
的回撥函式),所以當我試圖訪問按鈕時,我所得到的便是null
。
輪詢
為瞭解決這個問題,通常做法是使用輪詢,不停的查詢DOM直到節點出現。你可能會看到使用setInterval
或者setTimeout
這樣的方法,下面是使用遞迴的例子:
function attachListenerToButton() {
let button = document.getElementById('button');
if (button) {
button.addEventListener('click', () => alert('clicked!'));
return;
}
// If the node doesn't exist yet, try
// again on the next turn of the event loop.
setTimeout(attachListenerToButton);
}
attachListenerToButton();
或者,你可能已經見過一種基於Promise的方法,這感覺更現代一些:
async function attachListenerToButton() {
let button = document.getElementById('button');
while (!button) {
// If the node doesn't exist yet, try
// again on the next turn of the event loop.
button = document.getElementById('button');
await new Promise((resolve) => setTimeout(resolve));
}
button.addEventListener('click', () => alert('clicked!'));
}
attachListenerToButton();
不管怎麼說,這種策略都有非同小可的代價--主要是效能。在這兩個版本中,移除setTimeout()
會導致指令碼完全同步執行,阻塞主執行緒,以及其他需要在主執行緒上進行的任務。沒有輸入事件會被處理。你的標籤會被凍結。混亂不會隨之而來。
在這裡插入一個setTimeout()
(或者setInterval
),將下一次嘗試推遲到到事件迴圈的下一個迭代中,這樣就可以在這期間執行其他任務。但你仍然在重複地佔用呼叫棧,等待你的節點出現。如果你想讓你的程式碼很好地管理事件迴圈,那這就太不理想了。
你可以透過增加查詢的間隔時間(比如每200ms查詢一次)來減少呼叫棧的膨脹。但是你會面臨這樣的風險,即在節點出現和你的工作執行之間發生了意想不到的事情。例如,如果你正在新增一個click
事件監聽器,你不希望使用者在幾毫秒後才附加監聽器之前就有機會點選該元素。這樣的問題可能很少見,但當你稍後除錯可能出錯的程式碼時,它們肯定會帶來煩惱。
MutationObserver()
MutationObserver API 已經存在一段時間了,在現代瀏覽器中得到了廣泛支援。它的作用很簡單:當 DOM 樹發生變化(包括插入節點時)時執行某些操作。但是作為原生瀏覽器 API,你不需要像輪詢一樣考慮效能問題。觀察 body
內部任何變化的基本設定如下所示:
const domObserver = new MutationObserver((mutationList) => {
// document.body has changed! Do something.
});
domObserver.observe(document.body, { childList: true, subtree: true });
對於我們構造的示例,進一步完善也相當簡單。每當樹發生變化時,我們將查詢特定的節點。如果節點存在,則附加監聽器。
const domObserver = new MutationObserver(() => {
const button = document.getElementById('button');
if (button) {
button.addEventListener('click', () => alert('clicked!'));
}
});
domObserver.observe(document.body, { childList: true, subtree: true });
我們傳遞給 .observe()
的選項很重要。將 childList
設定為 true
使觀察器監視我們所針對的節點(document.body
)的變化,而 subtree:true
將導致監視其所有後代。誠然,這裡的 API 對我來說不是非常容易理解,因此在使用它滿足自己的需求之前,值得花費一些時間仔細思考。
無論如何,這種特定的配置最適用於你不知道節點可能被注入到何處的情況。但是,如果你確信它會出現在某個元素中,那麼更明智的做法是更加精確地定位目標。
清理
如果我們將觀察器保留為原樣,每次 DOM 的變化都會有新增另一個點選事件監聽器到同一個按鈕的風險。你可以透過將點選事件回撥拉到 MutationObserver
的回撥之外的自己的變數中來解決這個問題(.addEventListener()
不會向具有相同回撥引用的節點新增監聽器),但在不再需要它時即時清理觀察器會更加直觀。觀察器上有一個很好的方法可以做到這一點:
const domObserver = new MutationObserver((_mutationList, observer) => {
const button = document.getElementById('button');
if (button) {
button.addEventListener('click', () => console.log('clicked!'));
// No need to observe anymore. Clean up!
observer.disconnect();
}
});
響應速度
我之前提到了輪詢可能會在響應 DOM 更改時引入少量的假死時間。很多風險取決於你使用的時間間隔大小,但 setTimeout()
和 setInterval()
都在主任務佇列上執行它們的回撥,這意味著它們總是在事件迴圈的下一次迭代中執行。
然而,MutationObserver
在微任務佇列上觸發其回撥,這意味著它不需要等待事件迴圈的完整旋轉就可以觸發回撥。它的響應性更高。
我在瀏覽器中使用 performance.now()
進行了一項基礎實驗,以檢視將點選事件監聽器新增到按鈕上需要多長時間,此時它已掛載到 DOM 中。請記住,這是在我們的 setTimeout()
中沒有設定延遲的情況下進行的,因此我們看到的延遲可能是事件迴圈本身的速度(加上其他因素)。以下是結果:
方法 | 新增監聽器的延遲 |
---|---|
輪詢 | ~8ms |
MutationObserver() | ~.09ms |
這是一個非常驚人的差異。使用輪詢和零延遲的 setTimeout()
來附加監聽器的速度,大約比 MutationObserver
慢了 88 倍。這效果還不錯。
總結
考慮到效能優勢、更簡單的 API 和普遍的瀏覽器支援,與 MutationObserver
相比,使用 DOM 輪詢難以獲得優勢。我希望你在處理自己專案中的延遲掛載節點時會發現它很有用。我自己也會尋找其他場景,在這些場景下,MutationObserver
可能也很有用。
以上就是本文的全部內容,如果對你有所幫助,歡迎收藏、點贊、轉發~