1. ホーム
  2. JavaScript

【JavaScript】MutationObserver の使い方|DOMの変化を検知して処理を実行する方法

Share

プラグインが後から挿入した要素にイベントを付けたい、API のレスポンスで増えたカードだけを初期化したい——自分の書いたコードの外で DOM が書き換わる場面では、「変わったことを知る」手段が必要になります。それを標準の API として提供してくれるのが MutationObserver です。この記事では、MutationObserver の基本の使い方から observe() のオプション、MutationRecord の読み方、要素の追加や属性の変化を検知する実践例、そして無限ループを避けるコツまでを解説します。

MutationObserver は DOM の変化をあとから検知する仕組み

クリックや入力はイベントで拾えますが、「要素が増えた」「class が書き換わった」といった DOM 自体の変化には、通常のイベントがありません。自分のコードだけで DOM を組み立てているなら書き換えた直後に処理を続ければ済みますが、他のスクリプトや外部プラグイン、非同期通信の結果としてあとから挿入される要素には、その手が使えません。読み込み時に querySelectorAll() で取得しても、あとから増えた要素はもちろん対象外です。

MutationObserver は、指定した要素とその中身を見張り、変化があったときにまとめてコールバックを呼んでくれる監視役です。監視できるのは大きく3種類で、子要素の追加・削除、属性の変更、テキストの変更です。「誰が変えたか」に関係なく検知できるので、自分の管理外で起きる変化に反応したいときの定番手段になります。

かつては DOMSubtreeModified や DOMNodeInserted といった Mutation Events という仕組みがありましたが、これらは変更のたびに同期的に発火するため、大量の DOM 操作をすると著しく重くなるという問題を抱えていました。そのため非推奨となり、Chrome ではバージョン 127(2024 年)で既定で無効化されています。現在 DOM の変化を検知する方法は MutationObserver 一択と考えてよく、こちらは変化をまとめて非同期に通知する設計になっているため、パフォーマンス面の問題が解消されています。

基本の使い方は3ステップ

使い方は、コールバックを渡してインスタンスを作り、監視対象とオプションを指定して開始し、不要になったら止める、という3ステップです。まずは最小のコードを見てみましょう。

script.js
const target = document.querySelector('#list');

// 1. 変化が起きたときに呼ばれるコールバックを渡してインスタンスを作る
const observer = new MutationObserver((records, obs) => {
  for (const record of records) {
    console.log(record.type, record.target);
  }
});

// 2. 監視対象と「何を監視するか」を指定して開始する
observer.observe(target, { childList: true, subtree: true });

// 3. 不要になったら監視を止める
observer.disconnect();

コールバックの第1引数には、まとめられた変化の一覧(MutationRecord の配列)が渡されます。第2引数には observer 自身が渡されるので、コールバックの中で監視を止めたいときに便利です。

ここで大事なのは、observe() の第2引数を必ず指定する必要があるという点です。childList / attributes / characterData のどれも true になっていない場合、監視するものが無いため TypeError が投げられます。「とりあえず全部見たい」なら { childList: true, attributes: true, characterData: true, subtree: true } のように指定します。

なお、observe() は監視対象を追加していく形なので、別の要素に対して何度でも呼べます。ただし同じ要素に対してもう一度呼ぶと、オプションが後から指定したものに置き換わる点は覚えておいてください。監視をまとめて解除したいときは disconnect() を1回呼べば、その observer が抱えているすべての監視が止まります。

observe() のオプションで「何を監視するか」を決める

監視の挙動は、第2引数のオブジェクトで細かく指定します。指定できるプロパティは次の7つです。

オプション内容
childList子ノードの追加・削除を監視する(既定値 false)
attributes属性の変更を監視する(既定値 false)
characterDataテキストノードの中身の変更を監視する(既定値 false)
subtree対象自身だけでなく、その子孫すべてを監視の対象に含める
attributeOldValue変更前の属性値を oldValue で受け取る(指定すると attributes は true 扱い)
characterDataOldValue変更前のテキストを oldValue で受け取る(指定すると characterData は true 扱い)
attributeFilter監視する属性名を配列で絞り込む(例: ['class', 'aria-expanded'])

いちばん使うのが subtree との組み合わせです。childList: true だけだと監視対象の直接の子の増減しか通知されないため、孫要素の入れ替わりまで追いたいときは subtree: true を足します。逆に、監視範囲を広げるほどコールバックが呼ばれる回数は増えるので、必要な範囲に絞るのが基本です。

属性を監視するときは attributeFilter がとても効きます。attributes: true だけでは style や data-* を含むあらゆる属性の変更で呼ばれてしまいますが、attributeFilter: ['class'] と書けば class の変化だけを拾えます。attributeFilter や attributeOldValue を指定した場合は attributes: true を省略できますが、読みやすさのために明示しておくのがおすすめです。

コールバックが受け取る MutationRecord の読み方

コールバックには MutationRecord オブジェクトの配列が渡ってきます。1件が1つの変化に対応しており、どんな変化だったかは type を見て判断します。主なプロパティは次のとおりです。

プロパティ内容
type変化の種類。"childList" / "attributes" / "characterData" のいずれか
target変化が起きたノード。childList なら子が変わった親要素、attributes ならその要素、characterData ならテキストノード
addedNodes追加されたノードの NodeList(childList のとき。無ければ空)
removedNodes削除されたノードの NodeList(childList のとき。無ければ空)
attributeName変更された属性名(attributes 以外は null)
oldValue変更前の値。attributeOldValue / characterDataOldValue を指定したときだけ入り、それ以外は null

このほかに、追加・削除された位置を知るための previousSibling と nextSibling、名前空間付き属性のための attributeNamespace もあります。addedNodes にはテキストノードやコメントノードも入ってくるため、要素だけを扱いたいときは node.nodeType === Node.ELEMENT_NODE や node instanceof HTMLElement で絞り込むのが定石です。

ひとつ注意点として、変更後の値を教えてくれるプロパティはありません。属性の新しい値が知りたければ、コールバックの中で record.target.getAttribute(record.attributeName) のように自分で読み取ります。

実践例1:あとから追加される要素を検知して初期化する

もっとも多いユースケースが、動的に増える要素の初期化です。childList と subtree でリストを監視し、追加された要素にクラスを付けています。ボタンを押して、追加が検知されるのを確かめてみてください。「まとめて3件追加」を押すと、3回の appendChild() に対してコールバックが1回だけ呼ばれ、そこに 3 件の MutationRecord が届くことが分かります。

<div class="panel">
  <button id="add-one">1件追加</button>
  <button id="add-three">まとめて3件追加</button>
  <ul id="list"></ul>
</div>
<pre id="log">監視を開始しました。ボタンを押してください。</pre>
body {
  font-family: sans-serif;
  margin: 16px;
}

.panel {
  border: 1px solid #ddd;
  padding: 12px 16px;
  border-radius: 6px;
}

button {
  padding: 6px 12px;
  margin-right: 8px;
  cursor: pointer;
}

#list {
  margin: 12px 0 0;
  padding-left: 20px;
}

/* 検知したあとに付与されるクラス */
#list li.is-ready {
  color: #007bff;
  font-weight: bold;
}

#log {
  margin-top: 16px;
  padding: 12px;
  background: #f5f5f5;
  border-radius: 6px;
  white-space: pre-wrap;
  font-size: 13px;
  line-height: 1.7;
}
const list = document.getElementById('list');
const log = document.getElementById('log');
let count = 0;

function print(text) {
  log.textContent += '\n' + text;
}

// 1. 変化がまとまって届くコールバックを渡してインスタンスを作る
const observer = new MutationObserver((records) => {
  print('--- コールバックが1回呼ばれました(MutationRecord ' + records.length + '件) ---');

  for (const record of records) {
    // record.type は 'childList' / 'attributes' / 'characterData' のいずれか
    record.addedNodes.forEach((node) => {
      // テキストノードも含まれるので、要素だけを対象にする
      if (node.nodeType === Node.ELEMENT_NODE) {
        node.classList.add('is-ready'); // あとから来た要素を初期化する
        print('追加を検知: ' + node.textContent);
      }
    });
  }
});

// 2. 監視対象と、何を見るかを指定して監視を開始する
observer.observe(list, { childList: true });

document.getElementById('add-one').addEventListener('click', () => {
  const li = document.createElement('li');
  li.textContent = '項目 ' + ++count;
  list.appendChild(li);
});

document.getElementById('add-three').addEventListener('click', () => {
  // 3回に分けて追加しても、コールバックは1回だけまとめて呼ばれる
  for (let i = 0; i < 3; i++) {
    const li = document.createElement('li');
    li.textContent = '項目 ' + ++count;
    list.appendChild(li);
  }
});
Preview

実務では、ここで node.classList.add() の代わりに、イベントリスナーの登録やライブラリの初期化を行います。「読み込み時に一度だけ初期化する処理」を関数として切り出しておき、初回はその関数を直接呼び、以降は MutationObserver から呼ぶ、という組み立てにすると重複が防げます。すでに初期化した要素を二度初期化しないよう、data-initialized のような目印を付けて判定するのも定番です。

目的の要素が現れた瞬間だけ知りたい場合は、見つけた時点でコールバックの第2引数(observer 自身)から disconnect() を呼んで監視を終えるのがすっきりします。

script.js
// 「#target が現れたら1回だけ処理して監視を終える」パターン
const observer = new MutationObserver((records, obs) => {
  const el = document.querySelector('#target');
  if (!el) return;

  init(el);         // 目的の処理
  obs.disconnect(); // 役目が終わったので監視を止める
});

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

この書き方は手軽ですが、document.body 全体を subtree 付きで監視するとページ内のあらゆる変更でコールバックが走ります。監視対象は可能な限り狭い親要素に絞り、用が済んだら必ず disconnect() するようにしてください。

実践例2:属性の変化を監視して連動処理をする

もうひとつの定番が属性の監視です。アコーディオンやタブの状態を aria-expanded や class で表しているとき、その属性の変化を監視しておけば、「状態を書き換える処理」と「見た目を変える処理」を分けて書けます。次のデモは、ボタンのクリックでは aria-expanded を切り替えるだけにして、パネルの開閉とラベルの変更は MutationObserver 側で行っています。

<div class="accordion">
  <button id="toggle" class="accordion__btn" aria-expanded="false">詳細を見る</button>
  <div id="panel" class="accordion__panel" hidden>
    <p>パネルの中身です。aria-expanded の変化を検知して開閉しています。</p>
  </div>
</div>
<pre id="log">aria-expanded の変化を監視しています。</pre>
body {
  font-family: sans-serif;
  margin: 16px;
}

.accordion__btn {
  padding: 8px 16px;
  cursor: pointer;
}

/* 属性の値に応じて見た目を変える */
.accordion__btn[aria-expanded="true"] {
  background: #007bff;
  color: #fff;
}

.accordion__panel {
  margin-top: 12px;
  padding: 12px 16px;
  border: 1px solid #ddd;
  border-radius: 6px;
}

#log {
  margin-top: 16px;
  padding: 12px;
  background: #f5f5f5;
  border-radius: 6px;
  white-space: pre-wrap;
  font-size: 13px;
  line-height: 1.7;
}
const btn = document.getElementById('toggle');
const panel = document.getElementById('panel');
const log = document.getElementById('log');

const observer = new MutationObserver((records) => {
  for (const record of records) {
    // attributeName と oldValue が使えるのは attributes の変化のとき
    const value = record.target.getAttribute(record.attributeName);
    log.textContent +=
      '\n' + record.attributeName + ': ' + record.oldValue + ' → ' + value;

    // 属性の変化に連動して、パネルの表示を切り替える
    panel.hidden = value !== 'true';
    btn.textContent = value === 'true' ? '閉じる' : '詳細を見る';
  }
});

observer.observe(btn, {
  attributes: true,                    // 属性の変化を監視
  attributeOldValue: true,             // 変更前の値も受け取る
  attributeFilter: ['aria-expanded'],  // この属性だけに絞る
});

// ボタンは aria-expanded を書き換えるだけ。表示の切り替えは observer 側の仕事
btn.addEventListener('click', () => {
  const expanded = btn.getAttribute('aria-expanded') === 'true';
  btn.setAttribute('aria-expanded', String(!expanded));
  btn.classList.toggle('is-open'); // filter 対象外なので通知されない
});
Preview

attributeFilter: ['aria-expanded'] で対象を絞っているので、同じ要素の class を書き換えてもコールバックは呼ばれません。ログに「変更前 → 変更後」が出ているのは attributeOldValue: true を指定して oldValue を受け取っているからです。この形にしておくと、他のスクリプトが同じ属性を書き換えた場合でも同じ連動処理が走るため、状態管理の入口を1か所にまとめられます。

コールバックは変更ごとではなく、まとめて非同期に呼ばれる

MutationObserver のコールバックは、変更が起きた瞬間には呼ばれません。変化はいったんキューに溜められ、マイクロタスクとして、つまり現在実行中のスクリプトが一区切りついたタイミングでまとめて処理されます。そのため、DOM を書き換えた直後の行では、まだコールバックは動いていません。

script.js
const observer = new MutationObserver(() => console.log('2: コールバック'));
observer.observe(list, { childList: true });

list.appendChild(document.createElement('li'));
console.log('1: appendChild の直後');

// 出力順は「1: appendChild の直後」→「2: コールバック」

これは欠点ではなく、まとめて通知することでパフォーマンスを確保するための設計です。100 件の要素を1件ずつ追加してもコールバックは1回で、そこに 100 件の MutationRecord が届きます。裏を返せば「変更の直後に必ず処理が終わっている」ことは期待できないので、コールバックの結果を前提にした処理は、コールバックの中に書くか await などで順序を整える必要があります。

この「溜まっているキュー」を自分で取り出すためのメソッドが takeRecords() です。呼ぶと未処理の MutationRecord の配列が返り、キューは空になります。空になった分についてはコールバックが呼ばれないため、「これまでの変更は無視して、ここから監視を再開したい」という場面や、監視を止める直前に取りこぼしを回収したい場面で使います。

script.js
// 溜まっている変更を取り出して自分で処理してから監視を止める
const pending = observer.takeRecords();
if (pending.length > 0) {
  handleRecords(pending);
}
observer.disconnect();

disconnect() はキューも空にするため、止めた時点で未処理だった変更は通知されません。取りこぼしたくないなら、上のように takeRecords() を先に呼んでおきます。

コールバックが呼ばれない・呼ばれすぎるとき

MutationObserver でつまずくのは、たいてい「反応してくれない」か「反応しすぎる」のどちらかです。原因はほぼ決まっているので、順に確認してみてください。

監視オプションが目的と合っていない

いちばん多いのが subtree の指定漏れです。{ childList: true } だけでは直接の子の増減しか通知されないので、深い階層で要素が差し替わっても何も起きません。同じく、子要素の属性を監視したいときも subtree: true が必要です。

テキストの変更が拾えないケースも典型的です。characterData が見ているのはテキストノードそのものなので、要素を対象に { characterData: true } と書いても、その要素自身はテキストノードではないため通知されません。要素の中のテキストを追いたいなら { characterData: true, subtree: true } とします。さらに、el.textContent = '新しい文字列' のような代入は既存のテキストノードを丸ごと差し替える操作なので、characterData ではなく childList の変化として通知される点にも注意が必要です。

もうひとつの落とし穴が、監視対象を取得するタイミングです。observe() に渡した要素が入れ替えられて DOM から外れると、監視は「その要素」に付いているため、新しく置かれた同じセレクターの要素は監視されません。動的に差し替わる要素を追う場合は、その親要素を subtree: true で監視するほうが安全です。

自分の処理が新しい変更を生んで無限ループになる

コールバックの中で監視範囲の DOM を書き換えると、その変更がまた通知され、コールバックが再び呼ばれ——という循環が起こります。たとえば class の変化を監視しているコールバックの中で同じ要素の class を書き換えると、際限なく呼ばれ続けてページが固まってしまいます。

対処のひとつは、書き換える前に監視を外し、終わったら再開する方法です。disconnect() がキューを空にするので、自分の変更が通知されることはありません。

script.js
const options = { attributes: true, attributeFilter: ['class'] };

const observer = new MutationObserver(() => {
  observer.disconnect();            // いったん監視を外す

  target.classList.add('checked'); // 自分で DOM を書き換える

  observer.observe(target, options); // 同じオプションで監視を再開する
});

observer.observe(target, options);

もうひとつは、フラグで「自分が起こした変更かどうか」を判定する方法です。書き換える前に isUpdating = true にしておき、コールバックの先頭でフラグが立っていたら何もせずに抜ける、という形にします。監視の開始・停止を繰り返したくない場合や、複数箇所から DOM を書き換える場合はこちらのほうが管理しやすいことがあります。どちらの方法でも、書き換えた属性や要素を attributeFilter で監視対象から外せるなら、それがもっとも単純な解決になります。

使い終わった監視を解除していない

監視は明示的に止めるまで続きます。モーダルを閉じたあとやコンポーネントを破棄したあとも disconnect() を呼ばずに放置すると、その要素に関する変更ごとにコールバックが動き続け、コールバックが参照しているオブジェクトも解放されません。SPA のように画面を作り替えるアプリでは、これが積み重なって動作が重くなる原因になります。

要素の生成とセットで observer を作ったなら、破棄処理とセットで disconnect() を呼ぶ——と決めておくのが確実です。React なら useEffect のクリーンアップ関数、Vue なら onUnmounted の中が置き場所になります。

IntersectionObserver・ResizeObserver との使い分け

ブラウザーには名前の似た Observer 系 API がいくつかあり、見張る対象が違います。MutationObserver が見るのは DOM の構造そのもの(要素・属性・テキスト)で、要素が画面内に入ったかどうかは IntersectionObserver、要素のサイズが変わったかどうかは ResizeObserver の担当です。

たとえば「スクロールして見えたら画像を読み込む」なら IntersectionObserver、「幅に応じてレイアウトを切り替える」なら ResizeObserver を選びます。MutationObserver で位置やサイズの変化を追おうとしても、CSS だけで起きる変化は DOM の変更を伴わないため検知できません。目的に合った API を選ぶことが、そのまま素直な実装につながります。

まとめ

MutationObserver は、自分の管理外で起きる DOM の変化を検知するための標準 API です。new MutationObserver(callback) でインスタンスを作り、observe(target, options) で監視を始め、不要になったら disconnect() で止める——この3ステップが基本で、childList / attributes / characterData と subtree の組み合わせで「何をどこまで見るか」を決めます。

コールバックは変更ごとではなくマイクロタスクでまとめて呼ばれ、詳細は MutationRecord の type や addedNodes、attributeName から読み取ります。監視範囲は必要最小限に絞る、コールバックの中で監視対象を書き換えるときは disconnect() やフラグでループを断つ、使い終わったら必ず解除する。この3点を守れば、あとから挿入される要素への対応も安全に書けるはずです。

参考ページ