iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

MutationObserverを理解する

こんにちは。

iimonアドベントカレンダー19日目の記事を担当させていただきます、株式会社iimonのねにーです。

ついこの間アドベントカレンダーを書いたばかりのような気もするのですが、 早いもので気がつけば今年ももう12月です。

今年は所属プロダクトが変わり、またAIのインパクトと恩恵を目の当たりにしたりと、浅学ながらエンジニアとして様々な変化に触れることができた年でした。

さて、業務の中でUI実装をする際、特定の操作に応じてバッジアイコンに表示させる件数を更新するという処理を触る機会がありまして、 その中の既存処理でMutationObserverを使っている箇所があったのですが、率直に言って理解が不十分なままでした。

そういう訳でありましてこの機会にイメージだけでも掴んでおこうと思い、MutationObserverの仕組みと使い方について、概略レベルには留まりますがまとめました。

MutationObserverとは

MutationObserverとは、画面上のHTML要素(DOM)を監視し、変更があったタイミングで指定の処理を実行するJavaScriptの機能です。

使用が適したケースとしては、イベント発火による検知が難しいSPA上で動的に追加・変更される要素を検出してボタンを新たに追加したり、

外部スクリプトによる不特定タイミングでの広告の挿入などによるDOM変更を検知して代替コンテンツを表示させる、などが挙げられます。

冒頭で私が挙げた例では、ボックス上にリスト形式で並んでいるコンテンツをクリックしたタイミングで、未参照コンテンツの件数を示すバッジアイコン上の数字を更新する処理でMutationObserverを使っていました。

処理ステップと実装方法

実際に、MutationObserverの具体的な使い方について見ていきます。

処理の流れとしては、大きく以下の4ステップに分けられます。

  1. 監視対象の要素を決める
  2. コールバック関数(変更検知時の処理内容)の作成
  3. オブザーバのインスタンス生成
  4. 必要に応じたオプションを指定して監視を開始

具体的な記述方法の例としては以下のような形になります。

// 1. 監視対象要素を取得
const targetElem = document.getElementById('my-list');

// 2. 変更を検知した時に実行したい処理
const callback = (mutationsList, observer) => {
    // mutationsListには変更の記録(MutationRecord)が配列で入ってくる
    for (const mutation of mutationsList) {
        if (mutation.type === 'childList') {
            console.log('子要素が追加または削除されました');
        } else if (mutation.type === 'attributes') {
            console.log(`${mutation.attributeName} 属性が変更されました`);
        }
    }
};

// 3. オブザーバーのインスタンスを作成
const observer = new MutationObserver(callback);

// 4. オプション設定
const config = { 
    attributes: true,  // 属性の変化を監視
    childList: true,   // 子要素の追加・削除を監視
    subtree: true      // 孫要素以降も監視(サブツリー全体)
};

// 監視スタート
observer.observe(targetElem, config);

// クラス追加 -> "class 属性が変更されました"
targetElem.classList.add('active');

また、オブザーバによる監視が不要になったら、メモリリークを防ぐためにもしっかり停止しておきましょう。

// 監視を停止
observer.disconnect()

オプションの設定

observer.observe(targetElem, config)の第2引数で渡しているconfigオブジェクトで、監視する範囲を設定できます。

指定できるオプションは以下の全7つです。

オプション名 説明
childList boolean 対象ノード直下の子要素の追加・削除を監視
attributes boolean 対象ノードの属性(class, style, idなど)の変更を監視
characterData boolean テキストノードの内容(文字)の変更を監視
subtree boolean 対象ノードだけでなく、その子孫要素すべてを監視
attributeOldValue boolean 属性変更前の古い値を記録(attributes: trueが必要)
characterDataOldValue boolean テキスト変更前の古い値を記録(characterData: trueが必要)
attributeFilter array 監視する属性名を配列で指定

このうち少なくともchildList,attributes,characterDataのいずれか1つをtrueにして設定する必要があります。

代表的なオプションについていくつか見てみましょう。

childList

childListは、対象要素の直接の子要素の追加・削除を監視します。

以下の要素を例に見てみましょう。

<div id="target">
    <div id="child">
          <p id="grandchild">孫要素</p>
    </div>
</div>
const target = document.getElementById('target');
const child = document.getElementById('child')
const grandchild = document.getElementById('grandchild');

const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        // 省略
    });
});

// childList: trueを設定
observer.observe(target, { childList: true }); 

このtargetに対し、検知されるケースとされないケースを比較してみます。

// 検知される(直接の子要素の追加と削除)
target.innerHTML += '<span>新しい要素</span>'
target.appendChild(document.createElement('div'));
target.insertAdjacentHTML('beforeend', '<p>追加</p>');
target.removeChild(target.firstChild);
target.innerHTML = '';

以下は検知されないケースです。

// 検知されない - いずれもtargetから見ると孫にあたるため。。
child.appendChild(document.createElement('span'));
grandchild.querySelector('p').remove();
grandchild.textContent = '変更';

subtree

上記childListをtrueに指定しただけでは、直接の子要素しか検知されませんでしたが、孫要素も検知したい場合はsubtreeを指定します。

observer.observe(
    target,
    {
        childList: true,
        subtree: true,
    }
);

このようにsubtree: trueを指定することで、上記targetからみた孫要素であるpタグへの変更も検知されるようになります。

attributes

attributesは、要素の「属性」の変更を監視します。

以下を例に見てみます。

<div id="target" class="box" data-status="active">
    <div id="child" class="item">
        <p id="grandchild">孫要素</p>
    </div>
</div>
const target = document.getElementById('target');
const child = document.getElementById('child')
const grandchild = document.getElementById('grandchild')

const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        // 省略
    });
});

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

以下は検知されるケースです。

// 検知されるケース(target自身の属性変更)
// classの追加
target.classList.add('highlight');
// classの削除
target.classList.remove('box');
// styleの変更
target.style.display = 'none';

検知されないケースは以下の通りです。

// 子要素のクラス変更
child.classList.add('selected');
// 子要素のstyle変更
child.style.color = 'red';
// 孫要素のclass変更
grandchild.classList.add('active');

こちらも同様に、子孫要素も含めて検知したい場合はconfigにsubtree: trueを追加します。

attributeFilter

attributeFilterは、監視する属性を限定できるオプションです。

<div id="target" class="box" data-status="active" title="テスト"></div>
const target = document.getElementById('target');

const observer = new MutationObserver((mutations) => {
   // 省略
});

// classとdata-status属性を指定
observer.observe(target, {
    attributes: true,
    attributeFilter: ['class', 'data-status']
});

このオプションを使用し対象属性を絞ることで、不要なコールバックの発火を抑えることができ、パフォーマンスの向上が期待できるとともに、 何を監視しているのか分かりやすくなるといったメリットが見込めます。

MutationRecordのプロパティ

次に、具体的な処理内容が記されたコールバック関数の第1引数に指定されているmutationsListには、MutationRecordオブジェクトの配列が渡されてきます。

このオブジェクトに格納されている主なプロパティは以下の通りです。

プロパティ名 説明
type 変更の種類(childList, attributes, characterData)
target 変更が起きた要素
addedNodes 追加されたノードのリスト(NodeList)
removedNodes 削除されたノードのリスト
oldValue 変更前の値(オプションで有効にした場合のみ)
attributeName 変更された属性名

無限ループの罠

MutationObserverを使用するに際し注意したいのが、無限ループです。

例えばオブザーバに渡したコールバック関数内で監視対象のDOMを編集するような処理を書いてしまうと、 場合によっては無限ループに陥る可能性があります。

以下は、対象要素の中身が変わったら最後に更新というテキストを追記するという処理を書こうとして無限ループを引き起こしてしまう例です。

const target = document.getElementById('target-content');

const observer = new MutationObserver((mutations) => {
    // ❌ 変更検知後、さらに監視対象(target)の要素を変更しているため無限ループ発生
    target.textContent += ' (更新)'; 
    console.log('ループ中...');
});

observer.observe(target, { childList: true, subtree: true });

// 引き金
target.textContent = "テスト";

ループの要因を辿っていくと、以下のような形で無限ループに陥っています。

  1. target.textContentに対する編集で変更発生

  2. オブザーバが変更を検知し、コールバック実行

  3. コールバック内で '(更新)' を追記(=DOM変更)

  4. この追記をオブザーバが再度変更があったものと検知

  5. 2に戻る(以下無限ループ)

対策としては以下のようにDOMを書き換える直前に監視を停止(disconnect)し、書き換えが終わったタイミングで即座に再開(observe)することで無限ループを防ぐことができます。

const target = document.getElementById('target-content');
const config = { childList: true, subtree: true }; // 設定を変数にしておく

const observer = new MutationObserver((mutations) => {
    // 一時的に監視を止める
    observer.disconnect();

    // 監視対象外でDOMを変更
    target.textContent += ' (更新)';
    console.log('安全に更新しました');

    // 監視を再開する
    observer.observe(target, config);
});

observer.observe(target, config);

target.textContent = "初期テキスト";
// 結果: "初期テキスト (更新)" となり、ループせずに終了

廃止されたMutationEvent

MutationObserverが登場する以前にも、MutationEventというDOM変化を監視する仕組みは存在していました。

しかし、これはDOMに変更がある度にイベントが発火するなどしてブラウザのクラッシュを引き起こしやすく、 パフォーマンスに難ありとして現在では非推奨とされ、後継としてよりパフォーマンスに優れたMutationObserverが 登場したという経緯があったようです。

まとめ

MutationObserverを使うことにより、DOMの変更に対して柔軟な操作ができると分かりました。

また、DOMツリー上の変更を発火点として直接監視できるため、SPA特有の動的な変化にも対応できるのは、 かなり有用でしっかりと身につけておくべき技術かと思います。

拙文ながらここまで読んで頂きありがとうございます。 弊社ではエンジニアを募集しております。 ご興味がありましたらカジュアル面談も可能ですので、下記リンクより是非ご応募ください!

iimon採用サイト / Wantedly

明日のアドベントカレンダー担当は新卒としてジョインして以来、日々メキメキと力をつけている「つかちゃん」です!

乞うご期待!

参考記事

MutationObserver - Web API | MDN

MutationEvent - Web API | MDN

【初心者向け】JavaScriptで要素の監視を行う方法

【JavaScript】DOM の変化を監視する MutationObserver – webfrontend.ninja

MutationObserverを補足しつつ確認していく #JavaScript - Qiita