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.

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:
- 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. 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.- 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 |
|---|---|
| root | Vù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). |
| target | Phần tử bạn observe(). Khi nó giao với root, callback chạy. |
| threshold | Mứ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ính | Mô tả | Dùng khi nào |
|---|---|---|
isIntersecting | true nếu target đang giao với root | Kiểm tra nhanh nhất — 90% trường hợp dùng cái này |
intersectionRatio | Tỉ lệ (0 → 1) diện tích target nằm trong root | Scroll animation, tracking “đã đọc 50%“ |
boundingClientRect | Rect của target | Debug, tính toán nâng cao |
intersectionRect | Rect phần giao nhau | Hiệu ứng reveal theo vị trí |
rootBounds | Rect của root | Khi root là container tùy chỉnh |
target | Phần tử đang được quan sát | Map ngược lại DOM element |
time | Timestamp 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ùngintersectionRatiokhi 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 JavaScript | Không | Có |
| Kiểm soát ngưỡng preload | Không (do trình duyệt quyết định) | Có (rootMargin) |
| Placeholder / blur-up | Khó | Dễ |
Lazy load <iframe>, <video>, background | Không | Có |
| Fallback cho trình duyệt cũ | Không | Có (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:
- Gọi API trùng lặp → luôn có cờ
isLoadingchặn giữa chừng. - Tải mãi không dừng → theo dõi
hasMorevàobserver.unobserve(sentinel)khi hết dữ liệu. - Race condition khi cuộn nhanh → dùng
AbortControllerđể hủy request cũ, hoặc chặn bằngisLoadingnhư 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
thresholdmảng chỉ khi thực sự cần nhiều mốc.threshold: 0là đủ 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-motioncho mọi hiệu ứng chuyển động. - Fallback: kiểm tra
'IntersectionObserver' in windowhoặ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: falsebằngelsemộ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+scrollevent.
So sánh nhanh ba cách tiếp cận
Sự kiện scroll | Intersection Observer | loading="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ặcdisconnect()khi unmount -
rootMarginhợ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. rootMarginlà 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ờ “đơ”.