【JavaScript】DOM の変化を監視する MutationObserver

ウェブサイトやウェブアプリを開発する際に、自身が書いた JavaScript で HTML ドキュメントの DOM ツリーの操作を行うこともあれば、読み込んだ JavaScript ライブラリーが DOM ツリーの操作を行うこともあります。そのような状況の中、DOM ツリーの操作を監視する仕組みが欲しいと感じたこともあるのではないでしょうか。今回は DOM ツリーの変化を監視する MutationObserver の詳しい使い方について解説します。

MutationObserver の使い方

まずは MutationObserver の使い方を簡単に紹介します。次のサンプルは span 要素のテキストの変更を検知して、変更前のテキストと変更後のテキストをコンソールに出力します。

<span id="sample">おはよう</span>
// MutationObserver オブジェクトを生成
const observer = new MutationObserver((mutations) => {
  const rec    = mutations[0];             // MutationRecord オブジェクト
  const before = rec.removedNodes[0].data; // 変更前のテキスト
  const after  = rec.addedNodes[0].data;   // 変更後のテキスト
  console.log('「' + before + '」が「' + after + '」に変更されました。');
});

// span 要素の DOM オブジェクト
const span_el = document.getElementById('sample');

// span 要素の DOM 変化の監視を開始
observer.observe(span_el, {
  childList: true // 子ノードの監視を有効化
});

// span 要素のテキストを変更
span_el.textContent = 'こんにちわ';

span 要素の DOM の変化を検知するには、まず MutationObserver オブジェクトを生成します。ここでは変数 observerMutationObserver オブジェクトをセットしています。

MutationObserver コンストラクタには、DOM 変化が検知されたときに呼び出されるコールバック関数を指定します。このコールバック関数の引数 mutations には、DOM 変化の情報を格納した MutationRecord オブジェクトのリストが与えられます。DOM の変化は 1 つとは限らないため、このようにリストで与えられます。今回はテキストを変更しただけですので、mutations には 1 つの MutationRecord オブジェクトしか格納されません。MutationRecord オブジェクトの詳細については後述します。

以上のようにコールバック関数を定義した MutationObserver オブジェクトを生成したら、observe() メソッドを使って監視を開始します。observe() メソッドの第一引数には、監視対象となる HTML 要素の DOM オブジェクトを与えます。第二引数には、どんな変化を監視したいのかを定義したオブジェクトを与えます。どちらの引数も必須です。この第二引数の詳細についても後述します。

最後に span 要素のテキストを変更すると、前述のコールバック関数が呼び出され、次のような内容がコンソールに出力されます。

「おはよう」が「こんにちわ」に変更されました。

どんな変化を監視するのかを指定する

MutationObserver オブジェクトの observe() メソッドを使って監視を開始しますが、第二引数には、どんな変化を監視したいのかを定義するオブジェクトを与えます。

observer.observe(span_el, {
  childList: true // 子ノードの監視を有効化
});

前述のサンプルでは childListtrue をセットしましたが、他にもいくつか指定することが可能です。

プロパティ説明
childListBoolean子ノードの変化を監視するなら true をセットします。
attributesBoolean属性の変化を監視するなら true をセットします。
characterDataBooleanテキストノードのテキストデータの変化を監視するなら true をセットします。
subtreeBoolean対象ノードではなく、その子孫要素の変化を監視するなら true をセットします。
attributeOldValueBoolean変化前の属性値が必要なら true をセットします。
characterDataOldValueBoolean変化前のテキストデータが必要なら true をセットします。
attributeFilterArrayすべての属性の変化を監視するのではなく、一部の属性の変化だけを監視したい場合、監視したい属性名を配列で指定します。

基本的に必要なプロパティのみをセットすれば良いのですが、attributes, characterData, childList のいずれか一つは指定しなければいけません。

DOM の変化を表す MutationRecord オブジェクト

DOM の変化の情報を格納した MutationRecord オブジェクトについて詳しく見ていきましょう。前述の MutationObserver オブジェクトを生成するコードを再掲します。コンストラクタにセットしたコールバック関数に注目してください。

const observer = new MutationObserver((mutations) => {
  const rec    = mutations[0];             // MutationRecord オブジェクト
  const before = rec.removedNodes[0].data; // 変更前のテキスト
  const after  = rec.addedNodes[0].data;   // 変更後のテキスト
  console.log('「' + before + '」が「' + after + '」に変更されました。');
});

ここでは、DOM の変化を格納した MutationRecord オブジェクト(変数 rec)について詳細に説明します。MutationRecord オブジェクトには、次のプロパティがセットされています。

プロパティ説明
typeStringDOM の変化の種類を表すキーワード。属性の変化なら "attributes"、CharacterData ノードの変化なら "characterData"、ノードツリーの変化なら "childList" がセットされます。
targetNode変更の影響を受けたノードオブジェクト。
addedNodesNodeList追加されたノードオブジェクトのリスト。
removedNodesNodeList削除されたノードオブジェクトのリスト。
previousSiblingNode追加または削除されたノードの前にあるノードオブジェクト。なければ null がセットされます。
nextSiblingNode追加または削除されたノードの後にあるノードオブジェクト。なければ null がセットされます。
attributeNameString変更された属性名。なければ null がセットされます。
attributeNamespaceString変更された属性の名前空間。なければ null がセットされます。
oldValueStringtype"attributes" なら変更前の属性値、type"characterData" なら変更前のデータ、type"childList" なら null がセットされます。

前述のサンプルでは、span 要素のテキストを変更しました。それを踏まえると、MutationRecord オブジェクト(変数 rec)の removeNodes には変更前の Text ノードオブジェクトが、addedNodes には変更後の Text ノードオブジェクトが含まれるはずです。

同じ状況で、MutationRecord オブジェクト(変数 rec)の全体を見てみましょう。

const observer = new MutationObserver((mutations) => {
  const rec    = mutations[0]; // MutationRecord オブジェクト
  console.dir(rec);
});

多くのプロパティが null になっていますが、これは子ノードの変化を検知した場合、つまり、type"childList" だからです。

複数の変化を同時に検知する

前述のサンプルでは、コールバック関数に引き渡される MutationRecord オブジェクトのリストの個数は 1 個でしたが、それが複数になる場合を見てみましょう。

次のサンプルは、リンクを a 要素でマークアップしています。そして、その a 要素の変化を監視します。

<a href="https://www.google.co.jp/" id="search-link">Google</a>
const observer = new MutationObserver((mutations) => {
  console.dir(mutations);
});

const a_el = document.getElementById('search-link');

observer.observe(a_el, {
  childList: true,
  attributes: true
});

a_el.href='https://www.yahoo.co.jp/';
a_el.textContent = 'Yahoo! JAPAN';

observe() メソッドで、childListattributes の両方に true をセットしている点に注目してください。これは、a 要素の子ノードの変化に加え、属性の変化も監視対象になります。

このサンプルでは監視をセットしたのち、a 要素の href 属性の値を変更し、さらに、テキストも変更しています。コールバック関数では引数に与えられた MutationRecord オブジェクトのリスト(変数 mutations)をそのままコンソールに出力します。その結果は次の通りです。

ご覧の通り、MutationRecord オブジェクトが 2 つあります。1 つは href 属性の値の変化に関する情報、もう 1 つはテキストの変化に関する情報です。

ここで注目してほしいのは、href 属性とテキストの変更を命令するコードは 2 行で別々にもかかわらず、コールバックは 1 回に集約して呼び出される点です。

CharacterData とは?

observe() メソッドで指定できる監視内容には “characterData" を指定することができますが、これは何を指すのでしょうか。前述のサンプルのように、要素のテキストの変化を検知する場合は "childList" を指定していました。

この characterData とは Text ノードのテキストのことです。span 要素などの要素ノードは、子ノードとして、その要素のテキストを表す Text ノードを含むことになります。ゆえに次にコードは機能しません。

<span id="sample">おはよう</span>
const observer = new MutationObserver((mutations) => {
  console.dir(mutations);
});

const span_el = document.getElementById('sample');

observer.observe(span_el, {
  characterData: true
});

span_el.textContent = 'こんにちわ';

このサンプルでは span 要素を監視対象にしています。にもかかわらず、observe() メソッドでは characterDatatrue をセットしています。span 要素のテキストを変更したとしても、変更されるのは span 要素の characterData ではなく、その子ノードの Text ノードの characterData です。そのため変化を検知できません。

もし observe() メソッドで characterDatatrue をセットして変化を検知したいなら、監視対象を Text ノードオブジェクトとします。

しかし、これだけでは変化を検知できません。それは span 要素のオブジェクトの textContent プロパティを操作しているからです。textContent プロパティは子ノードの Text ノードを削除してから新たな Text ノードを追加します。そのため、Text ノードオブジェクトの data プロパティを操作する必要があります。

以上をまとめると、次のようなコードにすると期待通りに動作します。

observer.observe(span_el.firstChild, {
  characterData: true
});

span_el.firstChild.data = 'こんにちわ';

テキストの変化を監視するのに Text ノードを扱うのはちょっと面倒ですよね。observe() メソッドで childListtrue にセットすればテキストの変化は検知できるのに、なぜ characterData が存在するのか疑問を感じますよね。 おそらくですが、パフォーマンスも理由の一つかもしれませんね。

span 要素の childList を監視対象にすると、span 要素の子ノードの変化すべてが監視対象になります。一方、span 要素の子ノードの Text ノードの characterData が監視対象にすればテキストの変化にしか反応しません。

監視の停止

DOM 変化の監視の必要性がなくなったら、パフォーマンスという観点からも、監視を停止するのが良いでしょう。監視を停止するには、MutationObserver オブジェクトの disconnect() メソッドを呼び出します。

次のサンプルは、span 要素に hidden 属性がセットされるのを監視し、セットされたら監視を終了します。

const observer = new MutationObserver((mutations, obs) => {
  // obs === observer
  obs.disconnect(); // 監視を停止する
});

// span 要素の DOM オブジェクト
const span_el = document.getElementById('sample');

// span 要素の hidden 属性に限定して監視を開始
observer.observe(span_el, {
  attributes: true,
  attributeFilter: ['hidden']
});

// span 要素に hidden 属性をセット
span_el.hidden = true;

このサンプルでは監視を hidden 属性に限定するため、observe() メソッドで attributeFilter プロパティを使っています。

コールバック関数では、第二引数に引き渡された MutationObserver オブジェクト(変数 obs)を使って disconnect() メソッドを呼び出している点に注目してください。もちろん、このサンプルコードであれば、observer.disconnect() を呼び出しても結果は同じです。

このサンプルではコールバック関数をインラインで定義しているため、第二引数のありがたみは感じられませんが、もし関数を分離して、変数 observer のスコープが関数に及ばない場合は、第二引数の MutationObserver オブジェクトを使いたくなるでしょう。

廃止になった MutationEvent

DOM ツリーの変化を監視する手段は、MutationObserver が登場する前から存在していました。それは MutationEvent です。MutationObserver は MutationEvent の後継として生まれたという経緯があります。ネットで検索すれば今でも数多くの説明を見つけることができます。しかし、現在、MutationEvent は廃止扱いになっています。MutationEvent はまだ多くのブラウザーで利用可能ですが、いつサポートが終了するか分かりませんので注意しましょう。

MutationEvent が廃止になった大きな理由はパフォーマンスにあると言われています。MutationEvent は、監視対象によってはブラウザーのパフォーマンスを著しく悪化させることが分かっており再設計が求められていました。

MutationEvent は名前の通りイベントなのですが、状況によっては大量にイベントが発生してしまい、さらにイベントの伝搬の仕組みによりブラウザーの動作が遅くなり、ブラウザーがクラッシュする可能性もあったようです。

これらの問題を解決するために、MutationObserver が開発されました。MutationObserver ではイベント方式をやめコールバック方式とし、前述の通り、複数の DOM 操作があったにもかかわらずコールバック関数は 1 回しか呼び出されない点、observe() メソッドで attributeFilter を使って監視対象を限定する手段を用意した点、など様々な考慮が盛り込まれています。

まとめ

MutationObserver は普段から頻繁に使うものではなく、使いどころがなかなか見つけられませんが、DOM の変化を監視する仕組みがある、ということを知っておいても損はありません。もしかしたら、いつか重宝するときが来るかもしれません。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share