Học từ Facebook #1: dùng chung IntersectionObserver cho cả trang
Cách Facebook gom IntersectionObserver theo root + option và đếm tham chiếu, viết lại thành useInView và useInfiniteScroll an toàn khi API lỗi, có demo.
Hầu như hook useInView nào bạn từng viết cũng giống thế này:
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
tsx
Không sai, nhưng mỗi component tạo một observer. Một news feed có 200 bài viết, mỗi bài có ảnh lazy-load, video autoplay, tracking impression… sẽ có hàng trăm đến hàng nghìn observer cùng tồn tại.
Để hiểu vì sao đó là vấn đề và vì sao dùng chung lại tốt hơn, trước tiên cần biết IntersectionObserver thực sự chạy thế nào bên trong trình duyệt.
Tip
Tóm tắt
- Mỗi
new IntersectionObserverlà một object riêng, một hàng đợi riêng và một lần gọi callback riêng mỗi frame. - Facebook gom observer theo
root+ option đã chuẩn hoá, đếm tham chiếu để tựdisconnectkhi không còn ai dùng. - Quy tắc mang về: option giống nhau thì dùng chung một observer, và luôn trả hàm huỷ từ
useEffect. - Infinite scroll: dừng observe khi đang tải, observe lại khi xong (kể cả khi lỗi). Lần
observe()đầu luôn cho ra một entry, nên trang quá ngắn vẫn tự tải tiếp.
#Bên trong trình duyệt: IntersectionObserver chạy lúc nào?
Spec HTML mô tả mỗi vòng event loop có một bước gọi là update the rendering. Bước này chạy khoảng một lần mỗi frame, khi trình duyệt quyết định vẽ lại. Thứ tự rút gọn của bước này như sau:
- Chạy các callback
requestAnimationFrame. - Tính lại style và layout.
- Chạy các
ResizeObserver. - Cập nhật intersection observation: với từng observer, duyệt từng target, tính hình chữ nhật giao nhau giữa target và root (đã cộng
rootMargin). Sau đó so tỉ lệ giao nhau với danh sáchthresholdđể biết target đang ở "nấc" nào. - Vẽ (paint).
Ở bước 4, nếu nấc threshold của một target khác lần trước, trình duyệt tạo một IntersectionObserverEntry và cho vào hàng đợi của observer đó. Sau đó trình duyệt lên lịch một task để gọi callback của observer với tất cả entry đang chờ trong một lần gọi.
Từ đó rút ra vài hệ quả quan trọng:
- Callback luôn bất đồng bộ và được gom lô. 50 target của cùng một observer đổi trạng thái trong một frame thì callback chỉ chạy một lần với mảng 50 entry. 50 observer riêng thì là 50 lần gọi callback riêng lẻ, mỗi lần một mảng 1 phần tử.
- Nó không đo liên tục. Trình duyệt chỉ báo khi target vượt qua một mốc threshold. Với
threshold: 0(mặc định), bạn chỉ nhận được 2 sự kiện: bắt đầu giao và hết giao. Muốn biết "đang hiện 30% hay 70%", phải khai báo nhiều mốc như[0, 0.25, 0.5, 0.75, 1]. - Lần
observe()đầu tiên luôn cho ra một entry. Spec khởi tạo "nấc trước đó" là-1, nên lần tính đầu tiên chắc chắn khác và luôn tạo entry, kể cả khi element nằm ngoài màn hình (isIntersecting: false). Ta sẽ tận dụng chi tiết này ở phần infinite scroll. rootMarginnới hoặc thu hẹp vùng root.rootMargin: '300px'coi như viewport cao thêm 300px mỗi phía, dùng để tải trước.'-64px 0px 0px 0px'thì bỏ 64px trên cùng, ví dụ khi có thanh header cố định che nội dung.
Phần tính hình học đã rẻ vì layout vừa được tính xong. Chi phí của nhiều observer nằm ở phía còn lại: mỗi observer là một object riêng trong engine, có danh sách target và hàng đợi entry riêng, và mỗi observer kích hoạt một callback JS riêng. Trong JS, mỗi callback lại tạo closure, mảng entry, rồi gọi setState. Dùng chung observer không làm phép tính giao nhau biến mất, vì trình duyệt vẫn phải xét từng target. Nhưng nó gom hàng trăm lần gọi callback nhỏ thành vài lần gọi lớn và bớt hẳn số object phải quản lý.
Note
Đừng kỳ vọng dùng chung observer làm trang của bạn "nhanh gấp 300 lần". Lợi ích thật nằm ở việc ít callback hơn, ít bộ nhớ hơn và một chỗ duy nhất để kiểm soát (bắt lỗi, debug, đo đạc). Hãy đo bằng Performance panel trên máy yếu trước khi kết luận.
#Facebook làm thế nào?
Có hai module liên quan: observeIntersection (hàm thuần, thư mục useIntersectionObserver/) và hook useIntersectionObserver (thư mục Scroll/) trong facebook-codebase.
#observeIntersection: kho observer dùng chung
- Gom theo
root, rồi theo option. Cấu trúc làWeakMap<root, { [optionsKey]: SharedObserver }>. Viewport (root = null) không thể làm key của WeakMap, nên họ dùng một object hằng{ __noRoot: true }để đại diện. optionsKeyđược chuẩn hoá. Threshold được bỏ trùng và sort, các key của object option cũng được sort trước khiJSON.stringify. Nhờ vậy{ threshold: [1, 0.5] }và{ threshold: [0.5, 1, 1] }dùng chung một observer.- Một observer, nhiều callback. Mỗi observer giữ
targetToCallbacksMap: element → callback[]. Họ dùngWeakMapnếu trình duyệt hỗ trợ và đếm số target bằng một biếnreferenceCountriêng, vì WeakMap không có.size. Khi entry về, observer tra map để gọi đúng các callback của element đó. - Đếm tham chiếu. Callback cuối của element bị gỡ thì
unobserve.referenceCountvề 0 thì xoá observer khỏi cache. Mỗi callback được gọi quaErrorGuard.applyWithGuard(bài #6), nên một callback lỗi không chặn các callback khác. - Bất biến được kiểm tra bằng
invariant. Những dòng nhưn != null || g(0, 2439)là các assert kiểu "observer phải tồn tại". Nếu sai, code throw ngay với một mã lỗi số thay vì chạy tiếp trong trạng thái hỏng.
API trả về { remove() }, và remove gọi nhiều lần vẫn an toàn nhờ một cờ removed.
#useIntersectionObserver: lớp React phía trên
Hook này có vài chi tiết rất đáng học:
rootMarginnhận object{ top, right, bottom, left }hoặc số, rồi tự đổi thành chuỗi"Tpx Rpx Bpx Lpx". ỞuseViewportDuration(bài #2), giá trị này được tính từCometViewportMarginsContext: chiều cao các thanh cố định (header, thanh điều hướng) đổi dấu thành margin âm. Nhờ vậy phần nội dung nằm dưới header không bị tính là đang được xem.- Callback ref: hook trả về một hàm ref. Khi React gắn element mới, hook gỡ đăng ký cũ và đăng ký element mới.
- Element bị gỡ khỏi DOM thì hook tự bắn một entry giả "không còn giao" (
isIntersecting: false, các rect bằng 0). Entry này được bắn quaJSScheduler.defer, và chỉ khi ref vẫn còn lànull, tức element không được gắn lại ngay. Nếu không có bước này, component tiêu thụ sẽ kẹt ở trạng thái "đang hiện", vì trình duyệt không báo gì cho element đã bịunobserve. - Callback người dùng được bọc bằng
useDynamicCallbackDANGEROUS. Đây là pattern "latest ref": luôn gọi bản callback mới nhất mà không phải đăng ký lại observer mỗi lần render.
#Viết lại
Trước khi xem bên trong, đây là cách dùng. Component chỉ cần hook useInView ở phần sau; mọi component cùng option tự động dùng chung một observer:
function Card({ id }: { id: number }) {
const [ref, inView] = useInView<HTMLDivElement>({ threshold: 0.5 });
return <div ref={ref} className={inView ? 'card active' : 'card'}>#{id}</div>;
}
tsx
Bên dưới hook là hàm thuần observeIntersection(target, callback, options), trả về hàm huỷ. Phần cốt lõi là tạo observer lười và đăng ký target (phần huỷ đăng ký nằm trong file đầy đủ ở dưới):
src/lib/observeIntersection.ts
let shared = observersForRoot.get(optionsKey);
if (!shared) {
const callbacks = new Map<Element, IntersectionCallback[]>();
const lastEntries = new Map<Element, IntersectionObserverEntry>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
lastEntries.set(entry.target, entry);
// copy mảng: callback có thể tự huỷ đăng ký trong lúc đang chạy
for (const cb of callbacks.get(entry.target)?.slice() ?? []) {
try {
cb(entry);
} catch (error) {
reportCallbackError(error);
}
}
}
},
{
root: options.root ?? null,
rootMargin: options.rootMargin,
threshold: options.threshold,
},
);
shared = { observer, callbacks, lastEntries };
observersForRoot.set(optionsKey, shared);
observerCount++;
}
const current = shared;
let removed = false;
let callbacksForTarget = current.callbacks.get(target);
if (!callbacksForTarget) {
callbacksForTarget = [];
current.callbacks.set(target, callbacksForTarget);
current.observer.observe(target);
targetCount++;
} else {
// Element đã được observe trước đó nên observer sẽ không bắn entry đầu tiên
// cho callback mới. Phát lại entry gần nhất để callback mới cũng biết trạng thái.
const last = current.lastEntries.get(target);
if (last) {
queueMicrotask(() => {
if (removed) return;
try {
callback(last);
} catch (error) {
reportCallbackError(error);
}
});
}
}
callbacksForTarget.push(callback);
ts
Khác bản gốc: bản viết lại sửa thêm một chỗ Facebook chưa xử lý. Khi element đã được observe và một callback thứ hai đăng ký sau, IntersectionObserver không bắn lại entry (entry đầu tiên chỉ có ở lần observe() đầu). Callback mới sẽ không biết element đang hiện hay không cho tới lần đổi trạng thái kế tiếp, mà lần đó có thể không bao giờ tới. Ta lưu entry gần nhất của mỗi target và phát lại cho callback mới. Ngoài ra kho target dùng Map thay vì WeakMap (lý do ở mục "Tại sao không làm cách khác?").
Xem toàn bộ src/lib/observeIntersection.ts
src/lib/observeIntersection.ts
/**
* Dùng chung IntersectionObserver giữa nhiều element.
*
* Ý tưởng lấy từ module `observeIntersection` của Facebook: các element có cùng
* `root` + `rootMargin` + `threshold` sẽ dùng chung MỘT observer. Observer được
* tạo khi có người cần và tự `disconnect()` khi không còn ai dùng.
*/
export type IntersectionCallback = (entry: IntersectionObserverEntry) => void;
export interface ObserveIntersectionOptions {
root?: Element | Document | null;
rootMargin?: string;
threshold?: number | number[];
}
interface SharedObserver {
observer: IntersectionObserver;
callbacks: Map<Element, IntersectionCallback[]>;
lastEntries: Map<Element, IntersectionObserverEntry>;
}
// WeakMap không cho dùng `null` làm key nên viewport được đại diện bằng một object rỗng.
const VIEWPORT_ROOT = {};
const observersByRoot = new WeakMap<object, Map<string, SharedObserver>>();
// Hai option giống nhau phải ra cùng một key: chuẩn hoá giá trị mặc định,
// sort + bỏ trùng threshold.
function getOptionsKey(rootMargin = '0px', threshold: number | number[] = 0): string {
const normalizedThreshold = Array.isArray(threshold)
? [...new Set(threshold)].sort((a, b) => a - b)
: [threshold];
return JSON.stringify({ rootMargin, threshold: normalizedThreshold });
}
function reportCallbackError(error: unknown) {
// Một callback lỗi không được làm hỏng các callback khác của cùng observer.
// `reportError` (chuẩn web) báo lỗi ngay tới window.onerror / console mà không ném ra;
// trình duyệt cũ không có thì ném ở task khác để vẫn hiện trên console / error tracking.
if (typeof reportError === 'function') {
reportError(error);
} else {
setTimeout(() => {
throw error;
});
}
}
let observerCount = 0;
let targetCount = 0;
/** Số liệu để debug: đang có bao nhiêu observer và bao nhiêu element được theo dõi. */
export function getIntersectionStats() {
return { observers: observerCount, targets: targetCount };
}
/**
* Theo dõi `target`. Trả về hàm huỷ theo dõi (gọi nhiều lần cũng không sao).
*/
export function observeIntersection(
target: Element,
callback: IntersectionCallback,
options: ObserveIntersectionOptions = {},
): () => void {
const rootKey = options.root ?? VIEWPORT_ROOT;
const optionsKey = getOptionsKey(options.rootMargin, options.threshold);
let observersForRoot = observersByRoot.get(rootKey);
if (!observersForRoot) {
observersForRoot = new Map();
observersByRoot.set(rootKey, observersForRoot);
}
let shared = observersForRoot.get(optionsKey);
if (!shared) {
const callbacks = new Map<Element, IntersectionCallback[]>();
const lastEntries = new Map<Element, IntersectionObserverEntry>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
lastEntries.set(entry.target, entry);
// copy mảng: callback có thể tự huỷ đăng ký trong lúc đang chạy
for (const cb of callbacks.get(entry.target)?.slice() ?? []) {
try {
cb(entry);
} catch (error) {
reportCallbackError(error);
}
}
}
},
{
root: options.root ?? null,
rootMargin: options.rootMargin,
threshold: options.threshold,
},
);
shared = { observer, callbacks, lastEntries };
observersForRoot.set(optionsKey, shared);
observerCount++;
}
const current = shared;
let removed = false;
let callbacksForTarget = current.callbacks.get(target);
if (!callbacksForTarget) {
callbacksForTarget = [];
current.callbacks.set(target, callbacksForTarget);
current.observer.observe(target);
targetCount++;
} else {
// Element đã được observe trước đó nên observer sẽ không bắn entry đầu tiên
// cho callback mới. Phát lại entry gần nhất để callback mới cũng biết trạng thái.
const last = current.lastEntries.get(target);
if (last) {
queueMicrotask(() => {
if (removed) return;
try {
callback(last);
} catch (error) {
reportCallbackError(error);
}
});
}
}
callbacksForTarget.push(callback);
return () => {
if (removed) return;
removed = true;
const list = current.callbacks.get(target);
if (!list) return;
const index = list.indexOf(callback);
if (index !== -1) list.splice(index, 1);
if (list.length === 0) {
current.callbacks.delete(target);
current.lastEntries.delete(target);
current.observer.unobserve(target);
targetCount--;
}
if (current.callbacks.size === 0) {
current.observer.disconnect();
observersForRoot!.delete(optionsKey);
observerCount--;
if (observersForRoot!.size === 0) observersByRoot.delete(rootKey);
}
};
}
ts
#Đi qua code
sequenceDiagram participant A as Component A participant B as Component B participant S as observeIntersection participant IO as IntersectionObserver participant BR as Trình duyệt A->>S: observe card1 threshold 0.5 S->>IO: new IntersectionObserver S->>IO: observe card1 B->>S: observe card2 threshold 0.5 Note over S: cùng root và optionsKey nên dùng lại IO S->>IO: observe card2 BR->>IO: update the rendering, 2 target đổi nấc IO->>S: callback với 2 entry S->>A: entry card1 S->>B: entry card2
- Chọn kho theo root.
rootKey = options.root ?? VIEWPORT_ROOT. Root là element thật thì dùng chính element làm key của WeakMap. Khi element đó bị xoá khỏi DOM và không còn ai tham chiếu, cả kho observer của nó được GC dọn đi. - Tính
optionsKey.getOptionsKeyđiền giá trị mặc định ('0px',0), bỏ trùng và sort threshold. Không chuẩn hoá thìundefinedvà'0px'sẽ ra hai observer khác nhau, dù trình duyệt coi chúng là một. - Tạo observer lười. Chỉ tạo khi chưa có. Callback của observer duyệt
entries, lưulastEntries, rồi gọi bản copy (slice()) của danh sách callback. Có copy thì một callback tự huỷ đăng ký trong lúc chạy (kiểu "hiện một lần rồi thôi") không làm lệch vòng lặp. - Đăng ký target. Target mới thì
observer.observe(target). Target đã có thì phát lạilastEntries.get(target)cho callback mới bằngqueueMicrotask. Dùng microtask để callback luôn được gọi bất đồng bộ giống hành vi của trình duyệt. Code gọiobserveIntersectionkhông bao giờ bị gọi ngược lại ngay trong lúc nó còn đang đăng ký. Cờremovedchặn việc phát lại nếu người dùng đã huỷ trước khi microtask kịp chạy. Lời gọi phát lại cũng được bọctry/catchgiống đường gọi bình thường, để việc cô lập lỗi nhất quán. Lưu ý: entry phát lại là entry cũ,timevàboundingClientRectcủa nó là của lần đổi trạng thái trước. Chỉ dùng nó để biết "đang giao hay không", đừng dùng rect của nó để tính layout. - Huỷ đăng ký theo dây chuyền. Gỡ callback. Hết callback thì
unobserve. Hết target thìdisconnectvà xoá observer khỏi kho. Kho trống thì xoá luôn root khỏi WeakMap.
#Tại sao không làm cách khác?
"Sao không dùng một observer duy nhất cho mọi thứ?" root, rootMargin và threshold là tham số bất biến của một IntersectionObserver, truyền vào constructor rồi không đổi được nữa. Option khác nhau bắt buộc phải là observer khác nhau. Điều ta làm được là đảm bảo option giống nhau thì dùng chung.
"Sao muốn phát lại cho callback mới, không unobserve rồi observe lại cho nhanh?" Làm vậy thì trình duyệt tạo entry mới và gửi cho mọi callback của target đó. Những callback cũ sẽ nhận một entry trùng lặp. Với useViewportDuration, một entry thừa có thể bị tính thành một lần "hiện lại". Phát lại chỉ cho callback mới thì không ảnh hưởng ai khác.
"Bản gốc dùng WeakMap cho target, sao bản viết lại dùng Map?" Map cho phép dùng .size để đếm tham chiếu, khỏi giữ một biến đếm riêng dễ lệch. Cái giá là nếu ai đó quên gọi hàm huỷ, Map giữ element lại trong bộ nhớ. Nhưng trên thực tế, observer vẫn giữ target cho tới khi unobserve (nó cần target để tính giao nhau ở mỗi frame), nên WeakMap cũng không cứu được lỗi quên huỷ. Bài học: luôn trả hàm huỷ từ useEffect.
"Sao báo lỗi bằng reportError, không console.error?" reportError(error) là API chuẩn của web: nó đẩy lỗi tới window.onerror (và console) ngay lập tức mà không ném ra, nên Sentry hoặc hệ thống báo lỗi của bạn vẫn bắt được kèm stack gốc, còn vòng lặp callback hiện tại vẫn chạy tiếp cho các component khác. console.error thì chỉ in ra, không ai bắt được. Trình duyệt quá cũ không có reportError thì code lùi về cách cổ điển: ném lỗi trong setTimeout để nó đi qua window.onerror ở task sau.
#Hook useInView
src/lib/useInView.ts
export function useInView<T extends Element>(
options: ObserveIntersectionOptions = {},
): [(node: T | null) => void, boolean] {
const [node, setNode] = useState<T | null>(null);
const [inView, setInView] = useState(false);
const { root, rootMargin, threshold } = options;
// mảng threshold mới mỗi lần render -> so sánh bằng key để không đăng ký lại liên tục
const thresholdKey = JSON.stringify(threshold ?? 0);
useEffect(() => {
if (!node) return;
return observeIntersection(node, (entry) => setInView(entry.isIntersecting), {
root,
rootMargin,
threshold: JSON.parse(thresholdKey),
});
}, [node, root, rootMargin, thresholdKey]);
return [setNode, inView];
}
ts
(File useInView.ts còn import useLayoutEffect, useRef cho hook useInfiniteScroll ở phần sau.)
Ba quyết định nhỏ:
- Element nằm trong
useState, không phảiuseRef. Ref callbacksetNodelàm component render lại khi element đổi (render có điều kiện, đổikey, element trước đó bị thay thế). Effect phụ thuộcnodenên tự đăng ký lại. VớiuseRef, effect không có cách nào biếtref.currentđã đổi. thresholdKey. Người dùng hay viếtuseInView({ threshold: [0, 1] }), mỗi lần render tạo một mảng mới. So sánh bằng chuỗi JSON thì effect không chạy lại vô ích. Nếu không, mỗi lần render sẽ huỷ rồi đăng ký lại, và mỗi lần đăng ký lại lại sinh thêm một entry.- Không dùng chung state giữa các component. Mỗi component có
inViewriêng. Chỉ observer là dùng chung.
#Demo
300 card, mỗi card một useInView. Bật/tắt công tắc để so sánh: dùng chung chỉ cần 1 observer, cách thông thường cần 300. Mở tab Performance của DevTools và cuộn nhanh ở cả hai chế độ: chế độ dùng chung có ít lần gọi "IntersectionObserver callback" hơn hẳn trong mỗi frame.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Ứng dụng: infinite scroll
Ý tưởng cơ bản: đặt một div rỗng (sentinel) ở cuối danh sách. Sentinel tiến gần viewport thì tải trang tiếp. Viết thì dễ, nhưng có 3 cái bẫy kinh điển:
- Gọi trùng. Trong lúc chờ API, sentinel vẫn nằm đó. Nếu observer bắn thêm lần nữa (do threshold hoặc layout dịch chuyển), hai request cho cùng một trang chạy song song và dữ liệu bị nhân đôi.
- Kẹt khi trang quá ngắn. Trang 1 chỉ có 3 item, sentinel vẫn nằm trong viewport sau khi tải xong. Observer chỉ báo khi trạng thái đổi, nên sẽ không có lần báo tiếp theo. Danh sách dừng lại dù vẫn còn dữ liệu.
- Kẹt khi API lỗi. Nếu bạn dùng một cờ "đã kích hoạt" và chỉ reset khi số item đổi, thì khi API lỗi, số item không đổi, cờ không bao giờ được reset, và danh sách chết luôn. Bản đầu tiên của mình trong series này mắc đúng lỗi đó.
#Facebook làm thế nào?
CometInfiniteScrollSuspenseList có hai cách, được chọn bằng gatekeeper gkx("1475760"):
- Cách A (bật GK): chỉ render một component trigger khi
hasMore && !isLoading, vớikey = "tailTrigger" + loadCount. Mỗi lần tải xong, trigger được mount mới với key mới, nên nó có observer mới và cờalreadyTriggeredmới. Đang tải thì trigger không tồn tại, nên không thể gọi trùng. - Cách B (tắt GK): observe chính khối skeleton ("glimmer") ở cuối danh sách, giữ cờ
isGlimmerVisiblequaonVisible/onHidden. MộtuseEffectchạy lại mỗi khiisLoading/hasMoređổi: nếu không còn đang tải, còn dữ liệu và glimmer vẫn đang hiện, thì tải tiếp. Cách này giải quyết bẫy số 2 mà không cần observer báo lại.
Ngoài ra, onLoadMore được gọi bên trong một transition (useSuspenseTransition), để React render trang mới mà không chặn thao tác cuộn. Mốc thời gian bắt đầu tải được lưu lại để đo người dùng phải nhìn skeleton bao lâu (useCometTailLoadGlimmerTracker). Danh sách được bọc bằng SuspenseList để các item hiện ra theo đúng thứ tự từ trên xuống.
#Bản viết lại
Bản viết lại đi theo cách A, nhưng không cần đổi key. Ta dừng observe khi isLoading và observe lại khi tải xong, dựa vào đúng tính chất "lần observe() đầu luôn cho ra một entry". Cách dùng trước:
function FeedList() {
const [items, setItems] = useState<Post[]>([]);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setLoading] = useState(false);
const loadMore = async () => {
setLoading(true); // bắt buộc: báo cho hook là đang tải
try {
const page = await fetchPosts({ offset: items.length });
setItems((prev) => [...prev, ...page.items]);
setHasMore(page.hasMore);
} finally {
setLoading(false); // thành công hay lỗi đều quay về trạng thái observe
}
};
const sentinelRef = useInfiniteScroll<HTMLDivElement>({ onLoadMore: loadMore, hasMore, isLoading });
return (
<>
{items.map((post) => <PostCard key={post.id} post={post} />)}
{hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
</>
);
}
tsx
Và hook:
src/lib/useInView.ts
/**
* Gọi `onLoadMore` khi phần tử "sentinel" ở cuối danh sách tiến gần viewport.
*
* Giống CometInfiniteScrollSuspenseList: mỗi "phiên" observe chỉ được gọi tối đa một lần.
* Khi đang tải thì ngừng observe. Tải xong (thành công hay lỗi) thì observe lại:
* IntersectionObserver luôn bắn entry đầu tiên ngay khi bắt đầu observe, nên nếu sentinel
* vẫn còn trong vùng kích hoạt (trang quá ngắn) thì trang tiếp theo được tải luôn.
*/
export function useInfiniteScroll<T extends Element>({
onLoadMore,
hasMore,
isLoading,
rootMargin = '300px',
}: {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
rootMargin?: string;
}) {
// "latest ref": callback luôn là bản mới nhất mà không phải observe lại
const onLoadMoreRef = useRef(onLoadMore);
useLayoutEffect(() => {
onLoadMoreRef.current = onLoadMore;
});
const [node, setNode] = useState<T | null>(null);
useEffect(() => {
if (!node || !hasMore || isLoading) return;
// Cờ riêng cho từng phiên observe: chặn trường hợp observer bắn 2 lần trước khi
// React kịp render lại với isLoading = true.
let fired = false;
return observeIntersection(
node,
(entry) => {
if (!entry.isIntersecting || fired) return;
fired = true;
onLoadMoreRef.current();
},
{ rootMargin },
);
}, [node, hasMore, isLoading, rootMargin]);
return setNode;
}
ts
onLoadMoreRef là pattern "latest ref": effect không phải chạy lại mỗi khi onLoadMore đổi (hàm mới mỗi render), nhưng vẫn luôn gọi bản mới nhất. Từ React 19.2, useEffectEvent làm đúng việc này mà không cần tự giữ ref.
stateDiagram-v2 [*] --> Observing: hasMore và không loading Observing --> Loading: sentinel giao viewport, gọi onLoadMore Loading --> Observing: tải xong hoặc lỗi, observe lại Observing --> Done: hasMore = false Done --> [*]
Đối chiếu với 3 cái bẫy ở trên:
- Gọi trùng: khi
isLoading = true, effect bị dọn và observer ngừng theo dõi sentinel. Còn một khe hở nhỏ: observer có thể đã kịp bắn lần hai trước khi React render lại vớiisLoading = true. Biếnfiredkhai báo bên trong effect chặn khe hở đó. Mỗi phiên observe chỉ gọionLoadMoretối đa một lần. - Trang quá ngắn: tải xong,
isLoadingvềfalse, effect chạy lại vàobserve()lần nữa. Entry đầu tiên báo sentinel vẫn đang giao, nên trang tiếp theo được tải luôn. Cứ thế cho tới khi màn hình đầy. - API lỗi: dù thành công hay lỗi,
finally { setLoading(false) }(xemFeedListở trên) cũng đưa ta về trạng thái Observing. Không có cờ nào bị kẹt. Demo dưới đây cố tình cho API lỗi ở mỗi lần gọi thứ 3 để bạn thấy điều đó.
Important
Hợp đồng của hook: onLoadMore phải làm isLoading chuyển sang true (và sau đó về false). Nếu quên, phiên observe hiện tại đã "dùng hết lượt" và sẽ không có phiên mới. Khi API lỗi liên tục, hãy chờ một chút trước khi trả isLoading về false (demo chờ 1 giây), hoặc hiện nút "Thử lại", để không gọi lại API dồn dập.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Edge case và cạm bẫy
- Element
display: none. Không có hộp layout, nên luônisIntersecting: false. - Element có kích thước 0 nhưng vẫn được render (ví dụ
divrỗng, ảnh chưa tải). Nếu nằm trong vùng root, nó vẫnisIntersecting: truevớiintersectionRatio = 1(spec quy định vậy cho target diện tích 0). Đừng chỉ dựa vào ratio. Bài #2 xử lý trường hợp này bằng lý doTARGET_SIZE_0. - Danh sách nằm trong một container cuộn riêng (sidebar, modal). Với root mặc định (viewport), phần bị container cắt đi vẫn được tính đúng là không giao, vì trình duyệt cắt theo mọi tổ tiên có cuộn. Nhưng
rootMarginchỉ nới vùng viewport, rồi bị chính container cắt mất. Muốn tải trước 300px trong một danh sách cuộn riêng, phải truyềnroot: containerElementđểrootMargináp lên container. rootMargintrong iframe khác origin. Khi trang của bạn nằm trong iframe khác origin với trang cha và dùng root mặc định, trình duyệt bỏ quarootMargin, vì không được phép để lộ hình học của trang cha. Tải trước bằngrootMarginsẽ không hoạt động trong trường hợp đó.- Đừng đổi option liên tục. Mỗi bộ
rootMargin/thresholdmới là một observer mới. Ví dụrootMargin: `${window.innerHeight}px`tính lại mỗi lần resize sẽ tạo observer mới liên tục. Hãy làm tròn hoặc cố định giá trị. - StrictMode (dev). Effect chạy, dọn, rồi chạy lại. Nhờ đếm tham chiếu chính xác, observer bị
disconnectrồi tạo lại sạch sẽ, không để lại rác. Đây cũng là một cách tự kiểm tra code dọn dẹp của bạn.
#Kiểm thử
jsdom không có IntersectionObserver, nên ta tự viết một bản giả có thể điều khiển được.
Ví dụ test (bản phác, không có trong repo)
observeIntersection.test.ts
import { beforeEach, expect, it, vi } from 'vitest';
class FakeIO {
static instances: FakeIO[] = [];
targets = new Set<Element>();
constructor(public cb: IntersectionObserverCallback, public options?: IntersectionObserverInit) {
FakeIO.instances.push(this);
}
observe = (t: Element) => void this.targets.add(t);
unobserve = (t: Element) => void this.targets.delete(t);
disconnect = () => this.targets.clear();
// test chủ động "bắn" entry, giống trình duyệt ở bước update the rendering
fire(target: Element, isIntersecting: boolean) {
this.cb([{ target, isIntersecting } as IntersectionObserverEntry], this as any);
}
}
beforeEach(() => {
FakeIO.instances = [];
vi.stubGlobal('IntersectionObserver', FakeIO);
vi.resetModules(); // kho observer nằm ở module scope, phải nạp lại module
});
it('dùng chung observer khi option giống nhau', async () => {
const { observeIntersection } = await import('./observeIntersection');
const a = document.createElement('div');
const b = document.createElement('div');
observeIntersection(a, () => {}, { threshold: [1, 0.5] });
observeIntersection(b, () => {}, { threshold: [0.5, 1, 1] });
expect(FakeIO.instances).toHaveLength(1);
});
it('phát lại entry gần nhất cho callback đăng ký sau', async () => {
const { observeIntersection } = await import('./observeIntersection');
const el = document.createElement('div');
observeIntersection(el, () => {});
FakeIO.instances[0].fire(el, true);
const late = vi.fn();
observeIntersection(el, late);
await Promise.resolve(); // chờ microtask
expect(late).toHaveBeenCalledWith(expect.objectContaining({ isIntersecting: true }));
});
ts
Với test end-to-end, dùng Playwright trên trình duyệt thật như cách mình kiểm tra các demo trong series: cuộn bằng page.mouse.wheel, rồi đếm số card có class active.
#So sánh với thư viện
react-intersection-observercũng dùng chung observer theo bộ option, và có thêmtriggerOnce,skip, cùng một helper để mock trong test. Nếu dự án đã dùng nó thì không cần tự viết. Giờ bạn đã biết nó làm gì bên trong.- Lazy-load ảnh thì dùng
<img loading="lazy">có sẵn của trình duyệt, không cần observer. - Animation khi cuộn tới (fade-in…) thì cân nhắc CSS scroll-driven animations (
animation-timeline: view()) ở những trình duyệt đã hỗ trợ.
#Khi nào nên dùng?
- Nên: feed, grid ảnh, bảng dài, bất cứ đâu có từ vài chục element dùng observer với cùng option. Hoặc khi bạn muốn một chỗ duy nhất để bắt lỗi và đo đạc mọi callback intersection.
- Không cần: trang chỉ có vài observer. Khi đó code đơn giản quan trọng hơn.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll (bài này)
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

