核心原則

MutationObserver 監聽最少必要的變動 — 從「監聽哪個 root」「觀察什麼類型」「多久觸發一次」三維度收斂。 範圍寬會頻繁觸發、option 勾多會在不關心的變動上跑邏輯、apply 自己改 DOM 會引發無限循環。三維度都該顯式設計、不能只丟預設。


為什麼 observer 需要獨立議題

跟 selector 的差異

Observer 與 selector 都涉及「DOM 範圍」、機制完全不同:

維度SelectorObserver
時機同步、當下查詢非同步、回應未來變動
執行頻率一次或顯式重呼叫隨 DOM 變動自動觸發
失敗模式撈太多 / 撈太少觸發太頻繁 / 漏觸發 / 無限循環
設計重點起點 + 範圍 + 過濾監聽範圍 + option + 頻率

把 selector 與 observer 綁同一篇討論會混淆 — 兩者解決的是不同問題、有不同失敗模式、需要不同的設計工具。

Observer 寬範圍的失敗模式

失敗模式表現根因
過度觸發短時間觸發數十次subtree 太深 + option 太多
在錯時機跑layout 還沒穩就跑 apply沒等 framework patch 結束
無限循環apply 自己改 DOM 又觸發 observer沒 disconnect/observe 保護
漏掉變動預期會觸發但沒觸發option 沒勾對、或 root 選錯

四種都來自「沒精細設計 observer 的監聽形狀」。


三維度收斂

維度 1:監聽哪個 root(範圍)

核心定義:observer 的 root 元素決定「哪些範圍內的變動會被看到」。

1// 寬:監聽整個 .pagefind-ui
2new MutationObserver(apply).observe(ui, { childList: true, subtree: true });
3
4// 收斂:只監聽結果列表
5var results = shell.querySelector('.pagefind-ui__results');
6new MutationObserver(apply).observe(results, { childList: true });

寬範圍把無關變動也帶進來 — pagefind 重繪 input、調整 filter、重排 chip 都會觸發 apply、但 apply 只關心結果變動。

Root 選擇的決策:找到「包含所有目標變動、但不包含其他無關變動的最小元素」。

  • 太大 → 帶進無關變動、過度觸發
  • 太小 → 漏掉真正關心的變動
  • 剛好 → 只關心的變動觸發

問自己:「我關心的變動發生在哪些元素?這些元素的最小共同 ancestor 是誰?」答案就是 observer root。

維度 2:觀察什麼類型(option flag)

核心定義:MutationObserver 提供四種 option、每種對應不同類型變動:

1{
2  childList: true,        // 子節點增 / 減 / 重排
3  attributes: true,       // 屬性變動
4  attributeFilter: ['data-state'],  // 只看特定屬性
5  characterData: true,    // 文字內容變動
6  subtree: true,          // 上面三種往子樹深處看
7  attributeOldValue: true,  // 屬性變動時記錄舊值
8  characterDataOldValue: true,
9}

預設只勾需要的、不要全部 true:

Option用途觸發頻率
childList: true子節點增減
childList + subtree任何深度的子節點增減
attributes 全屬性任何屬性變動最高
attributes + attributeFilter只特定屬性
characterData文字內容(少用)

避免勾 subtree:subtree 把監聽從「直接子」擴展到「整個子樹」、觸發頻率可能爆炸。只在「真的需要看深層變動」時用。

避免無 filter 的 attributes:DOM 屬性變動很頻繁(class 改、style 改、aria-* 改),不過濾會被淹沒。用 attributeFilter: [...] 縮到只看你關心的屬性。

維度 3:多久觸發一次(頻率)

核心定義:observer 的回呼可能短時間內被連續呼叫、用 debounce 把多次合併成一次。

1var timer;
2function schedule() {
3  clearTimeout(timer);
4  timer = setTimeout(apply, 80);
5}
6new MutationObserver(schedule).observe(root, { childList: true });

Debounce 80ms 表示「最後一次變動後 80ms 沒再變、才跑 apply」 — 把連續變動合併。

Debounce vs Throttle

機制行為適合
Debounce安靜後執行等 framework 連續 patch 結束
Throttle固定頻率執行UI 同步要立即反應、但限速
立即執行每次都跑變動頻率本來就低、且每次都要處理

大部分 observer 場景適合 debounce — framework patch 是突發性、不是持續的。

Debounce 時間選擇

時間適合
16ms(一個 frame)跟 paint 同步、最即時
50-100ms一般 UI 反應、肉眼感受不到延遲
200-300ms等使用者輸入結束
1000ms+後台處理、不影響 UI

預設 50-100ms — 比一個 frame 寬、又不會讓使用者感受延遲。


Self-mutation 循環的處理

問題場景

apply 函式自己也改 DOM 時、會再次觸發 observer:

1function apply() {
2  // 改了某個元素的 class(attribute 變動)
3  someEl.classList.add('processed');
4}
5new MutationObserver(apply).observe(root, {
6  attributes: true, subtree: true,
7});
8// → apply 改 class 觸發 observer → observer 又呼叫 apply → 無限循環

這不是邏輯錯、是 observer 機制的特性:observer 不會區分「是不是 apply 自己改的」。

解法:disconnect / observe 配對

1var observer = new MutationObserver(function () {
2  observer.disconnect();      // 暫停監聽
3  apply();                    // 自己改 DOM 不會觸發
4  observer.observe(root, options);  // 恢復監聽
5});
6observer.observe(root, options);

apply 期間 observer 暫停、apply 結束後恢復 — 自己的改動不會觸發自己。

解法替代:用 attribute 標記區分

1function apply() {
2  isApplying = true;
3  someEl.classList.add('processed');
4  isApplying = false;
5}
6new MutationObserver(function () {
7  if (isApplying) return;
8  apply();
9}).observe(root, options);

但這個解法有時序風險 — observer 是非同步、isApplying 可能在錯時間被讀。disconnect/observe 配對更穩

解法替代:root 與目標分離

如果 apply 改的是 A、observer 監聽的是 B(A 跟 B 沒交集),自然不循環:

1new MutationObserver(apply).observe(resultsEl, { childList: true });
2function apply() {
3  // 改的是 input 而不是 results — 不會觸發 observer
4  inputEl.value = '...';
5}

設計時讓 observer 看的範圍跟 apply 改的範圍結構上分離 — 是最乾淨的解法、不需要 disconnect 配對。


觀察的時機問題

Observer 跟 framework 渲染週期競爭

Observer 在 framework 連續 patch 中段觸發、可能在 layout 還沒穩時就跑 apply、造成短暫視覺錯位:

1// framework 連續 patch:
2//   patch 1 → observer 觸發 → apply 跑 → 視覺 A
3//   patch 2 → observer 觸發 → apply 跑 → 視覺 B
4//   patch 3 → observer 觸發 → apply 跑 → 視覺 C(最終)
5// 使用者看到 A → B → C 的閃爍

Debounce 是這個問題的解 — 讓 observer 等 patch 完成才跑 apply。

確認時機正確

寫 observer 時自問:

問題答案決定
Apply 跑的時候 layout 是否已穩定?是否需要 debounce
Apply 自己改 DOM 嗎?是否需要 disconnect 配對
我關心的變動類型是什麼?option flag 怎麼勾
變動發生在哪一層?是否需要 subtree
Framework 的渲染週期會干擾嗎?debounce 時間取多久

每個問題都該有顯式答案、不能丟預設。


內在屬性比較:四種 observer 設計

設計觸發頻率Layout 穩定性維護成本
全勾 + subtree + 無 debounce最高低 — patch 中段觸發低(短期)/ 高(debug 噩夢)
收斂 root + 必要 option + 無 debounce
收斂 root + 必要 option + debounce
結構分離 + 收斂 + debounce最低最高中(前期設計成本)

推薦:收斂 root + 必要 option + debounce。apply 不改 DOM 時不需要 disconnect;改的話用結構分離優先、退而求其次用 disconnect。


進階技巧

1. 動態調整 observer 範圍

當監聽目標可能還沒 mount 時、用兩階段 observer:

 1// 階段 1:等目標 mount
 2var bootstrap = new MutationObserver(function () {
 3  var target = shell.querySelector('.pagefind-ui__results');
 4  if (!target) return;
 5  bootstrap.disconnect();
 6
 7  // 階段 2:mount 後監聽目標
 8  new MutationObserver(apply).observe(target, { childList: true });
 9});
10bootstrap.observe(shell, { childList: true, subtree: true });

階段 1 用寬範圍找到目標、階段 2 切到精準範圍 — 把寬範圍的觸發限制在「找目標」這個短時間。

2. 用 takeRecords 主動取出累積變動

1var observer = new MutationObserver(function () { /* ... */ });
2observer.observe(root, options);
3
4// 之後某時間點、想立刻處理累積的變動
5var records = observer.takeRecords();
6processRecords(records);

takeRecords 取出尚未觸發回呼的變動記錄、主動處理 — 適合「我想在某時間點同步處理累積變動」場景。

3. 多 observer 各管一塊

不要用一個 observer 監聽全部、各分一個:

1new MutationObserver(applyA).observe(elA, { childList: true });
2new MutationObserver(applyB).observe(elB, { attributes: true });

各自獨立 — 一個 observer 出錯不影響另一個、debug 範圍小、option 各自最佳化。


設計取捨:MutationObserver 的設計策略

四種做法、各自機會成本不同。這個專案選 A(收斂 root + 必要 option + debounce)當預設、其他做法在特定情境合理。

A:收斂 root + 必要 option + debounce + 結構分離(這個專案的預設)

  • 機制:root 取最小共同 ancestor、option 只勾真正關心的變動、加 50-100ms debounce、apply 改的範圍跟 observer 看的範圍結構上分離
  • 選 A 的理由:觸發頻率最低、layout 穩定、無 self-mutation 循環風險
  • 適合:絕大多數 observer 設計
  • 代價:前期設計成本中(要思考 root / option / 結構)

B:收斂 root + 必要 option(無 debounce)

  • 機制:縮範圍與 option、但不加 debounce
  • 跟 A 的取捨:B 即時反應、A 等 debounce;但 B 在 framework patch 中段觸發、layout 不穩時跑 apply 結果不可靠
  • B 比 A 好的情境:apply 不依賴 layout(純改 attribute、不讀 bounding rect)

C:寬範圍 + subtree + 全勾 option(預設配置)

  • 機制:observe(elem, { childList: true, subtree: true, attributes: true, …})
  • C 是反模式:「以防萬一全勾」會觸發數十倍頻率的 callback、framework 環境必撞效能 / 競態 bug
  • 看起來吸引人的原因:寫法簡單、不用想要監聽什麼、「全部都看就不會漏」
  • 實際發生的代價:CPU 100%、layout thrashing、self-mutation 引發無限迴圈

D:disconnect / observe 配對處理 self-mutation

  • 機制:apply 前 disconnect、apply 後 reconnect
  • 跟 A(結構分離)的取捨:D 處理 callback 必須改 observer 監聽範圍的情境、A 從設計上避免;A 更乾淨
  • D 比 A 好的情境:無法做結構分離(apply 必須改 observer 看的範圍)— 唯一情境

判讀徵兆

訊號Observer 問題修正動作
短時間觸發數十次範圍 / option 太寬縮 root、移除不需要的 option、加 debounce
Apply 跑時 layout 抖動在 framework patch 中段觸發加 debounce 50-100ms
Apply 內改 DOM 進入無限循環沒處理 self-mutation用結構分離 / disconnect 配對
預期變動沒觸發option 沒勾對、root 選錯對照變動類型確認 option
Subtree 用了但只關心直接子過度監聽深度移除 subtree、改用直接子監聽
屬性監聽觸發太頻繁沒用 attributeFilter加 filter 限縮屬性

核心原則:MutationObserver 是非同步監聽、跟同步 selector 設計工具完全不同。範圍 / option / 頻率三維度都要顯式設計 — 預設組合會在 framework 環境中過度觸發、且難以 debug。

subtree: true + attributes: true 是「監聽全部」的便利、窄 root + 最少 option 是「精準監聽」的對齊 — 同 #43 最小必要範圍#67 便利 vs 對齊反相關