JavaScript : コールバックがないならDOMの変更を監視する

月花です。

いろんなJavaScriptなりjQueryなりのライブラリを入れていると困るのが、ライブラリの動作にコールバックやフックがないことですよね。
なにか要素が追加されるとき、その追加された要素にCSSを適用したいがクラスもなければIDもない、さらにコールバックも無ければお手上げになってしまいます。

そこで、MutationObserver によるDOM変更の監視で解決しました。

たとえば、下記のように、DOMが追加や削除されるが、コールバックもなく追加されたものかどうか判断もできない場合です。
下記の場合ですと自作関数での追加なのでそこを編集してやればいいのですが、ライブラリを利用する場合、これに対応するコードはミニマム化されているので読んだりいじったりは困難です。

See the Pen dqywjm by gekka (@gekka9) on CodePen.

このような場合に追加や削除されたことを掴み、追加された要素やその親要素、兄弟要素に対してなにか処理をしてやりたい場合、MutationObserverによってDOMを監視することが可能です。

もくじ

  • MutationObserver
    • MutationObserver
    • MutationCallback
    • MutationRecord
    • MutationObserverInit
    • 簡単な実装例
  • MutationRecord
    • 追加されたノードに対して、さらに変更を加える
    • 何が起こったかで分岐してみる
    • 監視を停止してみる
  • MutationObserverInit
    • children と descendants の違い
  • 対応ブラウザと代替手段
    • MutationObserver の IE11対応 (※ 2018/08/22 追記)
      • Array.from()
    • Mutation events (非推奨)
    • なぜ Mutation events は非推奨なのか
    • できる限り Mutation events を使わないようにする
  • おわりに

MutationObserver

基本的には、下記のドキュメントにある。
developer.mozilla.org

が、あまり深く書いてくれていないので、下記も併読してもう少し深いところに突っ込んでいく。
DOM Standard


一旦、とりあえず最低限動くところまでを見ていこう。

MutationObserver

この子は、ある指定されたオブジェクトに対してそれ自身やその子孫の監視をする処理の本体で、監視開始や監視終了の関数を持っている。

関数名 引数 -
コンストラク function callback 変更を検知した際に呼び出すコールバック関数を渡す
observe() Node target, MutationObserverInit options 監視を開始する。監視対象と、どのような変更を検知するかの設定を渡す
disconnect() - 監視を停止する
takeRecords() - コールバック関数に渡される変更を返し、そのキューを空にする

MutationCallback

変更が検知されたときに呼び出される関数。

引数 -
MutationRecord 変更があったノードなど、検知した変更に関するデータオブジェクト
MutationObserver 変更を検知した MutationObserverインスタンス自身

MutationRecord

検知後、コールバック関数に渡される、検知した変更に関するデータオブジェクト。
変更された要素自身や変更箇所の隣人要素などが入っている。

MutationObserverInit

observe() にオプションとして渡す、監視対象の設定オブジェクト。
最低限、下記のうち少なくとも1つを明示的に設定する必要がある。

項目名 -
childList boolean trueにすると子ノードを監視対象とする
attributes boolean trueにすると、対象の属性値の変更を監視対象とする
characterData boolean trueにすると、対象の文字データの変更を監視対象とする

ここで、ノードとは簡単に言うとDOM構造の一部のことで、子ノードとは対象の要素の中に入れ子となっているDOM要素を指す。
今回の例でいくと、

<div>
  <ul id="target">
    <li>A</li>
    <li>B</li>
    <li>C</li>
  </ul>
</div>

ulタグを対象とした場合、childList をtrueにすると、ul・liの変更や、新たな子タグの追加を監視する。
一方で、characterData を true にした場合、もともと ul は li しか持っておらず直下にテキストは存在しないため、ノードは持っているがテキストデータは持っていないことになるので、テキストの追加を監視することになる(もちろん、追加した後に変更や削除があった場合、それらも検知される)。
もし childList と characterData を true にした場合は、DOM構造自体の変更だけでなく、ul 直下のテキストも検知することができる(そこにテキストを追加してはいけないが)。

簡単な実装例

上記を踏まえて、一度動かしてみよう。

See the Pen NLWVwR by gekka (@gekka9) on CodePen.

この例では、ulタグの childList を監視する。動作は li の追加と削除しかないため、その2パターンの変更を検知した際に、検知した回数をカウントするようにした。
また、コールバック関数に渡される MutationRecord は見ず、どのような変更であっても同じ動作をするし、disconnect() を呼んでいないため監視しっぱなしである。

次は、コールバック関数で込み入った処理を行うため、MutationRecord を詳しくみていく。

MutationRecord

項目名
type DOMString どの種類の検知に引っかかったかを保持している。例えば、childList の関しに引っかかったなら"childList"、attributesの検知なら"attributes"というように持つため、分岐処理をすることができる
target Node 変更があったノードそのもの。追加・削除されたノードや、変更されたノードを保持している
addedNodes NodeList 追加されたノードのリスト。今回は1つずつの追加のため、配列長は1となる
removedNodes NodeList 削除されたノードのリスト。今回は1つずつの削除のため、配列長は1となる
previousSibling Node 追加/削除があった要素の、1つ前のお隣さんノード。なければnullとなる
nextSibling Node 追加/削除があった要素の、1つ後のお隣さんノード。なければnullとなる
attributeName DOMString 変更があった要素の属性名を返す
attributeNamespace DOMString 変更があった要素の属性の名前空間を返す
oldValue DOMString attributes や characterData の変更の場合、変更前の値を保持している。childList の場合は null になる。使うには MutationObserverInit に設定が必要(後述)

追加されたノードに対して、さらに変更を加える

addedNodes を利用して、追加された要素に対して、追加後にさらに要素を変更してみる。

See the Pen KxKLGv by gekka (@gekka9) on CodePen.

addedNodes はノードのリストなので、配列長は1であるが、forEach を使って処理をしている。
極めて軽い処理なので、一瞬で動作が終わってしまうため、書き換え後のものが挿入されているように見えるが、実は挿入後に書き換えられている。

何が起こったかで分岐してみる

次に、attributes の監視に引っかかるような動作を追加し、分岐をしてみる。

See the Pen WgNqXJ by gekka (@gekka9) on CodePen.


type が childList の場合は、addedNodes や removedNodes の配列長を見ることで追加か削除が判別できる。
attributes では、古い値は oldValue に入っており、新しい値は target と attributeName を使って変更後に取得することができる。
characterData の場合も処理の基礎としては attributes と同様になる。

監視を停止してみる

何かの変更を検知して、一回だけ処理をすればいい場合は、コールバック関数の最後で監視を停止してやることができる。

See the Pen gdbYGO by gekka (@gekka9) on CodePen.


少し前の、変更するたびカウントを増やしていく例で、最初の一回のみで止めるようにした。
コールバック関数には、第二引数に MutationObserver のインスタンスそのものを受け取るため、これの disconnect() を呼んでやることで、コールバック関数内で自分自身を止めることができる。
もちろん、MutationObserver のインスタンスを持っている変数が使える場所である限り、任意の場所で止めることができる。

MutationObserverInit

前章までは、検知対象を絞り込むことに使っていたこのオプション配列だが、もう少し踏み込んだ設定もできる。

項目名
childList boolean 監視対象要素の子ノードを見るかどうか。以下、true で見る
attributes boolean 監視対象要素の属性値を見るかどうか
characterData boolean 監視対象要素のテキストを見るかどうか
subtree boolean 監視対象要素の子孫を見るかどうか。childList の場合は、あくまでも子への追加や削除を見ており、孫世代の要素が変わったとしても検知されない。また、attributes とcharacterData の場合は元々監視対象そのものの値の変更しか見ていない。これを true にしてやると、 childList は孫以降への変更も見るようになり、 attributes の場合は子以降の属性の変更も見るようになる。
attributeOldValue boolean 監視対象の変更前の属性値を MutationRecord に保持するかどうか。前章で使っている。
characterDataOldValue boolean 監視対象の変更前のテキストを MutationRecord に保持するかどうか
attributeFilter DOMStringの配列 attributes が true の場合有効で、どの属性の値の変更を検知するかのホワイトリスト

前章までで実際のコードを掲載していないのは characterData だが、処理の流れとしては attributes と同様なので、省略する。

children と descendants の違い

先程の例で、 subtree を true にせず、監視対象を元々の ul からその親の div に変えてみた。
subtree が孫を見るものかどうかの是非であるのが間違いないなら、これでは検知されないはずだ。

See the Pen zJYgyw by gekka (@gekka9) on CodePen.

一つ前の例のようなメッセージが出ていない。検知されなかったようだ。

ここで、
DOM Standard
には、

childList
Set to true if mutations to target’s children are to be observed.

subtree
Set to true if mutations to not just target, but also target’s descendants are to be observed.

とある。

children とあるのだから、 childList の場合は、subtree を true にしなくても検知するのでは、と考えていた。

children について、

An object that participates in a tree has a parent, which is either null or an object, and has children, which is an ordered set of objects. An object A whose parent is object B is a child of B.

descendants について、

An object A is called a descendant of an object B, if either A is a child of B or A is a child of an object C that is a descendant of B.

🤔?

ひとまず、 MutationObserver 自体の能力については一通り確認することができた。

対応ブラウザと代替手段

Can I use... Support tables for HTML5, CSS3, etc
上記を見ると、FireFoxChrome は早いこと対応しているが、IE は11以降からだった。

では、MutationObserver が使えない場合はどのようにすればよいだろうか。

MutationObserver の IE11対応 (※ 2018/08/22 追記)

IE11では、MutationObserver はサポートされているが、そこで扱うことになる NodeList の forEach() がサポートされていない。
つまり、これらの処理で渡される NodeList に対しては、IE11が扱えるループをしてやらねばならない。
そもそも、NodeList はあくまでも配列風 ( array-like ) オブジェクトであり、 Array を継承していない。
JavaScript における forEach は、制御構文ではなく Array.prototype に実装されている関数に過ぎず、これを継承していないオブジェクトでは呼び出すことができない。

Array.from()

様々なやり方があるが、ここでは単純な方法として、forEach の前で NodeList をArray に変換することにする。
そこで、Array.from() を使う。
単純な例として、第一引数のみを使い、ただ変換するだけの関数として扱う例を載せる。

第一引数では、array-likeオブジェクトもしくは iterableオブジェクトを受け取る。
ここで、NodeList は先述のように array-likeオブジェクトなので、単純に渡すだけで Arrayオブジェクトにしてくれる。
Array になってしまえば Array.prototype が実装されたオブジェクトとなるので、forEach を呼び出すことができる。

observer = new MutationObserver(function(mutations, observer){
    mutations.forEach( function(mutation){
        var arrayNodes = Array.from(mutation.addedNodes);
        arrayNodes.forEach( function(node){
            // 処理
        });
    });
});

MutationObserver の代替手段 Mutation events (非推奨)

Mutation events というものがある。

  • DOMAttrModified
  • DOMAttributeNameChanged
  • DOMCharacterDataModified
  • DOMElementNameChanged
  • DOMNodeInserted
  • DOMNodeInsertedIntoDocument
  • DOMNodeRemoved
  • DOMNodeRemovedFromDocument
  • DOMSubtreeModified

これらは、イベントリスナとして扱うことができ、 addEventListener の第一引数として渡すことで、各々のイベントに応じて発火する。

この中では、

  • DOMNodeInserted
  • DOMNodeRemoved
  • DOMAttrModified
  • DOMCharacterDataModified

これらが、それぞれ先程のコードに該当するだろう。

これらを使って、一番最初の単純な例を作ってみる。

See the Pen WgbebQ by gekka (@gekka9) on CodePen.


なぜ Mutation events は非推奨なのか

それは、これは同期的な処理だからである。
同期的にDOMを監視し、なにか処理を入れるということは他の動作に影響を及ぼしてモッサリしてしまう、ということである。
このため、非同期的な処理である MutationObserver を代わりに使うことが推奨されている。

できる限り Mutation events を使わないようにする

非推奨だからといって、絶対使わないというわけにもいかないのが現実。
でもできるだけ使いたくない。

であれば、 MutationObserver が使えればそれを、使えなければ Mutation events を使うようにしてやればよい。

See the Pen aazomY by gekka (@gekka9) on CodePen.


このように、 typeof MutationObserver が undefined かどうかで、そのブラウザが MutationObserver を実装しているかどうかを判別することができる。

おわりに

MutationObserver は非常に強力な手段であることがわかったが、実際にシステムに組み込んでみると思いの外発火する。
各種統計用の JavaScriptライブラリや reCAPTCHA 、lazyload など、様々な変更がひっきりなしに発火していることに気づく。
やむを得ず body の孫まで監視しているような状態では、とんでもない数になると思われる。
こうした変更すべてを検知していては、さすがに今の優秀なブラウザでも重くなりそうである。

コールバックが実装されていたり、追加要素にはクラスを振ってくれるライブラリをできるだけ使うことを前提に、最終手段として使っていきたい。
使う場合も、できれば subtree は使わず、終わったら disconnect() を呼び出すようにしたいところ。