jsで対象のDOMが出現するまで待つ(javascript)

JavaScript

とあるWebシステムを利用しているのですが、どうにもUIが使いにくく自前で改良しようとして困ったときのお話です。データを一括で入力したり丸め処理を入れたいのですが、一括入力機能がそもそも存在せず、丸め処理もできないため自力で入力。なんて糞なUIなのだと思いながら我慢しましたが、早くも2日目に嫌になりました。

ということで最近はまってるChrome拡張機能を使って省力化を図ろうとしたところ、APIでデータがくる&DOMもその時生成されるため、少し苦戦しました。

苦戦ポイントは、jsで動的に生成されるDOM要素を如何に取得するか?です。

動的なDOMを待つ方法

長くなりそうなので最初に答えを書いておきます。
setTimeoutでループ処理をしながらdocument.querySelector()あたりでDOMが取得できるまでぶん回すのが簡単で確実です。querySelector自体も十分に高速なのでこれで困ることはほぼないでしょう。

function waitForElement(selector, callback, intervalMs, timeoutMs) {
    const startTimeInMs = Date.now();
    findLoop();

    function findLoop() {
        if (document.querySelector(selector) != null) {
            callback();
            return;
        } else {
            setTimeout(() => {
                if (timeoutMs && Date.now() - startTimeInMs > timeoutMs) return;
                findLoop();
            }, intervalMs);
        }
    }
}

以下にMutationObserverを使ったパターンを書きますが、そもそもこれを知っている人が皆無ですし、理解するのも面倒。保守性も悪くなるのでsetTimeoutを使いましょう。

MutationObserver

domの変更イベントを検知できるMutationObserverというものがあります。
これを利用するとDOMの変更のたびに変更された要素等が流れてくるため、差分のみをチェックできるため、効率性の観点で優秀です。

function waitForElement(selector, text = null) {
    return new Promise(resolve => {
        const nodes = document.querySelectorAll(selector);
        for (const node of nodes) {
            if (node.nodeType === 1 && (text === null || node.textContent === text)) {
                return resolve(node);
            }
        }

        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType !== 1) {
                        // needs ELEMENT_NODE only. exclude TEXT_NODE and other stuff.
                        continue;
                    }

                    if (node.matches(selector) && (text === null || node.textContent === text)) {
                        observer.disconnect();
                        return resolve(node);
                    }
                }
            }
        });

        observer.observe(document, {
            childList: true,
            subtree: true,
            attributes: false,
            characterData: false,
        });
    });
}
利用する場合

waitForElement('div#main a', 'ほげ').then(node => {
    console.log(node);
    const href = node.getAttribute('href');

    // 何か処理
});

promiseを使ってるので若干見にくくなっていますが、
MutationObserverにCallbackを入れてnewし、
obsrever.observe()にて対象のDOMの監視を開始します。
監視が不要になればobserver.disconnect()で監視を終了します。
それだけ。

変更されたDOMはどれ?

MutationObserverのコールバックの引数にはMutaionRecordの配列が流れてきます。
MutaionRecordはaddedNodesやremovedNodesというプロパティがあるため、それらを見ることで変更されたNodeの詳細を確認できます。

innerHTMLの変更が検知できない!

一例ですが以下のようなDOMの追加を行った場合、MutationObserverでは1つのdiv追加イベントとしてしか認識しません。

const div = document.createElement('div');
div.innerHTML = '<p>hoge</p><a>fuga</a>';
document.body.appendChild(div);

何が言いたいのかというと、欲しいaタグがinnerHTMLに埋め込まれて一括で追加された場合、mutaionRecord.addedNodesを調べても該当するaタグは見つかりません。addedNodeの子要素を再帰的に自ら検索しないとヒットしませんので注意が必要です。
私もしばらくこれに悩まされました。Nodeの下まで追わなければなりません。

SetTimeoutかMutationObserverか悩ましいところ

ロジック的にはMutationObserverの方が好ましいです。変更差分しか見ないわけなので処理量は圧倒的に少ないです。一方で親しみのないオブジェクトなので使い方を間違いやすく、理解にも時間が掛かります。どっちで実装するのがいいんでしょうか?

一人で悩んでも結論は出ないので、私がよく利用するpuppeteerを参考にしてみたいと思います。
puppeteerにもよく似たメソッドが定義されていて、waitForSelector()というものがあります。
これのソースを追いかけてみましょう。

だいぶ端折りますが、waitForSelectorメソッドは_waitForSelectorOrXPath()を呼び出し、WaitTask()を呼んでいます。

async _waitForSelectorOrXPath(selectorOrXPath, isXPath, options = {}) {
    // 省略
    const waitTask = new WaitTask(this, predicate, title, polling, timeout, selectorOrXPath, isXPath, waitForVisible, waitForHidden);
    const handle = await waitTask.promise;
    if (!handle.asElement()) {
      await handle.dispose();
      return null;
    }
    return handle.asElement();

    function predicate(selectorOrXPath, isXPath, waitForVisible, waitForHidden) {
      const node = isXPath
        ? document.evaluate(selectorOrXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
        : document.querySelector(selectorOrXPath);
      if (!node)
        return waitForHidden;
      if (!waitForVisible && !waitForHidden)
        return node;
      const element = /** @type {Element} */ (node.nodeType === Node.TEXT_NODE ? node.parentElement : node);

      const style = window.getComputedStyle(element);
      const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
      const success = (waitForVisible === isVisible || waitForHidden === !isVisible);
      return success ? node : null;
    }

    // 省略
}
https://github.com/puppeteer/puppeteer/blob/4e8d074c2f8384a2f283f5edf9ef69c40bd8464f/experimental/puppeteer-firefox/lib/DOMWorld.js#L388

ここで見てほしいのは以下のところです。

      const node = isXPath
        ? document.evaluate(selectorOrXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
        : document.querySelector(selectorOrXPath);

predicate()の定義内でquerySelectorを使ってますね。MutationObserverは出てきません。
高速化などしなくてもこれで十分という判断でしょう。

ということで結論!
setTimeoutとquerySelectoがシンプルかつ十分に高速。
保守性の面でも非常に優れていると思います。

コメント