前端 reactive 效能的盤點與優化:MutationObserver 三維度(root / options / debounce)、polling → observer、iteration / regex / reflow / lazy load 四個成本面。

適用:使用者反映卡頓、CPU 100%、scroll lag、resize jank、首次互動延遲。 不適用:純後端效能、純伺服器渲染(SSR 的成本另一套)。

自包含聲明:閱讀本文件不需要先讀其他 reference。本文件涵蓋四個效能風險面向、observer 設計準則、量測方法。


何時參閱本文件

訊號該做的第一件事
使用者打字時搜尋頁卡頓量 input listener / observer 觸發頻率
Scroll 時掉幀量 scroll listener 觸發頻率 + reflow 成本
Resize 視窗時 layout 跳動量 ResizeObserver 觸發 + 重新計算成本
CPU 100%、即使頁面靜止找 setInterval / setTimeout polling、換 observer
結果規模大(> 500 筆)時慢量 iteration cost、看是否每筆都跑 regex
首次互動延遲(搜尋頁 200ms+ 才能輸入)量 critical path、看 lazy chunk 是否要 preload
即將寫 observer.observe(document.body, { subtree: true })停 — 範圍過寬、補上限制

為什麼 reactive 效能要主動盤點

Reactive 系統的成本不是線性 — 一個觸發頻率失控的 listener 會放大整個系統的負擔:

  • 一個 observer 觸發 → callback 執行 → DOM 變動 → 再觸發 observer → 無限迴圈
  • 一個 input listener 沒 debounce → 每個鍵盤事件跑一次重 query → CPU 飆高
  • 一個 setInterval polling 50ms → 永遠不停、即使頁面背景

主動盤點 = 寫之前先估觸發頻率、寫之後用 console.count 驗證。事後 debug 比事前設計貴 10 倍。


風險面向 1:Listener 觸發頻率

MutationObserver 三維度

維度預設過寬訊號
Root最窄(具體 element)document.body / document.documentElement
Options{ childList: true }{ subtree: true, attributes: true, characterData: true }
Debounce0ms 或微 microtask沒寫 debounce、callback 執行 > 5ms

過寬範例

1// 監聽整個 page 任何變動
2new MutationObserver(cb).observe(document.body, {
3  childList: true,
4  subtree: true,
5  attributes: true,
6  characterData: true,
7});
8// 一次 react state 變動 → 100+ 個 callback

對例

1const root = document.querySelector('.pagefind-ui__results-area');
2let timer;
3new MutationObserver(() => {
4  clearTimeout(timer);
5  timer = setTimeout(callback, 100);  // debounce 100ms
6}).observe(root, { childList: true });
7// 只監聽 results 直接子節點變動、debounce 100ms

量觸發頻率

1let count = 0;
2new MutationObserver(() => {
3  count++;
4  console.log('mutation', count);
5}).observe(...);
6
7// 預期:使用者打字 1 秒、觸發 10 次以下
8// 觀察:100+ 次 → 範圍過寬、加 debounce 或縮 root

或用 console.count('decorate') 計數、看每秒觸發幾次。


風險面向 2:Polling 換 Observer

反例:setInterval polling

1const timer = setInterval(() => {
2  const el = document.querySelector('.target');
3  if (el) {
4    decorate(el);
5    clearInterval(timer);
6  }
7}, 50);

問題:CPU 50% busy waiting、即使元素永遠不出現、interval 永遠跑。

對例:MutationObserver + fast-path

 1function waitForElement(selector, root = document.body) {
 2  return new Promise(resolve => {
 3    const existing = root.querySelector(selector);
 4    if (existing) return resolve(existing);
 5
 6    const obs = new MutationObserver(() => {
 7      const el = root.querySelector(selector);
 8      if (el) {
 9        obs.disconnect();
10        resolve(el);
11      }
12    });
13    obs.observe(root, { childList: true, subtree: true });
14  });
15}

Fast-path 先檢查(如果已經在 DOM 立即返回)、否則 observer 等元素出現。0 latency、0 idle CPU、元素出現立刻觸發。


風險面向 3:Iteration / Regex 成本

反例:每筆結果跑重 regex

1const results = await pagefind.search(query);
2const filtered = results.results.filter(r => /complex|regex|here/i.test(r.excerpt));
3// 500 筆 × regex test = 500 次 regex 編譯與執行

對例:regex compile 一次、用 cached version

1const re = /complex|regex|here/i;
2const filtered = results.results.filter(r => re.test(r.excerpt));
3// regex 只編譯一次、test 每次便宜

量 iteration 成本

1console.time('filter');
2const filtered = results.filter(...);
3console.timeEnd('filter');
4// 觀察:> 16ms → 影響 60fps、要優化

大資料量的常用優化

問題優化
每筆都跑 regexregex 編譯一次、test 重用
每筆 query DOMDOM query 一次、緩存結果
排序 N²Array.sort() (N log N)
全量過濾後分頁分頁邊界提前 break、不跑完全部

風險面向 4:Layout Reflow 成本

Reflow(重新計算 layout) > Repaint(重繪) > Composite(合成)— 三者成本遞減。

Reflow 觸發訊號

操作成本
改 width / height / top / marginReflow(layout 變動)
改 color / backgroundRepaint(不影響 layout)
改 transform / opacityComposite(GPU、最便宜)
getBoundingClientRect()強制 sync reflow(如果 pending 變動)

反例:read-write-read-write 觸發 layout thrashing

1elements.forEach(el => {
2  const w = el.offsetWidth;       // read
3  el.style.width = `${w * 2}px`;  // write
4  const h = el.offsetHeight;      // read(強制 reflow)
5  el.style.height = `${h * 2}px`; // write
6});
7// 每次 read 觸發一次 reflow、N 個元素 = N 次 reflow

對例:批量 read、批量 write

1const sizes = elements.map(el => ({
2  el, w: el.offsetWidth, h: el.offsetHeight,
3}));
4sizes.forEach(({ el, w, h }) => {
5  el.style.width = `${w * 2}px`;
6  el.style.height = `${h * 2}px`;
7});
8// 1 次 reflow、性能提升 N 倍

量 reflow 成本

Chrome DevTools Performance panel → 找 “Layout” 紫色塊。> 16ms 要優化。


風險面向 5:資源載入時序

Critical path vs lazy chunk

資源該不該 lazy
首屏需要的 CSS / JS否(critical path、preload)
搜尋頁的 search index是(使用者進搜尋頁前不需要)
Footer 圖片是(lazy load on scroll)
跟首屏互動相關的 JS否(input listener 要立刻 ready)

範例:搜尋頁的 lazy chunk

1<!-- 搜尋頁進來時、preload 第一個 chunk -->
2<link rel="preload" href="/_pagefind/pagefind-entry.json" as="fetch" crossorigin>
3<link rel="preload" href="/_pagefind/pagefind.js" as="script">
4
5<script type="module">
6  import('/_pagefind/pagefind.js').then(p => p.init());
7</script>

不 preload 的代價:使用者進搜尋頁 → 點 input → 等 200-500ms 才能搜尋。

量 critical path

Chrome DevTools Network panel → 看每個資源的 timing。Slow 3G throttle 模擬真實使用者環境。


盤點 reactive listener 的協議

對複雜頁面(搜尋頁、dashboard)做一次性盤點:

 1// 1. 列出所有 observer / listener
 2console.log({
 3  mutationObservers: window.observers,  // 自家紀錄
 4  resizeObservers: window.resizeObservers,
 5  inputListeners: '...',
 6});
 7
 8// 2. 加 console.count 在每個 callback
 9const decorateCount = (() => { let c = 0; return () => { console.count(`decorate ${++c}`); }; })();
10
11// 3. 操作頁面 1 分鐘、看 console
12// 4. 任何 callback 執行 > 100 次/分鐘 → 評估是否需要 debounce / 縮範圍

定期盤點(每加新 observer 後)= 主動發現觸發頻率失控、不等使用者抱怨。


Wrong vs Right 對照

範例 1:搜尋頁打字卡頓

1input.addEventListener('input', () => {
2  // 每個鍵盤事件都重 query 整個 results、重排版
3  const results = expensiveQuery(input.value);
4  renderResults(results);
5});

1let timer;
2input.addEventListener('input', () => {
3  clearTimeout(timer);
4  timer = setTimeout(() => {
5    const results = expensiveQuery(input.value);
6    renderResults(results);
7  }, 200);  // debounce 200ms
8});

範例 2:等元素出現

1const timer = setInterval(() => {
2  if (document.querySelector('.target')) {
3    decorate();
4    clearInterval(timer);
5  }
6}, 100);

1new MutationObserver((mutations, obs) => {
2  if (document.querySelector('.target')) {
3    obs.disconnect();
4    decorate();
5  }
6}).observe(document.body, { childList: true, subtree: true });
7// 注意:subtree 只在「等元素出現」場景可接受、決完後 disconnect

自檢清單(dogfooding)

寫 reactive code 或 perf debug 時:

  • MutationObserver root 是不是最窄能達成目標的 element?
  • options 是不是只開必要的(childList 預設、subtree 要有理由、attributes 不是預設)?
  • 重 callback 有沒有 debounce / throttle?
  • setInterval / setTimeout polling 能不能換成 MutationObserver?
  • iteration / regex 在大資料量下測過嗎?> 16ms 要優化
  • 改 layout 屬性有沒有 batch read-write、避免 layout thrashing?
  • Lazy chunk 是 critical path 還是真的 lazy?

延伸閱讀

對應的事後檢討(在 content/report/):


Last Updated: 2026-04-26 Version: 0.1.0