既存ページにイベント計測を追加するサードパーティ JS は、ホストページの DOM を監視しながら、その DOM 自体を書き換えることがあります。たとえばボタンやリンクに data-track-id を付与し、計測対象として初期化済みであることを data-track-bound に記録します。
対象が静的なページであれば、初回ロード時に一度だけ初期化すれば済みます。しかし、SPA では画面遷移や非同期通信によって DOM が継続的に更新されます。そのため、MutationObserver で変化を監視し、新しく現れた計測対象にも属性付与を行います。
このときの基本方針は、以前 Qiita に書いた「MutationObserver で自分が書き込む属性を観測しない設計」と同じです。サードパーティ JS 自身が書く属性を観測対象に含めると、自分の書き込みで observer が再通知されます。この記事ではその基礎は前提にして、イベント計測のサードパーティ JS で本番運用時に必要になりやすい対策に絞ります。
この記事では、主に次のような問題への対処を扱います。
- 自分が付けた
data-*属性で observer が再通知される -
classが頻繁に変わるページで再適用が実行されすぎる - 前回の再適用が終わる前に、次の再適用が始まる
- 画面遷移後も timer や observer が残る
前提: 自分が書く属性を観測しない
再通知を防ぐ中心は、attributeFilter で再適用のきっかけとして必要な属性だけを観測することです。たとえばサードパーティ JS が次の属性を書き換えるとします。
data-track-iddata-track-eventdata-track-bounddata-track-statetitle
これらを観測対象に含めなければ、自分の書き込みによって監視コールバックが再び呼ばれる経路を断てます。通知後にガードフラグで除外するより、通知の入口を狭くするほうが実行タイミングに依存しません。
const OBSERVED_ATTRIBUTES = [
'class',
'hidden',
'disabled',
'data-ui-state',
'data-open',
];
const observer = new MutationObserver((records) => {
enqueue(records);
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: OBSERVED_ATTRIBUTES,
});
観測するのは、ホストページの状態変化を示す属性です。hidden が外れて要素が表示された、disabled が外れて操作可能になった、data-ui-state が closed から open になった、class の変更によって表示状態が変わった、といったケースを拾います。一方、自分が追加した data-track-id や data-track-bound は再適用のきっかけにする必要がありません。
サードパーティ JS が <head> 内で実行される可能性がある場合は、document.body が生成された後に observer を初期化する必要があります。
なお、attributeFilter を指定すると属性監視は有効になるため、仕様上は attributes: true を省略できます。ここでは、属性を監視する意図を明確にするため、あえて明記しています。
class の高頻度な変更をまとめる
再適用ループを防いでも、observer が頻繁に起動しすぎる問題は残ります。特に注意が必要なのが class 属性です。
class は、スタイルライブラリによる更新、スクロール連動の表示切り替え、アニメーション、hover や focus の状態表現、ローディングスピナーなどで高頻度に変更されます。これらの変更をすべて即座に適用処理の再実行へつなぐと、見た目だけの変化によって重い DOM 走査が繰り返されます。
一方で、class を観測対象から完全に外すこともできません。ユーティリティクラスの付け外しによって display: none が解除され、適用対象が表示されるケースがあるためです。
そこで、変更内容に応じて待ち時間を切り替えます。
-
classの変更だけなら 200 ms 待つ -
childListの変更や、hidden、disabledなどの状態を表す属性の変更が混ざったら 80 ms に短縮する - 変更が途切れなくても、最長 500 ms で必ず処理する
これは、変更内容に応じて debounce の待ち時間を切り替え、最大待機時間を設ける設計です。
const STATE_DEBOUNCE_MS = 80;
const CLASS_DEBOUNCE_MS = 200;
const MAX_WAIT_MS = 500;
let timer: ReturnType<typeof setTimeout> | null = null;
let pendingSince: number | null = null;
let hasNonClassChange = false;
function enqueue(records: MutationRecord[]): void {
pendingSince ??= performance.now();
for (const record of records) {
if (
record.type !== 'attributes' ||
record.attributeName !== 'class'
) {
hasNonClassChange = true;
}
}
const delay = hasNonClassChange
? STATE_DEBOUNCE_MS
: CLASS_DEBOUNCE_MS;
const elapsed = performance.now() - pendingSince;
const wait = Math.min(delay, MAX_WAIT_MS - elapsed);
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(flush, Math.max(0, wait));
}
この実装では、スピナーが 50 ms ごとに class を更新していても、変更を蓄積し始めてから 500 ms 以内に必ず flush されます。通常の debounce のように、変更が止まるまで待ち続ける状態にはなりません。また、class の変更を待っている途中で hidden や childList の変更が届けば、待ち時間は 80 ms に短縮されます。
ここでは flush() や MutationObserver の初期化部分は省略しています。最終的に DOM 全体へ再適用するだけなら、MutationRecord[] を保存せず、class 以外の変更が含まれているかどうかだけを持てば十分です。高頻度なページでは、不要な MutationRecord を溜めないほうが扱いやすくなります。
非同期の再適用を直列化する
debounce で observer の発火をまとめても、再適用処理の多重実行は防ぎきれません。たとえば applyAll() が大きな DOM を扱うため、途中でメインスレッドへ制御を返す非同期処理になっているとします。
async function applyAll(): Promise<void> {
await applyImageFixes();
await yieldToMain();
await applyButtonFixes();
await yieldToMain();
await applyFormFixes();
}
前回の applyAll() が終わる前に、observer、スクロール、画面遷移などから新しい実行要求が届くと、同じ処理が並行して走る可能性があります。
function requestReapply(): void {
void applyAll();
}
対策は、実行中に届いた要求を保留中の要求として覚えておき、完了後に 1 回だけ再実行することです。
export function createReapplyController(
applyAll: () => Promise<void>,
): {
request: (reason: string) => void;
dispose: () => void;
} {
let inFlight = false;
let pending = false;
let pendingReason: string | null = null;
let disposed = false;
const run = (reason: string): void => {
if (disposed) return;
if (inFlight) {
pending = true;
pendingReason = reason;
return;
}
inFlight = true;
void Promise.resolve()
.then(() => applyAll())
.catch((error: unknown) => {
console.warn('[third-party-js] re-apply failed', {
reason,
error,
});
})
.finally(() => {
inFlight = false;
if (disposed || !pending) {
return;
}
const nextReason = pendingReason ?? 'rerun';
pending = false;
pendingReason = null;
run(nextReason);
});
};
return {
request: run,
dispose: () => {
disposed = true;
pending = false;
pendingReason = null;
},
};
}
debounce は短時間に発生した変更を時間軸でまとめます。一方、実行中に届いた再適用要求は、保留中の要求として 1 回分にまとめます。observer 以外にも再適用の入口があるなら、両方が必要です。
再適用を冪等にする
debounce や実行中要求の集約を入れると、applyAll() の呼び出し回数は入力イベントの数と一致しなくなります。同じ変更に対して 1 回だけ実行されることもあれば、処理中に別の変更が届いて 2 回実行されることもあります。そのため、再適用は何回呼ばれても同じ結果へ収束する必要があります。
const BOUND_MARKER = 'data-track-bound';
function applyTrackingMetadata(
button: HTMLButtonElement,
trackId: string,
): boolean {
if (button.hasAttribute(BOUND_MARKER)) {
return false;
}
if (button.hasAttribute('data-track-id')) {
return false;
}
button.setAttribute('data-track-id', trackId);
button.setAttribute(BOUND_MARKER, 'true');
return true;
}
イベント計測の例なら、次の三つを守ります。
-
button:not([data-track-bound])のように、処理済み要素を最初から除外する - 書き込み直前にも、処理済みマーカーや現在値を確認する
- 同じ入力から同じ
data-track-idを生成する
再適用のたびに data-track-id へ文字列を追記するような実装だと、実行回数によって最終結果が変わります。間引きは性能を改善する仕組みであり、正しさを保証する仕組みではありません。正しさは冪等性で担保します。
DOM 走査を減らす
適用ルールが多数ある場合、1 件ずつ querySelectorAll() を呼ぶ実装は、再適用のたびに多くのセレクタ評価を発生させます。
function applyPerSelector(
rules: readonly TrackingRule[],
): number {
let applied = 0;
for (const rule of rules) {
const elements = document.querySelectorAll(
rule.selector,
);
for (const element of elements) {
if (applyOne(element, rule)) {
applied++;
}
}
}
return applied;
}
改善策の一つは、セレクタをカンマで結合し、候補要素をまとめて取得することです。
function applyCombined(
rules: readonly TrackingRule[],
): number {
if (rules.length === 0) {
return 0;
}
const combined = rules
.map((rule) => rule.selector)
.join(',');
const candidates = document.querySelectorAll(combined);
let applied = 0;
for (const element of candidates) {
for (const rule of rules) {
if (!element.matches(rule.selector)) {
continue;
}
if (applyOne(element, rule)) {
applied++;
}
}
}
return applied;
}
この方法では、querySelectorAll() の呼び出し回数を減らせます。ただし、常に速くなるとは限りません。結合したセレクタが長すぎる、候補要素が多く Element.matches() の照合が増える、同じ要素に複数の適用ルールがマッチする、といった場合は個別クエリのほうが速い可能性もあります。
また、ルールの適用順序に意味がある場合は、結合クエリへ単純に置き換えると挙動が変わることがあります。実際の適用ルールと対象ページを使って計測してから採用します。外部から受け取ったセレクタを結合する場合は、1 件の不正なセレクタでクエリ全体が失敗します。事前に検証するか、例外が発生したルールを切り離せるようにし、セレクタの長さ、ルール数、1 回で処理する要素数にも上限を置きます。
dispose() で遅延処理を止める
DOM 監視を行うサードパーティ JS には、初期化だけでなく破棄処理も必要です。停止対象には、少なくとも次のものがあります。
MutationObserver- debounce のタイマー
- idle callback
- 実行中に予約された再実行
- スクロールやリサイズのイベントリスナー
- SPA の画面遷移を検知するフック
function dispose(): void {
observer.disconnect();
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
cancelIdleWork();
reapplyController.dispose();
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
}
停止順序は、次の考え方にすると整理しやすくなります。
- observer やイベントリスナーを解除し、新しい実行要求が発生しないようにする
- 予約済みの timer や idle callback を取り消す
- 実行中の処理が完了しても再起動しない状態にする
ただし、Promise としてすでに実行中の applyAll() は、通常の dispose() だけでは途中停止できません。停止が必要なら、AbortController の AbortSignal を処理へ渡し、各処理単位で中断を確認する設計が必要です。
async function applyAll(
signal: AbortSignal,
): Promise<void> {
signal.throwIfAborted();
await applyImageFixes();
signal.throwIfAborted();
await yieldToMain();
signal.throwIfAborted();
await applyButtonFixes();
}
observer.disconnect() を呼んだからといって、内部のすべての遅延処理が止まるわけではありません。timer と idle callback は個別に取り消し、すでに開始した Promise については、完了後の処理を無効化するか、AbortSignal に対応させる必要があります。
まとめ
DOM を監視して書き換えるサードパーティ JS では、まず自分が書く属性を観測対象から外すことが重要です。そのうえで、頻繁な class 変更や実行中に届く再適用要求をまとめると、不要な再実行を抑えられます。dispose() では observer だけでなく、timer や保留中の再実行も止めます。初期化、再適用、破棄のそれぞれで実行経路を整理しておくと、ホストページ側の変更が多い環境でも扱いやすくなります。
参考情報
- MDN Web Docs — MutationObserver
- MDN Web Docs — MutationRecord
- MDN Web Docs — MutationObserver.observe() attributeFilter
- MDN Web Docs — MutationObserver.observe() childList
- MDN Web Docs — MutationObserver.observe() subtree
- MDN Web Docs — setTimeout()
- MDN Web Docs — querySelectorAll()
- MDN Web Docs — Element.matches()
- MDN Web Docs — AbortController