jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Observer APIs deep dive — MutationObserver, ResizeObserver và họ hàng trong browser

Đào sâu Observer pattern trong browser: MutationObserver theo dõi DOM, ResizeObserver bắt resize, IntersectionObserver, PerformanceObserver, ReportingObserver — concept, use case production, pitfall, memory cleanup.

Browser cung cấp một họ API chia sẻ chung một pattern: new XxxObserver(cb), gọi observe(target, options), callback chạy bất đồng bộ khi điều kiện thoả mãn, và bạn disconnect() khi xong. Chúng giải quyết những vấn đề mà ngày xưa phải hack bằng setInterval + diff thủ công, hoặc những sự kiện căn bản không có DOM event tương ứng.

Bài này deep dive 5 Observer chínhMutationObserver (DOM), ResizeObserver (kích thước), IntersectionObserver (visibility), PerformanceObserver (đo lường), ReportingObserver (browser violations) — kèm use case thực chiến, pitfall, và pattern cleanup chuẩn trong React/framework.


Mục lục

  1. Observer pattern — vì sao có cả family
  2. MutationObserver — concept & options
  3. MutationObserver — 6 use case thực chiến
  4. ResizeObserver — bắt mọi resize, không cần window.resize
  5. IntersectionObserver advanced — beyond lazy load
  6. PerformanceObserver — đo runtime production
  7. ReportingObserver — bắt deprecation, CSP, intervention
  8. So sánh — chọn observer nào cho vấn đề nào
  9. Memory leak & cleanup — pattern chuẩn React
  10. Pitfalls thường gặp
  11. Custom Observer — pattern tự xây
  12. Checklist

1. Observer pattern — vì sao có cả family

Trước khi có Observer API, frontend code đầy setInterval và polling:

// ❌ ngày xưa: poll size mỗi 100ms — main thread chết
setInterval(() => {
  const w = el.offsetWidth;
  if (w !== prevWidth) onResize(w);
  prevWidth = w;
}, 100);

// ❌ MutationEvent (deprecated): synchronous, mỗi mutation = stack frame mới
el.addEventListener('DOMNodeInserted', handler);

Cả 2 cách đều fire trên main thread đồng bộ, blocking, và scale cực kém. Observer API ra đời với 4 tính chất chung:

Tính chấtÝ nghĩa
AsyncCallback chạy sau khi browser hoàn thành batch — không block
BatchedNhiều thay đổi gom lại 1 lần callback
Off-main-threadPhần lớn observer work làm bên C++ engine, JS chỉ nhận record
Disconnect-able.disconnect() rõ ràng — không leak listener
                   ┌─────────────────────┐
                   │ DOM/Layout/Paint    │
                   │ thay đổi (~~~)      │
                   └──────────┬──────────┘
                              │ engine batch

                   ┌─────────────────────┐
                   │ Microtask / RAF     │
                   │ flush               │
                   └──────────┬──────────┘
                              │ async dispatch

                   ┌─────────────────────┐
                   │ Observer callback   │  ◄── JS chỉ chạy phần này
                   │ với MutationRecord  │
                   └─────────────────────┘

Hệ quả: bạn có thể observe 10k node mà gần như không tốn perf. Browser engine làm hết, JS chỉ chạy khi thật sự có sự kiện.

5 Observer chính trong browser modern

APIBắt sự kiện gìHỗ trợ
MutationObserverDOM tree/attribute thay đổiMọi browser
ResizeObserverElement thay đổi kích thước2020+
IntersectionObserverElement vào/ra viewport2018+
PerformanceObserverPerformance entry mới (LCP, CLS, LoAF…)Mọi browser
ReportingObserverDeprecation, CSP, intervention reportsChrome/Edge

2. MutationObserver — concept & options

MutationObserver báo bạn khi DOM tree thay đổi: thêm/xoá node, đổi attribute, đổi text content. Đây là API thay thế cho DOMNodeInserted/DOMSubtreeModified cũ — nhanh hơn nhiều và đúng chuẩn modern.

API cơ bản

const observer = new MutationObserver((mutations, observerInstance) => {
  for (const m of mutations) {
    console.log(m.type, m.target, m.addedNodes, m.removedNodes);
  }
});

observer.observe(targetNode, {
  childList: true,
  attributes: true,
  characterData: true,
  subtree: true,
  attributeOldValue: true,
  characterDataOldValue: true,
  attributeFilter: ['class', 'data-state'],
});

// Khi xong:
observer.disconnect();

Options — cẩn thận với scope

OptionBắt thay đổi gì
childListThêm/xoá direct children
attributesThay đổi attribute trên target
characterDataThay đổi Text/CharacterData node
subtreeÁp dụng recursive vào toàn bộ descendant
attributeOldValueLưu giá trị cũ của attribute trong MutationRecord
characterDataOldValueLưu text cũ
attributeFilterMảng attribute name — chỉ bắt những cái này

Phải có ít nhất 1 trong 3 (childList, attributes, characterData), nếu không sẽ throw TypeError.

Quy tắc vàng: scope hẹp nhất có thể

// ❌ scope quá rộng — observe TOÀN BỘ document, mọi mutation
observer.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true,
});

// ✅ scope hẹp — chỉ class change trên 1 element
observer.observe(htmlElement, {
  attributes: true,
  attributeFilter: ['class'],
});

Trên trang lớn, observer rộng có thể tạo hàng nghìn record/giây khi user scroll, hover, type. Mỗi record là object trong heap → GC pressure, callback chạy lag.

MutationRecord — đọc gì

interface MutationRecord {
  type: 'childList' | 'attributes' | 'characterData';
  target: Node;            // node bị/đang thay đổi
  addedNodes: NodeList;    // chỉ với childList
  removedNodes: NodeList;
  previousSibling: Node | null;
  nextSibling: Node | null;
  attributeName: string | null;     // chỉ với attributes
  attributeNamespace: string | null;
  oldValue: string | null;          // nếu bật *OldValue
}

takeRecords() — flush trước disconnect

// Nếu có mutation pending nhưng chưa fire callback,
// disconnect() sẽ DROP chúng. Lấy ra trước:
const pending = observer.takeRecords();
processPending(pending);
observer.disconnect();

Pattern hữu ích khi bạn cần đảm bảo không miss event nào (form auto-save, undo/redo).

Async behavior — microtask, không sync

const obs = new MutationObserver(() => console.log('cb'));
obs.observe(div, { childList: true });

div.appendChild(document.createElement('span'));  // mutation 1
div.appendChild(document.createElement('span'));  // mutation 2
console.log('after appends');

// Output:
// after appends
// cb           ◄── 1 lần callback, 2 record

Browser batch mutation cùng tick, callback chạy ở microtask sau khi script hiện tại xong. Nghĩa là:

  • Không infinite loop nếu callback đổi DOM tiếp (sẽ batch ở tick sau).
  • Phải xử lý array mutations trong callback — không phải single record.

3. MutationObserver — 6 use case thực chiến

a. Watch third-party widget injection

Bạn nhúng widget (chat, ads, analytics) load JS từ vendor. Vendor inject DOM khi xong. Bạn cần biết để hide loading state hoặc style override:

const observer = new MutationObserver((mutations, obs) => {
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node instanceof HTMLElement && node.id === 'intercom-frame') {
        styleIntercomLauncher(node);
        obs.disconnect();        // chỉ cần bắt 1 lần → cleanup
        return;
      }
    }
  }
});

observer.observe(document.body, { childList: true, subtree: true });

Pattern: disconnect trong callback ngay khi tìm thấy → không leak.

b. Auto-init component khi DOM được inject động

Server-rendered HTML từ legacy backend (Rails partial, PHP fragment) được inject runtime, không qua React. Bạn vẫn muốn auto-attach hành vi (tooltip, dropdown):

const componentInitializers = {
  '[data-tooltip]': initTooltip,
  '[data-dropdown]': initDropdown,
  '[data-modal-trigger]': initModal,
};

const observer = new MutationObserver((mutations) => {
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (!(node instanceof HTMLElement)) continue;
      for (const [selector, init] of Object.entries(componentInitializers)) {
        // node tự match
        if (node.matches(selector)) init(node);
        // descendant match
        node.querySelectorAll(selector).forEach((el) =>
          init(el as HTMLElement)
        );
      }
    }
  }
});

observer.observe(document.body, { childList: true, subtree: true });

Đây là cách Stimulus, htmx, và các “sprinkles JS” framework làm internally.

c. Detect dark mode class change trên <html>

Theme switcher đổi class="dark" trên <html>. Bạn cần re-render canvas/SVG/chart khớp theme:

const html = document.documentElement;

const observer = new MutationObserver(() => {
  const isDark = html.classList.contains('dark');
  redrawChart(isDark);
});

observer.observe(html, {
  attributes: true,
  attributeFilter: ['class'],
});

Đơn giản hơn pub-sub global state — đặc biệt nếu theme switcher là 3rd-party script bạn không control.

d. Form auto-save khi field xuất hiện

Multi-step form mount field theo step. Bạn muốn auto-save mỗi khi input mới appear (gắn debounce listener):

function attachAutoSave(input: HTMLInputElement) {
  input.addEventListener('input', debounce(() => save(input), 500));
}

const observer = new MutationObserver((mutations) => {
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node instanceof HTMLInputElement) attachAutoSave(node);
      if (node instanceof HTMLElement) {
        node.querySelectorAll('input').forEach(attachAutoSave);
      }
    }
  }
});

observer.observe(formEl, { childList: true, subtree: true });

e. Bridge legacy library → modern framework

Khi migrate từ jQuery plugin sang React, có giai đoạn 2 thế giới sống chung. Plugin jQuery thay đổi DOM mà React không biết → React re-render ghi đè plugin’s work.

MutationObserver cho phép bạn watch chỗ jQuery chạm vào, và sync state ngược về React:

function useJqueryPluginValue(elRef: RefObject<HTMLElement>) {
  const [value, setValue] = useState('');

  useEffect(() => {
    const el = elRef.current;
    if (!el) return;

    const obs = new MutationObserver(() => {
      setValue(el.dataset.selectedValue ?? '');
    });
    obs.observe(el, {
      attributes: true,
      attributeFilter: ['data-selected-value'],
    });
    return () => obs.disconnect();
  }, [elRef]);

  return value;
}

f. Test helper — wait for DOM

Cleaner hơn waitFor với polling — Testing Library mặc định dùng MutationObserver:

function waitForElement(selector: string, timeout = 3000): Promise<Element> {
  return new Promise((resolve, reject) => {
    const existing = document.querySelector(selector);
    if (existing) return resolve(existing);

    const timer = setTimeout(() => {
      obs.disconnect();
      reject(new Error(`Timeout waiting for ${selector}`));
    }, timeout);

    const obs = new MutationObserver(() => {
      const el = document.querySelector(selector);
      if (el) {
        clearTimeout(timer);
        obs.disconnect();
        resolve(el);
      }
    });

    obs.observe(document.body, { childList: true, subtree: true });
  });
}

Pattern này tốt hơn setInterval polling: callback chỉ fire khi thật sự có DOM mutation. Nếu không có activity, không tốn CPU.


4. ResizeObserver — bắt mọi resize, không cần window.resize

window.addEventListener('resize') chỉ bắt viewport resize. Còn khi:

  • Sidebar collapse/expand → main content rộng/hẹp.
  • Parent flex container đổi.
  • Font load xong → text reflow.
  • Image load xong → container nở.

window.resize không fire. ResizeObserver thì fire cho mọi element bị resize, bất kể nguyên nhân.

API

const observer = new ResizeObserver((entries, obs) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    handleResize(entry.target, width, height);
  }
});

observer.observe(el);
observer.observe(el2, { box: 'border-box' });

observer.disconnect();
observer.unobserve(el);  // chỉ ngừng 1 element

contentRect vs borderBoxSize vs devicePixelContentBoxSize

PropertyĐo gìKhi nào dùng
contentRectContent box (excl padding/border) trong CSS pxCompatibility, đa số case
contentBoxSize[0]Content box, supports writing-mode (vertical text)Layout đa hướng
borderBoxSize[0]Border box (incl padding + border)Match offsetWidth/Height
devicePixelContentBoxSize[0]Pixel chính xác trên device pixelCanvas HiDPI
new ResizeObserver(([entry]) => {
  // ✅ canvas chuẩn HiDPI — vẽ với physical pixel
  const dpr = window.devicePixelRatio;
  const w = entry.devicePixelContentBoxSize?.[0]?.inlineSize
         ?? entry.contentBoxSize[0].inlineSize * dpr;
  const h = entry.devicePixelContentBoxSize?.[0]?.blockSize
         ?? entry.contentBoxSize[0].blockSize * dpr;
  canvas.width = w;
  canvas.height = h;
}).observe(canvas);

Use case: auto-resize textarea theo nội dung

function autoGrow(textarea: HTMLTextAreaElement) {
  const obs = new ResizeObserver(() => {
    textarea.style.height = 'auto';
    textarea.style.height = textarea.scrollHeight + 'px';
  });
  obs.observe(textarea);
  textarea.addEventListener('input', () => {
    textarea.style.height = 'auto';
    textarea.style.height = textarea.scrollHeight + 'px';
  });
  return () => obs.disconnect();
}

Use case: chart/d3 redraw khi container resize

function useResponsiveChart(ref: RefObject<HTMLDivElement>) {
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const obs = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      drawChart(el, { width, height });
    });
    obs.observe(el);
    return () => obs.disconnect();
  }, [ref]);
}

Use case: container queries fallback (browser cũ)

Container queries là native 2023+, nhưng nếu phải support browser cũ, ResizeObserver thay được:

function attachContainerQueries(el: HTMLElement) {
  const obs = new ResizeObserver(([entry]) => {
    const w = entry.contentRect.width;
    el.classList.toggle('container-md', w >= 400);
    el.classList.toggle('container-lg', w >= 800);
  });
  obs.observe(el);
}

Loop infinite — cảnh báo nguy hiểm

// ❌ resize → callback đổi size → resize → callback → ...
new ResizeObserver(([entry]) => {
  const w = entry.contentRect.width;
  entry.target.style.width = w + 1 + 'px';   // trigger resize tiếp
}).observe(el);

Console error: ResizeObserver loop completed with undelivered notifications.

Browser bảo vệ bằng cách defer notification sang next frame, nhưng loop logic vẫn fail. Pattern an toàn: chỉ đổi size của element khác, không phải target đang observe.

// ✅ observe A, đổi B
new ResizeObserver(([entry]) => {
  const w = entry.contentRect.width;
  otherEl.style.width = w + 'px';
}).observe(el);

Hoặc dùng flag để break loop:

let updating = false;
new ResizeObserver(() => {
  if (updating) return;
  updating = true;
  requestAnimationFrame(() => {
    // update logic
    updating = false;
  });
}).observe(el);

5. IntersectionObserver advanced — beyond lazy load

Lazy image + infinite scroll sentinel là use case basic. Phần này đi sâu hơn — pattern ít phổ biến nhưng cực kỳ hữu ích.

Threshold array — phân tầng visibility

const obs = new IntersectionObserver(
  (entries) => {
    for (const e of entries) {
      console.log(`visible: ${(e.intersectionRatio * 100).toFixed(0)}%`);
    }
  },
  { threshold: [0, 0.25, 0.5, 0.75, 1.0] }  // fire ở mỗi mốc
);

obs.observe(target);

Use case: video auto-pause khi < 50% visible, auto-play khi ≥ 75%.

new IntersectionObserver(
  ([entry]) => {
    if (entry.intersectionRatio >= 0.75) video.play();
    else if (entry.intersectionRatio < 0.5) video.pause();
  },
  { threshold: [0.5, 0.75] }
).observe(video);

rootMargin — offset viewport ảo

// "Trigger 200px BEFORE element vào viewport thật"
new IntersectionObserver(cb, { rootMargin: '200px 0px' }).observe(el);

// "Element được coi là visible chỉ khi cách top 100px"
// (hữu ích cho sticky header — ignore phần dưới sticky)
new IntersectionObserver(cb, { rootMargin: '-100px 0px 0px 0px' });

Format: top right bottom left — như CSS margin.

Use case: scroll spy / table of contents

Highlight TOC item của section đang visible:

const headings = document.querySelectorAll('h2[id]');
const tocLinks = new Map<string, HTMLAnchorElement>();
document.querySelectorAll('.toc a').forEach((a) => {
  tocLinks.set((a as HTMLAnchorElement).hash.slice(1), a as HTMLAnchorElement);
});

const observer = new IntersectionObserver(
  (entries) => {
    for (const e of entries) {
      const id = e.target.id;
      const link = tocLinks.get(id);
      if (!link) continue;
      link.classList.toggle('active', e.isIntersecting);
    }
  },
  {
    // chỉ kích hoạt khi heading nằm trong band 0-30% viewport từ trên
    rootMargin: '0px 0px -70% 0px',
    threshold: 0,
  }
);

headings.forEach((h) => observer.observe(h));

Use case: analytics impression tracking

Đo “user nhìn thấy ad/post bao lâu” — cần ≥ 50% pixel hiện ≥ 1 giây (IAB Viewable Impression standard):

function trackImpression(el: HTMLElement, onView: () => void) {
  let timer: ReturnType<typeof setTimeout> | null = null;

  const obs = new IntersectionObserver(
    ([entry]) => {
      if (entry.intersectionRatio >= 0.5) {
        timer = setTimeout(() => {
          onView();
          obs.disconnect();
        }, 1000);
      } else if (timer) {
        clearTimeout(timer);
        timer = null;
      }
    },
    { threshold: [0.5] }
  );

  obs.observe(el);
  return () => {
    if (timer) clearTimeout(timer);
    obs.disconnect();
  };
}

Use case: detect sticky element “stuck” state

Khi header position: sticky đang stick (chạm top), thêm shadow. Detect bằng sentinel + IO:

<div class="sentinel"></div>
<header class="sticky-header"></header>
.sentinel {
  position: absolute;
  top: 0;
  height: 1px;
}
.sticky-header {
  position: sticky;
  top: 0;
}
.sticky-header.is-stuck {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
const sentinel = document.querySelector('.sentinel')!;
const header = document.querySelector('.sticky-header')!;

new IntersectionObserver(
  ([entry]) => {
    header.classList.toggle('is-stuck', !entry.isIntersecting);
  },
  { threshold: [1] }
).observe(sentinel);

Khi user scroll xuống → sentinel ra khỏi viewport → header “stuck”.

Pattern này rất phổ biến — position: sticky không có pseudo-class báo “đang stick”, nên IO + sentinel là cách chuẩn.


6. PerformanceObserver — đo runtime production

Performance metric trước đây phải poll qua performance.getEntries(). PerformanceObserver push entry mới ngay khi available — backbone của mọi RUM library (Vercel Speed Insights, Cloudflare RUM, custom).

Entry types đáng quan tâm

entryTypeĐo gì
navigationToàn bộ timing của page load
resourceMỗi asset (JS, CSS, image, fetch)
paintFP (first-paint), FCP (first-contentful-paint)
largest-contentful-paintLCP — Web Vital chính
layout-shiftCLS — mỗi shift event
first-inputFID — first input delay (legacy)
eventMọi event với duration > threshold (cho INP)
longtaskTask > 50ms — main thread bị block
long-animation-frameLoAF — replace longtask, chi tiết hơn (2024+)
measure / markUser Timing API
elementElement timing — assets you marked critical

Pattern chuẩn: report Web Vitals

function observe<T extends PerformanceEntry>(
  type: string,
  cb: (entries: T[]) => void
) {
  try {
    const obs = new PerformanceObserver((list) => cb(list.getEntries() as T[]));
    obs.observe({ type, buffered: true });
    return obs;
  } catch (e) {
    // entryType không support → silently skip
    return null;
  }
}

// LCP: lấy cái CUỐI CÙNG (LCP có thể đổi nhiều lần)
let lcp = 0;
observe<LargestContentfulPaint>('largest-contentful-paint', (entries) => {
  const last = entries[entries.length - 1];
  lcp = last.renderTime || last.loadTime;
});

// CLS: cộng dồn các shift KHÔNG có recent input
let cls = 0;
observe<LayoutShift>('layout-shift', (entries) => {
  for (const e of entries) {
    if (!e.hadRecentInput) cls += e.value;
  }
});

// LoAF: long animation frame — biết script nào block
observe<PerformanceLongAnimationFrameTiming>('long-animation-frame', (entries) => {
  for (const e of entries) {
    if (e.duration > 100) {
      console.warn('LoAF', e.duration, 'ms', e.scripts);
    }
  }
});

buffered: true quan trọng: nếu observer attach sau event đã fire (ví dụ FCP đã xảy ra trước khi script chạy), browser vẫn deliver entry đó từ buffer. Bỏ flag này = miss event sớm.

Send beacon khi unload

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    navigator.sendBeacon('/rum', JSON.stringify({ lcp, cls }));
  }
});

sendBeacon không block unload, fire-and-forget — đúng cách báo metric cuối session.

Use case: log slow component

performance.mark('hero-start');
renderHero();
performance.mark('hero-end');
performance.measure('hero-render', 'hero-start', 'hero-end');

new PerformanceObserver((list) => {
  for (const m of list.getEntries()) {
    if (m.duration > 100) {
      log.warn(`Slow render: ${m.name} ${m.duration}ms`);
    }
  }
}).observe({ type: 'measure', buffered: true });

Hiện ngay trong DevTools Performance tab + auto-warn khi vượt budget.

Use case: detect 3rd-party tracker request

new PerformanceObserver((list) => {
  for (const r of list.getEntries() as PerformanceResourceTiming[]) {
    if (r.name.includes('google-analytics') || r.name.includes('facebook')) {
      log.info(`tracker: ${r.name} ${r.duration.toFixed(0)}ms`);
    }
  }
}).observe({ type: 'resource', buffered: true });

Tracker chậm là chuyện thường — biết để bàn với marketing.


7. ReportingObserver — bắt deprecation, CSP, intervention

Ít ai biết tới observer này, nhưng nó vô giá cho health monitoring production. Browser sinh “report” khi:

  • Code dùng API deprecated (sắp bỏ).
  • Vi phạm CSP (Content Security Policy).
  • Browser intervene (auto-pause autoplay, throttle background).
  • Permissions policy violation.
if ('ReportingObserver' in window) {
  const obs = new ReportingObserver(
    (reports, observer) => {
      for (const r of reports) {
        log.warn('Browser report', r.type, r.body);
        // Gửi về backend cho dashboard
        fetch('/api/browser-report', {
          method: 'POST',
          body: JSON.stringify({ type: r.type, body: r.body, url: r.url }),
        });
      }
    },
    { buffered: true, types: ['deprecation', 'intervention'] }
  );
  obs.observe();
}

Report types

TypeKhi nào fire
deprecationDùng API sắp removed (ví dụ appCache, <keygen>)
interventionBrowser từ chối thực hiện (autoplay block, …)
crashTab/iframe crash
csp-violationCSP block resource/inline script

Use case: phát hiện deprecation trước EOL

Bạn dùng polyfill có call API deprecated → user vẫn chạy nhưng sẽ broken khi browser bỏ. ReportingObserver báo trước hàng tháng.

Hỗ trợ

Hiện tại Chrome/Edge support đầy đủ. Firefox/Safari hỗ trợ một phần qua HTTP Report-To / Reporting-Endpoints header. Production nên combine cả 2:

Reporting-Endpoints: default="https://example.com/reports"
Content-Security-Policy-Report-Only: default-src 'self'; report-to default

8. So sánh — chọn observer nào cho vấn đề nào

   "DOM tree thay đổi?"            → MutationObserver
   "Element thay đổi kích thước?"  → ResizeObserver
   "Element vào/ra viewport?"      → IntersectionObserver
   "Performance metric mới?"       → PerformanceObserver
   "Browser cảnh báo gì?"          → ReportingObserver
Quan tâmAPI tốt nhấtTránh dùng
Lazy load imageIntersectionObserverscroll event + getBoundingRect
Element bị inject từ vendorMutationObserversetInterval + querySelector
Container resize (không phải viewport)ResizeObserverwindow.resize (chỉ bắt viewport)
Theme class changeMutationObserverCustom event bus
Sticky stuck detectionIntersectionObserverscroll event
LCP / CLS / INPPerformanceObserverPolyfill cũ
Auto-grow textareaResizeObserver + inputPolling
Wait for DOM trong testMutationObserversetInterval
Detect autoplay blockReportingObserverplay() promise check (incomplete)

9. Memory leak & cleanup — pattern chuẩn React

Mọi observer giữ reference tới target. Quên disconnect() = element không bao giờ được GC, kể cả khi đã unmount.

Pattern React đúng

function useMutationObserver(
  ref: RefObject<HTMLElement>,
  options: MutationObserverInit,
  callback: MutationCallback
) {
  // Giữ callback ổn định bằng ref → tránh observe lại mỗi render
  const callbackRef = useRef(callback);
  useEffect(() => {
    callbackRef.current = callback;
  });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const obs = new MutationObserver((m, o) => callbackRef.current(m, o));
    obs.observe(el, options);
    // ✅ MUST disconnect on unmount
    return () => obs.disconnect();
    // options serialize qua deps để re-observe nếu options thay đổi
  }, [ref, JSON.stringify(options)]);
}

Pattern Vanilla / framework khác

class Component {
  private observer: MutationObserver | null = null;

  mount(el: HTMLElement) {
    this.observer = new MutationObserver(this.onMutation);
    this.observer.observe(el, { childList: true });
  }

  unmount() {
    this.observer?.disconnect();
    this.observer = null;
  }

  private onMutation = (records: MutationRecord[]) => {
    // ...
  };
}

Singleton observer cho nhiều target

Nếu bạn observe nhiều element cùng config, không cần observer mới mỗi cái. ResizeObserver/IntersectionObserver design cho phép 1 instance observe nhiều element:

const sharedObserver = new ResizeObserver((entries) => {
  for (const e of entries) {
    callbacks.get(e.target)?.(e);
  }
});

const callbacks = new WeakMap<Element, (e: ResizeObserverEntry) => void>();

export function observeResize(
  el: Element,
  cb: (e: ResizeObserverEntry) => void
) {
  callbacks.set(el, cb);
  sharedObserver.observe(el);
  return () => {
    sharedObserver.unobserve(el);
    callbacks.delete(el);
  };
}

WeakMap quan trọng — element được GC thì entry tự dọn. Nếu dùng Map thường, nó keep reference → leak.


10. Pitfalls thường gặp

disconnect không dọn pending records

obs.disconnect();
// Nếu có mutation đã queue nhưng chưa fire → MẤT

Fix: takeRecords() trước:

const remaining = obs.takeRecords();
processIfNeeded(remaining);
obs.disconnect();

Observer trong observer — vô tận

// ❌ MutationObserver callback đổi DOM → trigger MutationObserver tiếp
const obs = new MutationObserver(() => {
  document.body.appendChild(document.createElement('div'));
});
obs.observe(document.body, { childList: true });

Browser không infinite loop synchronously (do microtask schedule), nhưng tab vẫn freeze. Luôn check guard trong callback:

let handling = false;
const obs = new MutationObserver(() => {
  if (handling) return;
  handling = true;
  try {
    // ...
  } finally {
    handling = false;
  }
});

IntersectionObserver fire ngay khi observe

Lần đầu observe(el), IO luôn fire callback với current state — ngay cả khi target chưa đổi. Nếu bạn skip event đầu mặc định:

let initial = true;
new IntersectionObserver((entries) => {
  if (initial) {
    initial = false;
    return;
  }
  // logic thực
}).observe(el);

entry.contentRect đôi khi 0×0

Nếu element display: none hoặc chưa attach DOM, contentRect sẽ là 0×0. Check trước khi xử lý:

new ResizeObserver(([e]) => {
  if (e.contentRect.width === 0) return; // skip hidden
  // ...
}).observe(el);

PerformanceObserver throw nếu entryType không support

// ❌ Safari < 14 không support 'longtask' → throw
obs.observe({ type: 'longtask' });

// ✅ try/catch
try {
  obs.observe({ type: 'longtask', buffered: true });
} catch {}

// ✅ hoặc check trước
if (PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
  obs.observe({ type: 'longtask' });
}

buffered: true chỉ work với entryType single

// ❌ Mix entryTypes + buffered → throw
obs.observe({ entryTypes: ['paint', 'longtask'], buffered: true });

// ✅ Tách thành 2 observer hoặc 2 lần observe
obs.observe({ type: 'paint', buffered: true });
obs.observe({ type: 'longtask', buffered: true });

11. Custom Observer — pattern tự xây

Khi không có Observer API native cho thứ bạn quan tâm, tự xây theo cùng pattern:

class ScrollDirectionObserver {
  private callbacks = new Set<(dir: 'up' | 'down') => void>();
  private lastY = 0;
  private rafId: number | null = null;
  private attached = false;

  observe(cb: (dir: 'up' | 'down') => void) {
    this.callbacks.add(cb);
    if (!this.attached) this.attach();
    return () => this.unobserve(cb);
  }

  unobserve(cb: (dir: 'up' | 'down') => void) {
    this.callbacks.delete(cb);
    if (this.callbacks.size === 0) this.detach();
  }

  disconnect() {
    this.callbacks.clear();
    this.detach();
  }

  private attach() {
    this.attached = true;
    this.lastY = window.scrollY;
    window.addEventListener('scroll', this.onScroll, { passive: true });
  }

  private detach() {
    this.attached = false;
    window.removeEventListener('scroll', this.onScroll);
    if (this.rafId) cancelAnimationFrame(this.rafId);
  }

  private onScroll = () => {
    if (this.rafId) return;          // throttle qua RAF
    this.rafId = requestAnimationFrame(() => {
      const y = window.scrollY;
      const dir = y > this.lastY ? 'down' : 'up';
      this.lastY = y;
      this.rafId = null;
      this.callbacks.forEach((cb) => cb(dir));
    });
  };
}

// Usage
const observer = new ScrollDirectionObserver();
const stop = observer.observe((dir) => console.log(dir));
// later: stop()

Đặc điểm pattern observer chuẩn

  1. observe(target, callback) trả về unsubscribe function.
  2. Nội bộ throttle qua RAF / microtask — không fire mỗi event raw.
  3. Auto attach listener khi có ít nhất 1 subscriber, detach khi count về 0 (lazy resource).
  4. disconnect() dọn toàn bộ.
  5. Dùng WeakMap/WeakSet nếu reference target — không leak.

Đây cũng là cách Zustand, TanStack Store, Jotai internal implement reactive state.


12. Checklist

Khi dùng MutationObserver

  • Scope hẹp nhất có thể — chỉ subtree, attribute, hoặc filter cần
  • attributeFilter thay vì observe all attributes
  • disconnect() ngay khi đã tìm thấy target (one-shot pattern)
  • Guard infinite loop nếu callback đổi DOM
  • takeRecords() trước disconnect() nếu cần flush pending

Khi dùng ResizeObserver

  • Không đổi size của target trong callback (loop)
  • Dùng borderBoxSize thay contentRect cho HiDPI canvas
  • 1 observer instance cho nhiều element thay vì N instance
  • Skip khi width === 0 (hidden element)

Khi dùng IntersectionObserver

  • rootMargin dương cho prefetch, âm cho “must be deeply visible”
  • Threshold array cho phân tầng (impression tracking)
  • disconnect() sau khi event đầu nếu là one-shot (lazy load)
  • Skip event đầu nếu logic cần “real change”

Khi dùng PerformanceObserver

  • buffered: true để bắt entry trước khi observer attach
  • Try/catch hoặc supportedEntryTypes check
  • Send qua sendBeaconvisibilitychange = hidden
  • Tách nhiều observe() call cho nhiều entryType

Cleanup chung

  • useEffect return luôn có obs.disconnect()
  • Singleton observer + WeakMap callback cho lib reusable
  • Test memory leak với DevTools Memory tab (Detached HTMLElement)

Tóm tắt

ObserverVấn đề giải quyếtEffortHiệu năng
MutationObserverDOM thay đổi runtimeThấpCực cao
ResizeObserverElement resize (mọi nguyên nhân)ThấpCao
IntersectionObserverElement vào/ra viewportThấpCực cao
PerformanceObserverĐo runtime productionTrungCao
ReportingObserverBrowser violationsThấpCao

Quy tắc: thấy mình viết setInterval, scroll/resize listener với getBoundingRect(), hoặc loop polling — dừng lại, gần như chắc chắn có Observer thay thế. Pattern phẳng, async, batched, off-main-thread — đó là cả lý do family này tồn tại.

Observer pattern không phải “advanced JS” — nó là default tool để xử lý sự kiện ngầm trong browser modern. Nắm 5 API trên là đủ giải quyết 80%+ vấn đề “tôi muốn biết khi X thay đổi” mà không cần Redux store hay event bus tự build.


Nguồn tham khảo