SPAでは、初回表示後にもJavaScriptによってDOM要素が追加されます。後から追加された画像にloading="lazy"を設定するようなスクリプトでは、MutationObserverを使ってDOMの変化を監視できます。
一方、処理済みの要素を判別するために独自属性を書き込み、その属性変更までObserverで監視すると、自分の処理によってObserverが再び呼び出されます。
この記事では、次の設計を扱います。
- ObserverはDOMへの要素追加だけを監視する
- スクリプト自身が書き込む属性は監視しない
- 各処理は処理済みフラグを確認し、繰り返し実行できるようにする
コード例はTypeScript 5.xで記述します。
想定する処理
Webページに読み込まれたスクリプトが、画像へloading="lazy"を設定するケースを考えます。
初回ロード時に既存の画像を処理し、その後はSPAによって追加された画像も処理します。処理済みかどうかは、data-image-optimized属性で管理します。
const PROCESSED_ATTR = 'data-image-optimized';
function isProcessed(element: Element): boolean {
return element.hasAttribute(PROCESSED_ATTR);
}
function markAsProcessed(element: Element): void {
element.setAttribute(PROCESSED_ATTR, '');
}
画像を処理する関数は、すでに処理済みなら何もしません。
function optimizeImages(root: ParentNode = document): void {
root.querySelectorAll('img:not([loading])').forEach((image) => {
if (isProcessed(image)) return;
image.setAttribute('loading', 'lazy');
markAsProcessed(image);
});
}
このように、同じ処理を複数回呼んでも結果が変わらない性質を冪等性と呼びます。Observerのコールバックが複数回実行される可能性を考えると、DOMを変更する処理は冪等にしておく必要があります。
自分が書き込む属性を監視すると再通知が発生する
MutationObserver.observe()では、attributes: trueを指定すると属性の変更を監視できます。さらにattributeFilterを指定すると、監視する属性を限定できます。
次の設定では、スクリプト自身が書き込むdata-image-optimizedを監視しています。
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [PROCESSED_ATTR],
});
この場合、処理の流れは次のようになります。
- SPAが画像を追加する
- Observerのコールバックが呼ばれる
-
optimizeImages()がdata-image-optimizedを書き込む - その属性変更によって、Observerのコールバックが再び呼ばれる
- 2回目の処理では処理済み判定により何も行われない
処理済み判定が正しく実装されていれば、通常は永久にループしません。しかし、実質的に何もしないObserver通知とDOM検索が1回余分に発生します。
対象要素や処理ルールが多い場合は、各属性変更によって追加の通知が発生します。コールバック内で毎回document.querySelectorAll()を実行していると、不要なDOM走査も増えます。
debounceで通知をまとめても、自分の書き込みが新たな通知を作る構造自体は変わりません。
属性変更を必要としないなら監視しない
画像の追加を検知したいだけなら、属性変更を監視する必要はありません。childListとsubtreeだけを指定します。
observer.observe(document.body, {
childList: true,
subtree: true,
});
これにより、次の変更だけがObserverへ通知されます。
- 子要素の追加
- 子要素の削除
- 子孫要素で発生した同様の変更
loadingやdata-image-optimizedの追加は属性変更なので、Observerの発火条件にはなりません。
Observerは「新しい要素が追加されたこと」を知らせ、処理済み属性は「その要素をすでに処理したか」を判断します。それぞれの役割を分けることで、自分の書き込みによる再通知を避けられます。
追加されたノードだけを処理する
Observerが呼ばれるたびにページ全体を検索する実装でも動作しますが、ページが大きくなると処理コストが増えます。
MutationRecord.addedNodesを使い、追加されたノードだけを処理すると効率的です。
function processAddedNode(node: Node): void {
if (!(node instanceof Element)) return;
if (node.matches('img:not([loading])')) {
optimizeImage(node);
}
optimizeImages(node);
}
function optimizeImage(image: Element): void {
if (isProcessed(image)) return;
image.setAttribute('loading', 'lazy');
markAsProcessed(image);
}
Observerでは、各MutationRecordのaddedNodesを渡します。
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
processAddedNode(node);
}
}
});
追加されたノード自身が画像である場合と、追加されたコンテナの内側に画像がある場合の両方を処理しています。
ページ全体へ毎回querySelectorAll()を実行するよりも、変更された範囲へ処理対象を限定できます。
実装全体
初回処理とObserverの設定をまとめると、次のようになります。
const PROCESSED_ATTR = 'data-image-optimized';
function isProcessed(element: Element): boolean {
return element.hasAttribute(PROCESSED_ATTR);
}
function markAsProcessed(element: Element): void {
element.setAttribute(PROCESSED_ATTR, '');
}
function optimizeImage(image: Element): void {
if (isProcessed(image)) return;
image.setAttribute('loading', 'lazy');
markAsProcessed(image);
}
function optimizeImages(root: ParentNode = document): void {
root.querySelectorAll('img:not([loading])').forEach(optimizeImage);
}
function processAddedNode(node: Node): void {
if (!(node instanceof Element)) return;
if (node.matches('img:not([loading])')) {
optimizeImage(node);
}
optimizeImages(node);
}
export function setupImageObserver(): () => void {
if (
typeof window === 'undefined' ||
typeof MutationObserver === 'undefined' ||
!document.body
) {
return () => {};
}
optimizeImages();
const observer = new MutationObserver((records) => {
try {
for (const record of records) {
for (const node of record.addedNodes) {
processAddedNode(node);
}
}
} catch (error) {
console.warn('Image observer callback failed', error);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}
この実装では、Observerと画像処理の責務を次のように分けています。
| 処理 | 責務 |
|---|---|
MutationObserver |
DOMへ追加されたノードを通知する |
processAddedNode() |
追加された範囲から画像を探す |
optimizeImage() |
画像を一度だけ処理する |
data-image-optimized |
処理済みかどうかを記録する |
| cleanup関数 | Observerを停止する |
属性変更を監視する必要がある場合
既存要素の属性が後から変わり、それによって処理対象になる場合は、属性変更の監視が必要です。
たとえば、最初はdata-srcだけを持つ画像へ、後からsrcが設定されるケースです。この場合は、必要な属性だけをattributeFilterへ指定します。
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'data-src'],
});
重要なのは、スクリプト自身が変更する属性をできるだけ含めないことです。
attributeFilter: [
'src',
'data-src',
// PROCESSED_ATTRは含めない
]
ただし、監視対象の属性を処理内でも変更する場合は、同じ問題が再び発生します。その場合は、次のいずれかを検討します。
- 書き込む属性と監視する属性を分ける
-
MutationRecord.oldValueを使って実質的な変更か判定する - Observerを一時的に停止してから変更し、再接続する
- 自分が行った変更かどうかを別の状態で管理する
Observerの停止と再接続は、その間に発生したホストページ側の変更を見落とす可能性があります。単純に循環を避ける目的だけで多用するのは避けた方が安全です。
debounceは必要か
debounceは、短時間に連続した処理を一定時間待ってからまとめて実行する方法です。大量のDOM更新が短時間に発生するページでは役立ちます。
ただし、MutationObserverの通知はもともと複数の変更をMutationRecord[]としてまとめて渡します。そのため、常に追加のdebounceが必要とは限りません。
まずは追加されたノードだけを処理し、それでも処理回数や負荷が問題になる場合にdebounceを追加する方が単純です。
debounceを導入する場合は、追加ノードを保持し続けることでDOM要素の参照が長く残る点や、cleanup時にタイマーを解除する必要がある点にも注意します。
childList監視でも自分の変更が通知される場合
今回の例では、スクリプトが変更するのは属性だけです。そのため、attributesを監視しなければ自分の変更はObserverへ通知されません。
一方、処理内で次のような構造変更を行う場合は、childListだけを監視していても自分の変更が通知されます。
- 要素を追加する
- 要素を削除する
- 要素を別の場所へ移動する
- テキストを要素として差し替える
たとえば、画像の隣にボタンを追加すると、そのボタンの追加がchildListの変更として通知されます。
その場合も、処理対象を限定し、追加した要素を明確に識別する必要があります。
const OWNED_ATTR = 'data-owned-by-image-script';
function processAddedNode(node: Node): void {
if (!(node instanceof Element)) return;
if (node.hasAttribute(OWNED_ATTR)) return;
// 自分が追加した要素を再処理しない
}
「属性を監視しなければ循環しない」のではなく、自分の処理がどの種類のMutationを発生させるかを確認し、そのMutationを不要に監視しないことが基本です。
まとめ
MutationObserverを使った処理では、Observerのコールバック自身もDOMを変更します。その変更を監視対象に含めると、自分の処理によって不要な再通知が発生します。
画像へ属性を追加する今回の例では、次の構成が適しています。
-
childListとsubtreeだけを監視する - 自分が書き込む属性は監視しない
- 追加されたノードだけを処理する
- 処理済みフラグによって各処理を冪等にする
属性変更を検知する要件がある場合も、attributeFilterには本当に必要な属性だけを指定します。特に、自分が書き込む属性を監視対象へ含める場合は、再通知が必要なものかを確認する必要があります。
重要なのは、Observerと処理済みフラグの役割を分けることです。Observerは処理を開始する契機を作り、処理済みフラグは同じ要素への重複処理を防ぎます。