MutationObserverの変更記録モデルとマイクロタスク配送
DOMの変更監視を、コールバック地獄も再入バグも起こさず実装できるようになる。変更をレコードへ束ね、マイクロタスクでまとめて配る仕組みと、同期通知をあえて避けた設計理由を原理から解説します。
- 1.MutationObserverはDOM変更を1つずつコールバックするのではなく、MutationRecordへ蓄積し、observe済みObserverを『マイクロタスクをキュー』して一括配送する。同じ操作群がまとまった単位で届く。
- 2.通知が同期でなくマイクロタスクなのは、変更の連鎖中に外部コードへ制御を渡さず一貫したツリー状態を保ち、Mutation Eventsで問題だったツリー破壊・再入・性能劣化を避けるため。配送は現在のタスク終端、レンダリング前に走る。
- 3.コールバック取得前なら takeRecords() で保留レコードを同期的に引き出せる。レコードは type(childList/attributes/characterData)ごとに addedNodes・oldValue 等を持ち、subtree 指定で子孫も観測。disconnect で全保留が破棄される。
なぜ同期通知ではなくレコードの束ねなのか
DOMの変更を監視したいとき、素朴な発想は「ノードが変わるたびにコールバックを呼ぶ」同期通知です。実際、初期のDOMにはこれを行う Mutation Events(DOMNodeInserted など)がありました。しかしこの設計は深刻な欠陥を抱え、現在は非推奨です。
問題の核心は、変更の途中で外部コードに制御が渡ることにあります。1回の appendChild のような操作の最中にイベントが同期発火し、そのハンドラがさらにDOMを書き換えると、進行中の変更と新しい変更が入れ子になります。ツリーの整合性を保つのが極端に難しくなり(再入問題)、1ノードの挿入が連鎖イベントを誘発して性能も崩れます。ブラウザ実装側でも、あらゆる変更点に同期ディスパッチを挿し込むコストが重くのしかかりました。
MutationObserver はこの反省から、変更をその場で通知しない設計に切り替えました。変更は MutationRecord という記録レコードに蓄えられ、複数の変更がまとまった単位で、後から非同期に配送されます。コールバックが走る時点ではDOM操作の連鎖はすでに完了しているため、観測側は常に一貫した確定状態のツリーを見ます。
| 観点 | Mutation Events(旧・非推奨) | MutationObserver |
|---|---|---|
| 通知のタイミング | 変更ごとに同期発火 | レコードへ蓄積し非同期に一括 |
| ツリーの状態 | 変更の途中(再入の危険) | 連鎖完了後の確定状態 |
| 連続変更の扱い | 1変更=1イベント | まとめて1配送に束ねる |
| 性能特性 | 挿入1回が連鎖発火 | 操作群をバッチ化し低コスト |
変更記録モデル:MutationRecordへの蓄積
MutationObserver を作り observe(target, options) を呼ぶと、ターゲットノードに観測対象(registered observer)が登録されます。以降、DOMに変更が起きるたびに、その変更を表す MutationRecord が、影響を受けるObserverの**記録キュー(record queue)**に積まれます。レコードは変更の種類ごとに次の情報を持ちます。
type : "childList" | "attributes" | "characterData"
target : 変更が起きたノード
addedNodes : 追加されたノード(childList時、staticなNodeList)
removedNodes : 削除されたノード(childList時)
previousSibling : 追加/削除位置の直前の兄弟
nextSibling : 追加/削除位置の直後の兄弟
attributeName : 変わった属性名(attributes時)
oldValue : 変更前の値(対応オプション指定時のみ)
重要なのは、レコードが変更の差分を表す点です。childList なら追加・削除ノードの集合、attributes なら変わった属性、characterData ならテキストの変化です。attributeOldValue / characterDataOldValue を有効にしない限り oldValue は null のままで、無駄な旧値保持を省きます。subtree: true を付けると、ターゲット自身だけでなく全子孫の変更も同じObserverに集約されます(DOMツリーの内部表現 の親子・兄弟リンクを辿る形で観測範囲が決まります)。
同じターゲットに対して observe を再度呼ぶと、その registered observer のオプションが置き換えられます。一方、別のターゲットに対する observe は独立した登録として加算されます。1つのObserverで複数ノードを観測でき、それらの変更は同じ記録キューに集約され、同じコールバックへ一括で届きます。
マイクロタスク配送:いつ、どう呼ばれるか
レコードが積まれただけではコールバックは走りません。仕様は「Observerをマイクロタスクとしてキューする(queue a mutation observer microtask)」という専用の手続きを定義します。あるObserverに初めてレコードが積まれた回に、まだスケジュール済みでなければ、通知用のマイクロタスクが1つ予約されます。同一バッチ内で何度変更が起きても、マイクロタスクは重複してキューされません。
そのマイクロタスクが実行されると、各Observerについて「記録キューを空にして取り出し、その配列を引数にコールバックを1回呼ぶ」という処理が走ります。つまりコールバックは、その間に溜まった全レコードをまとめて受け取ります。
const observer = new MutationObserver((records, obs) => {
// records は溜まった MutationRecord の配列。
// 1回の DOM 操作群がまとめて 1 配送で届く。
for (const r of records) {
if (r.type === "childList") {
handleChildList(r.addedNodes, r.removedNodes);
}
}
});
observer.observe(node, { childList: true, subtree: true });
マイクロタスクである帰結は明確です。配送は現在のタスク(またはマイクロタスク)が終わった直後、マイクロタスクキューを排出する段で走り、レンダリング更新より前に完了します。setTimeout(タスク)より早く、しかし同期コールバックより遅い、という中間の位置取りです。連続するDOM操作を1つの同期コードブロックで行えば、それらは1回の配送にまとまります。この順序関係は イベントループの内部構造 のタスク/マイクロタスク/レンダリングの並びそのものです。
1つのタスク内で1000ノードを追加しても、マイクロタスクは1回しか予約されず、コールバックも1回だけ呼ばれます(1000件のレコードを持って)。これがバッチ化の実体です。逆に、コールバック内でさらにDOMを変更すれば、それは次のマイクロタスク予約を生み、別の配送として届きます。再入はせず、世代が分かれます。
takeRecords と disconnect:保留レコードの制御
配送を待たずに保留中のレコードを同期的に引き出したいことがあります。takeRecords() は記録キューを空にして、溜まっていたレコード配列をその場で返します。これによりキューが空になるため、予約済みのマイクロタスクが後で走ってもコールバックには何も渡りません(あるいは新たな変更分だけが渡ります)。
// disconnect の直前に取りこぼしを回収する典型例
const pending = observer.takeRecords();
observer.disconnect();
processRecords(pending); // 配送を待たずに今すぐ処理
disconnect() は全ターゲットの registered observer を解除し、保留中のレコードもすべて破棄します。つまり disconnect 後にコールバックが残レコードで呼ばれることはありません。取りこぼしを避けたいなら、上のように disconnect の前に takeRecords() で回収します。
コールバック内でDOMを書き換えると、その変更が同じObserverに観測され、次のマイクロタスクで再びコールバックが呼ばれます。条件次第で延々と回り続けます。対策は、(1) 書き込み前に一時 disconnect() し書き込み後に observe() し直す、(2) 観測対象と書き込み対象を分離する、(3) コールバック冒頭で takeRecords() を捨てて自分起因の変更を無視する、のいずれかです。同期通知ではないため即時の無限再入は起きませんが、世代をまたいだループは十分起こり得ます。
レコードの粒度と観測範囲の注意点
childList のレコードは直接の子の追加・削除を記録します。subtree: true でも、記録される target は変更が直接起きた親ノードであり、addedNodes / removedNodes はそのとき動いたノードのみです。深い部分木をまるごと挿入しても、内部の各ノードが個別レコードになるわけではなく、部分木の根を addedNodes に持つ1レコードとして届きます。削除されたノードを removedNodes から得たあと、それを再び document から辿ろうとしても、すでにツリーから外れているため見つからない点にも注意します。
属性監視では attributeFilter で対象属性を絞れます。フィルタを与えると、列挙した属性以外の変更はレコード化されず、不要な通知を抑えられます。テキスト変更(characterData)は Text ノードなどのデータの変化を捉えるもので、要素の追加・削除とは別系統です。
なお MutationObserver は shadow ツリー内のノードも、そのツリーのホストではなくshadow root 側を直接 observe すれば監視できます。light DOM 側のObserverは shadow 境界を越えて子孫を観測しません(境界とツリー合成の原理は Shadow DOMのカプセル化 を参照)。
頻出は次の5点です。(1) 通知は同期せず MutationRecord に蓄積し、マイクロタスクで一括配送する。(2) 同じObserverのマイクロタスクは重複キューされず、連続変更が1配送に束ねられる。(3) 配送タイミングは現在のタスク終端・レンダリング前で、setTimeout より早い。(4) takeRecords() は保留レコードを同期取得、disconnect() は登録解除と保留破棄を行う。(5) 同期通知を避けた理由は、Mutation Eventsの再入・ツリー破壊・性能劣化の回避。
まとめ
MutationObserver は、DOM変更をその場で通知する代わりに MutationRecord へ束ね、observe済みObserverごとにマイクロタスクを1回キューして、溜まったレコードを一括配送する非同期モデルです。配送は現在のタスク終端・レンダリング前に走り、setTimeout より早く同期コールバックより遅い中間に位置します。同期通知を避けたのは、旧 Mutation Events が抱えた再入・ツリー整合性の破壊・性能劣化を構造的に防ぐためで、コールバックは常に変更連鎖が完了した確定状態のツリーを見ます。保留レコードは takeRecords() で同期回収でき、disconnect() は登録解除とともに保留を破棄します。同じ非同期計測の系譜は IntersectionObserverとResizeObserverの非同期計測モデル、配送順序の土台は イベントループの内部構造 と合わせて押さえてください。
Web/フロントエンド Article
MutationObserverの変更記録モデルとマイクロタスク配送を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
通知が同期でなくマイクロタスクなのは、変更の連鎖中に外部コードへ制御を渡さず一貫したツリー状態を保ち、Mutation Eventsで問題だったツリー破壊・再入・性能劣化を避けるため。配送は現在のタスク終端、レンダリング前に走る。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / DOM」に近いか確認する。
- 強みである「MutationObserverはDOM変更を1つずつコールバックするのではなく、MutationRecordへ蓄積し、observe済みObserverを『マイクロタスクをキュー』して一括配送する。同じ操作群がまとまった単位で届く。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。