Skip to main content
JavaScript 9 mins read Devs2

Intersection Observer API: Hướng Dẫn Hoàn Chỉnh Về Lazy Load, Infinite Scroll và Scroll Animation

Học Intersection Observer API từ A-Z: cách hoạt động, root/rootMargin/threshold, lazy load ảnh, infinite scroll, scroll animation, sticky header và best practices để tối ưu hiệu năng thay thế scroll event.

#Intersection Observer #JavaScript #Lazy Loading #Infinite Scroll #Scroll Animation #Web Performance #Browser API #Best Practices

Minh họa Intersection Observer API với viewport, threshold và lazy load

Bạn đã bao giờ mở DevTools, bật tab Performance rồi thấy hàng loạt khung hình đỏ lòm chỉ vì một hàm xử lý sự kiện scroll? Hay bạn muốn ảnh chỉ tải khi người dùng cuộn tới, nhưng không biết bắt đầu từ đâu?

Trước đây, gần như mọi website đều xử lý những việc này bằng cách lắng nghe sự kiện scroll rồi gọi getBoundingClientRect() để tự tính toán vị trí. Cách làm đó vừa rối, vừa dễ gây giật lag, vừa phải tự debounce/throttle bằng tay.

Intersection Observer API ra đời để giải quyết đúng bài toán đó: cho trình duyệt biết “hãy nói cho tôi biết khi phần tử này xuất hiện trong tầm nhìn”, và để trình duyệt tự lo phần tính toán nặng. Trong bài viết này, chúng ta sẽ đi từ khái niệm, cú pháp, đến 5 use case thực tế — lazy load, infinite scroll, scroll animation, sticky header, tracking viewability — kèm best practices.

Intersection Observer là gì?

Intersection Observer là một API của trình duyệt cho phép bạn theo dõi bất đồng bộ xem một phần tử (gọi là target) có đang giao nhau (intersect) với một vùng tham chiếu (gọi là root, mặc định là viewport) hay không.

Nói cách đơn giản:

Thay vì bạn liên tục hỏi “phần tử này đang ở đâu?” trong mỗi sự kiện scroll, bạn đăng ký trước “hãy báo tôi khi phần tử này lộ diện” và trình duyệt sẽ tự gọi callback khi trạng thái thay đổi.

Vấn đề của cách làm cũ: sự kiện scroll

Cách truyền thống trông như thế này:

// ❌ Cách cũ: chạy hàng trăm lần mỗi giây trên main thread
window.addEventListener('scroll', () => {
  const rect = el.getBoundingClientRect();
  if (rect.top < window.innerHeight && rect.bottom > 0) {
    el.classList.add('visible');
  }
});

Có ba vấn đề nghiêm trọng:

  1. Callback bắn ra quá nhiều lần. Một cú cuộn nhẹ có thể kích hoạt hàng chục đến hàng trăm lần scroll.
  2. getBoundingClientRect() buộc trình duyệt tính lại layout. Gọi nó liên tục trong scroll handler gây layout thrashing, làm frame bị drop, đặc biệt trên mobile.
  3. Bạn phải tự quản lý debounce/throttle, tự kiểm tra đã “kích hoạt” hay chưa, tự dọn event listener. Rất dễ sai.

Cách Intersection Observer giải quyết

// ✅ Cách mới: trình duyệt tự lo phần nặng
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
    }
  });
});

observer.observe(el);

Trình duyệt gộp và tính toán mức độ giao nhau một cách tối ưu (thường ở tần số thấp hơn nhiều so với tần số khung hình), rồi chỉ gọi callback khi trạng thái giao nhau thực sự thay đổi. Code ngắn hơn, nhanh hơn, và không chặn main thread.

Cách hoạt động: một bức tranh đơn giản

Hãy tưởng tượng một viewport (màn hình người dùng) và một target (phần tử bạn quan sát):

┌─────────────────────────────┐
│        VIEWPORT (root)      │
│                             │
│    ┌───────────────┐        │
│    │   TARGET      │        │  ← Đang giao nhau (isIntersecting: true)
│    │   (card)      │        │     intersectionRatio: 0.6 (60% lộ ra)
│    └───────────────┘        │
│                             │
└─────────────────────────────┘
        ▲
        │  Khi target đi vào / đi ra / thay đổi mức độ giao nhau
        │  → callback được gọi với entry tương ứng

Ba khái niệm bạn cần nhớ:

Khái niệmÝ nghĩa
rootVùng tham chiếu để so sánh. Mặc định là null = viewport. Có thể là một phần tử cha cuộn được (ví dụ container có overflow: auto).
targetPhần tử bạn observe(). Khi nó giao với root, callback chạy.
thresholdMức độ giao nhau (0 → 1) cần đạt để callback chạy. 0 = chỉ cần chạm một pixel là báo.

Cú pháp cơ bản

Tạo observer và bắt đầu quan sát

const observer = new IntersectionObserver(callback, options);

// Bắt đầu quan sát một hoặc nhiều phần tử
observer.observe(document.querySelector('.card'));
observer.observe(document.querySelector('.banner'));

// Ngừng quan sát một phần tử
observer.unobserve(el);

// Ngừng quan sát tất cả và giải phóng observer
observer.disconnect();

Options: root, rootMargin, threshold

const options = {
  // Vùng tham chiếu. null = viewport của trình duyệt
  root: null,

  // Mở rộng/thu hẹp vùng root (giống margin CSS)
  // '200px 0px' → kích hoạt sớm 200px khi còn cách viewport
  rootMargin: '0px',

  // Ngưỡng giao nhau. Số hoặc mảng số (0 → 1)
  threshold: 0,
};

const observer = new IntersectionObserver(handleIntersect, options);

Callback và IntersectionObserverEntry

Callback nhận vào một mảng các entry (một observer có thể quan sát nhiều target, nên callback gộp chung):

function handleIntersect(entries, observer) {
  entries.forEach((entry) => {
    console.log(entry.target);          // Phần tử đang được xét
    console.log(entry.isIntersecting);  // true nếu vừa giao nhau
    console.log(entry.intersectionRatio); // 0 → 1, tỉ lệ diện tích lộ ra
    console.log(entry.boundingClientRect); // Vị trí target
    console.log(entry.intersectionRect);   // Vùng giao nhau
    console.log(entry.rootBounds);         // Vùng của root
    console.log(entry.time);               // Thời điểm callback chạy
  });
}

Bảng ý nghĩa các thuộc tính quan trọng nhất:

Thuộc tínhMô tảDùng khi nào
isIntersectingtrue nếu target đang giao với rootKiểm tra nhanh nhất — 90% trường hợp dùng cái này
intersectionRatioTỉ lệ (0 → 1) diện tích target nằm trong rootScroll animation, tracking “đã đọc 50%“
boundingClientRectRect của targetDebug, tính toán nâng cao
intersectionRectRect phần giao nhauHiệu ứng reveal theo vị trí
rootBoundsRect của rootKhi root là container tùy chỉnh
targetPhần tử đang được quan sátMap ngược lại DOM element
timeTimestamp của lần giao nhauĐo thời gian, analytics

Mẹo: trong hầu hết trường hợp bạn chỉ cần entry.isIntersecting. Chỉ dùng intersectionRatio khi cần biết “lộ ra bao nhiêu” — ví dụ gắn nhãn “đã xem” cho quảng cáo.

Ví dụ 1: Lazy load ảnh

Đây là use case phổ biến nhất. Ý tưởng: ảnh thật nằm trong data-src, chỉ gán vào src khi ảnh sắp vào viewport.

<img
  class="lazy"
  data-src="/images/hero.jpg"
  src="/images/placeholder.svg"
  alt="Ảnh minh hoạ"
  width="800"
  height="450"
/>
const lazyImages = document.querySelectorAll('img.lazy');

const imageObserver = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;

      const img = entry.target;
      img.src = img.dataset.src;
      img.removeAttribute('data-src');

      // Quan trọng: ngừng quan sát sau khi đã tải
      observer.unobserve(img);
    });
  },
  {
    // Kích hoạt trước 200px để ảnh kịp tải khi người dùng cuộn tới
    rootMargin: '200px 0px',
    threshold: 0.01,
  }
);

lazyImages.forEach((img) => imageObserver.observe(img));

Vì sao rootMargin: '200px 0px' quan trọng? Nếu chỉ kích hoạt khi ảnh đã vào viewport, người dùng sẽ thấy khoảng trắng nhấp nháy trong lúc ảnh đang tải. Mở rộng root lên 200px giúp bắt đầu tải trước khi ảnh xuất hiện — trải nghiệm mượt hơn hẳn.

Lazy load ảnh xong thì nên dùng loading="lazy" không?

Tiêu chíloading="lazy" (native)Intersection Observer
Cần JavaScriptKhôngCó
Kiểm soát ngưỡng preloadKhông (do trình duyệt quyết định)Có (rootMargin)
Placeholder / blur-upKhóDễ
Lazy load <iframe>, <video>, backgroundKhôngCó
Fallback cho trình duyệt cũKhôngCó (tự viết)

Kết luận thực tế: dùng loading="lazy" cho ảnh đơn giản (không cần JS, được trình duyệt tối ưu sẵn). Dùng Intersection Observer khi cần kiểm soát chi tiết hoặc lazy load iframe/video/background-image.

Ví dụ 2: Infinite scroll (cuộn vô hạn)

Ý tưởng: đặt một sentinel (phần tử đánh dấu) ở cuối danh sách, quan sát nó; khi sentinel lộ ra nghĩa là người dùng đã cuộn gần hết → tải thêm dữ liệu.

<ul id="feed"></ul>
<div id="sentinel" aria-hidden="true"></div>
<p id="status" hidden>Đang tải...</p>
let page = 1;
let isLoading = false;
let hasMore = true;

const list = document.querySelector('#feed');
const sentinel = document.querySelector('#sentinel');
const statusEl = document.querySelector('#status');

async function loadMore() {
  if (isLoading || !hasMore) return;

  isLoading = true;
  statusEl.hidden = false;

  try {
    const res = await fetch(`/api/posts?page=${page}&limit=10`);
    const { data, hasNextPage } = await res.json();

    const fragment = document.createDocumentFragment();
    data.forEach((post) => {
      const li = document.createElement('li');
      li.textContent = post.title;
      fragment.appendChild(li);
    });
    list.appendChild(fragment);

    page += 1;
    hasMore = hasNextPage;
  } catch (err) {
    console.error('Tải thất bại:', err);
  } finally {
    isLoading = false;
    statusEl.hidden = true;
  }
}

const scrollObserver = new IntersectionObserver(
  (entries) => {
    if (entries[0].isIntersecting) {
      loadMore();
    }
  },
  { rootMargin: '300px 0px' } // Tải trước khi chạm đáy 300px
);

scrollObserver.observe(sentinel);

Ba cái bẫy thường gặp và cách tránh:

  1. Gọi API trùng lặp → luôn có cờ isLoading chặn giữa chừng.
  2. Tải mãi không dừng → theo dõi hasMore và observer.unobserve(sentinel) khi hết dữ liệu.
  3. Race condition khi cuộn nhanh → dùng AbortController để hủy request cũ, hoặc chặn bằng isLoading như trên.

Lưu ý về a11y và SEO: infinite scroll làm người dùng khó tới được footer và nội dung khó được index. Hãy luôn có nút “Tải thêm” dự phòng (progressive enhancement) hoặc dùng phân trang thật trong URL: ?page=2.

Ví dụ 3: Scroll animation (reveal khi cuộn tới)

Hiệu ứng fade/slide khi phần tử vào tầm nhìn — rất phổ biến trên landing page.

<section class="card reveal">Nội dung A</section>
<section class="card reveal">Nội dung B</section>
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Tôn trọng người dùng giảm chuyển động */
@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
const revealObserver = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');
        observer.unobserve(entry.target); // Animation chỉ chạy 1 lần
      }
    });
  },
  { threshold: 0.15 }
);

document
  .querySelectorAll('.reveal')
  .forEach((el) => revealObserver.observe(el));

Nguyên tắc vàng: chỉ animate opacity và transform (hai thuộc tính được GPU tăng tốc). Tránh animate height, top, margin vì chúng buộc trình duyệt tính lại layout mỗi frame.

Ví dụ 4: Sticky header và highlight menu đang xem

Hai ứng dụng này dùng chung một kỹ thuật: quan sát nhiều target và phản ứng theo isIntersecting.

// Đổi style header khi người dùng cuộn qua hero
const header = document.querySelector('header');
const hero = document.querySelector('#hero');

new IntersectionObserver(
  ([entry]) => {
    header.classList.toggle('is-scrolled', !entry.isIntersecting);
  },
  { threshold: 0, rootMargin: '-80px 0px 0px 0px' }
).observe(hero);

Với table of contents (mục lục tự highlight theo vị trí đang đọc):

const headings = document.querySelectorAll('article h2[id]');
const tocLinks = document.querySelectorAll('.toc a');

const tocObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;

      const id = entry.target.id;
      tocLinks.forEach((link) => {
        link.classList.toggle('active', link.hash === `#${id}`);
      });
    });
  },
  {
    // Tạo một "đường ngang" tưởng tượng ở 1/3 phía trên viewport
    rootMargin: '-20% 0px -70% 0px',
    threshold: 0,
  }
);

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

Điểm hay ở đây: rootMargin âm thu hẹp root thành một dải mỏng — chỉ heading nằm trong dải đó mới được coi là “đang đọc”.

Ví dụ 5: Tracking viewability (analytics, quảng cáo)

Muốn biết một bài viết/quảng cáo thực sự được xem trong bao lâu? Dùng intersectionRatio để đo thời gian phần tử lộ ra đủ lâu.

function trackViewability(el, { minRatio = 0.5, minTime = 1000 } = {}) {
  let visibleSince = null;

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.intersectionRatio >= minRatio) {
          // Bắt đầu tính giờ khi lộ ra đủ minRatio
          visibleSince ??= Date.now();
        } else {
          // Rời khỏi tầm nhìn → chốt thời gian xem
          if (visibleSince) {
            const duration = Date.now() - visibleSince;
            if (duration >= minTime) {
              navigator.sendBeacon('/api/analytics/view', JSON.stringify({
                id: el.dataset.id,
                duration,
              }));
            }
            visibleSince = null;
          }
        }
      });
    },
    { threshold: [0, 0.5, 1] }
  );

  observer.observe(el);
  return () => observer.disconnect();
}

trackViewability(document.querySelector('#ad-slot'), {
  minRatio: 0.5,
  minTime: 1000,
});

Đây là nền tảng cho các chỉ số theo chuẩn IAB (50% diện tích, tối thiểu 1 giây đối với banner hiển thị).

Best practices & những lỗi thường gặp

✅ Nên làm

  • unobserve() ngay khi đã xử lý xong nếu chỉ cần kích hoạt một lần (lazy load, reveal animation). Không cần theo dõi phần tử đã hoàn thành.
  • Dùng một observer cho nhiều target cùng loại thay vì tạo observer mới cho từng phần tử — nhẹ hơn và callback gộp chung.
  • Dùng rootMargin để preload, không dùng để “đoán” trạng thái.
  • Dùng threshold mảng chỉ khi thực sự cần nhiều mốc. threshold: 0 là đủ cho hầu hết trường hợp.
  • Animate opacity/transform, tránh thuộc tính gây reflow.
  • Tôn trọng prefers-reduced-motion cho mọi hiệu ứng chuyển động.
  • Fallback: kiểm tra 'IntersectionObserver' in window hoặc dùng polyfill để hỗ trợ trình duyệt rất cũ.

❌ Tránh

  • Không disconnect() khi component unmount (nhất là trong SPA) → rò rỉ bộ nhớ và callback chạy trên DOM không còn tồn tại.
  • Gọi observe() trùng lặp cùng một phần tử nhiều lần — trình duyệt bỏ qua, nhưng dễ gây nhầm lẫn trong code.
  • Xử lý cả isIntersecting: false bằng else một cách vô thức — callback chạy khi target rời khỏi viewport nữa, không chỉ khi đi vào.
  • Dùng Intersection Observer để thay thế scroll event cho các việc cần vị trí chính xác (ví dụ parallax mượt 60fps) — IO báo theo ngưỡng, không phải từng pixel.
  • Lạm dụng để đo đạc khi cần dữ liệu liên tục — với những bài toán cần mỗi khung hình, hãy dùng requestAnimationFrame + scroll event.

So sánh nhanh ba cách tiếp cận

Sự kiện scrollIntersection Observerloading="lazy"
Hiệu năng❌ Kém (main thread)✅ Tốt (bất đồng bộ)✅ Tốt nhất (native)
Độ chính xác vị trí✅ Từng pixel⚠️ Theo ngưỡng❌ Không kiểm soát
Kiểm soát ngưỡng preload✅ Tự tính✅ rootMargin❌ Do trình duyệt
Độ phức tạp code❌ Cao (debounce, cleanup)✅ Thấp✅ Không có
Hỗ trợ trình duyệt✅ Mọi nơi✅ 97%+✅ 95%+

Checklist trước khi ship

  • Observer được tạo một lần, dùng chung cho nhiều target cùng loại
  • unobserve() được gọi khi target đã xử lý xong (một-lần) hoặc disconnect() khi unmount
  • rootMargin hợp lý để preload ảnh/nội dung trước khi người dùng tới
  • Có cờ chặn gọi trùng lặp cho infinite scroll (isLoading, hasMore)
  • Animation tôn trọng prefers-reduced-motion
  • Có fallback ('IntersectionObserver' in window) hoặc polyfill
  • Nội dung lazy load được hiển thị placeholder có kích thước (width/height) để tránh CLS
  • Infinite scroll có nút “Tải thêm” dự phòng cho a11y và SEO

Kết luận

Intersection Observer API là một trong những API “ăn điểm” nhất mà bạn có thể thêm vào kỹ năng JavaScript của mình. Nó thay thế một cách thanh lịch những đoạn code scroll thủ công đầy rủi ro bằng vài dòng khai báo rõ ràng — nhanh hơn, dễ đọc hơn, và ít bug hơn.

Ba ý cốt lõi cần nhớ:

  • Đừng tự tính toán vị trí — hãy để trình duyệt thông báo cho bạn qua isIntersecting.
  • rootMargin là công cụ preload mạnh nhất — mở rộng vùng root để bắt đầu tải sớm.
  • Luôn dọn dẹp — unobserve() khi xong, disconnect() khi unmount.

Hãy thử ngay hôm nay: chọn một trang có ảnh nặng trong dự án của bạn, thay thế scroll listener bằng Intersection Observer, rồi mở Performance tab để cảm nhận sự khác biệt.


Bài viết tiếp theo: Web Workers — đưa tác vụ nặng ra khỏi main thread để giao diện không bao giờ “đơ”.

Recently Used Tools