處理尚不存在的 DOM 節點

chuck發表於2023-03-26
探索 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 可能也很有用。

以上就是本文的全部內容,如果對你有所幫助,歡迎收藏、點贊、轉發~

相關文章