Học từ Facebook #16: Khung chat không giật khi tải tin cũ
Cách Messenger giữ khung chat dính đáy và không nhảy khi tải tin cũ, và bản viết lại bằng React neo theo tin nhắn để xử lý cả spinner lẫn ảnh tải chậm.
Khung chat là một trong những thứ khó làm "cho đúng" nhất trên web, dù trông chỉ là một div có overflow-y: auto. Người dùng mong đợi:
- Mở cuộc trò chuyện là thấy tin mới nhất ở đáy.
- Đang ở đáy mà có tin mới thì tự cuộn theo. Đang kéo lên đọc tin cũ thì đứng yên, chỉ hiện nút "↓ 3 tin mới".
- Kéo lên đỉnh thì tải tin cũ hơn, và chỗ đang đọc không nhảy đi đâu cả.
- Ảnh trong tin nhắn tải xong làm tin cao thêm 200px: vẫn không nhảy.
- Cuộn tới mép khung chat thì dừng, không kéo luôn cả trang bên ngoài.
Làm ngây thơ thì gặp đủ cả năm lỗi. Thử ngay trong demo: bật "Cách ngây thơ", kéo lên đỉnh. Khi tin cũ được chèn vào, tin bạn đang đọc bị đẩy văng xuống hơn 500px.
Tip
Tóm tắt
- Lưu vị trí khi người dùng cuộn, khôi phục trong
useLayoutEffect(sau khi DOM đổi, trước khi vẽ) và trong callback ResizeObserver. - Facebook lưu "khoảng cách tới đáy". Bản viết lại neo theo tin nhắn đang hiện (anchor), đúng cả khi nội dung đổi ở phía trên lẫn phía dưới.
- Bắn sự kiện theo cạnh (chỉ khi chuyển vào đỉnh/đáy) để không gọi tải tin cũ lặp lại.
overscroll-behavior: containđể cuộn tới mép không kéo theo trang bên ngoài.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Facebook làm thế nào?
Messenger trên facebook.com dùng module MWChatConversationScroller_DEPRECATED.bs (nằm trong bundle 99 ChatComposer/all.js của facebook-codebase). Đuôi .bs cho biết module được viết bằng ReasonML và biên dịch qua BuckleScript. Vì vậy trong code có những thứ như {TAG: 0, _0: b}: đó là cách ReasonML biểu diễn một biến thể (variant) có kèm dữ liệu.
#Bốn trạng thái vị trí
Giải mã hàm cập nhật vị trí (đã đổi tên biến, viết gọn lại):
function isAtBottom(el, tolerance = 20) { return el.scrollTop + el.clientHeight >= el.scrollHeight - tolerance }
function isAtTop(el, tolerance = 20) { return el.scrollTop <= tolerance }
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
state.current = isAtBottom(el) ? (hasMoreAfter ? 1 : 0)
: isAtTop(el) ? { TAG: 0, _0: distFromBottom } // NearTop(dist)
: { TAG: 1, _0: distFromBottom }; // Middle(dist)
js
Dịch ra kiểu TypeScript:
type Position =
| 'AtBottom' // 0: ở đáy, không còn tin mới hơn chưa tải
| 'AtBottomWithMoreAfter' // 1: ở đáy nhưng phía sau còn tin (đang xem lịch sử, nhảy tới tin cũ)
| { NearTop: number } // ở đỉnh, lưu khoảng cách tới ĐÁY
| { Middle: number }; // ở giữa, lưu khoảng cách tới ĐÁY
ts
Dung sai 20px được export thành hằng scrollTolerance = 20. Facebook có 4 trạng thái; bản viết lại bên dưới gộp hai trạng thái "ở đáy" làm một, còn 3.
Điểm mấu chốt: vị trí được lưu dưới dạng khoảng cách tới đáy, không phải scrollTop. Khi tin cũ chèn lên trên, scrollHeight tăng, nhưng khoảng cách từ chỗ đang xem tới đáy không đổi. Khôi phục khoảng cách đó là giữ nguyên chỗ đang đọc.
#Sự kiện theo cạnh (edge-triggered)
Hàm cập nhật không gọi "tải tin cũ" mỗi khi thấy đang ở đỉnh. Nó so trạng thái cũ với trạng thái mới:
- Đi từ AtBottom hoặc Middle vào NearTop:
dispatch(2)(tải tin cũ). - Đi từ NearTop hoặc Middle về đáy:
dispatch(1)(về đáy: đánh dấu đã đọc, tải tin mới hơn nếu có). - Đứng yên ở đỉnh, cuộn qua lại vài pixel: không gọi gì thêm.
Đây là khác biệt giữa "level-triggered" (cứ ở trạng thái X là gọi) và "edge-triggered" (chỉ gọi khi chuyển vào X). Kiểu thứ hai tự nhiên chống gọi trùng mà không cần cờ isLoading.
stateDiagram-v2 [*] --> AtBottom AtBottom --> Middle: kéo lên Middle --> NearTop: chạm đỉnh / tải tin cũ NearTop --> Middle: tin cũ chèn vào, vị trí được giữ Middle --> AtBottom: kéo xuống / đánh dấu đã đọc NearTop --> AtBottom: nhảy xuống cuối AtBottom --> AtBottom: tin mới đến, dính đáy
#Khôi phục sau mỗi lần render
useLayoutEffect(() => { // không có deps: chạy sau MỌI lần render
const s = state.current;
if (typeof s === "number") { if (s === 0) scrollToBottom() } // AtBottom: dính đáy
else if (s.TAG /* Middle */ && gkx("1600131")) { /* bỏ qua */ }
else scrollTop = scrollHeight - (s._0 + clientHeight); // giữ khoảng cách tới đáy
});
js
useLayoutEffectchạy sau khi DOM đã đổi nhưng trước khi trình duyệt vẽ. Người dùng không bao giờ thấy khung hình "đang nhảy".AtBottomWithMoreAfterthì không dính đáy: đang xem lịch sử (ví dụ vừa nhảy tới một tin cũ được trả lời), tin mới đến không nên kéo người dùng đi.Middle: mặc định cũng giữ khoảng cách tới đáy, trừ khi gatekeeper1600131bật. Lý do có cờ này: đang đọc ở giữa mà có tin mới thêm vào cuối, giữ khoảng cách tới đáy sẽ làm nội dung đang đọc trôi lên đúng bằng chiều cao tin mới. Việc đặt nó sau một gatekeeper cho thấy Facebook đang thử bỏ hành vi đó. Bản viết lại bên dưới giải quyết triệt để chuyện này.
Một layout effect khác lo trường hợp nội dung chưa lấp đầy khung:
useLayoutEffect(() => {
if (el.clientHeight > 0 && Math.abs(el.clientHeight - el.scrollHeight) < 20) dispatch(0);
});
js
Cuộc trò chuyện mới chỉ có 3 tin thì không có thanh cuộn, người dùng không thể "kéo lên đỉnh" để kích hoạt tải tin cũ, nên phải tự tải tiếp.
#Lắng nghe gì, và tại sao lạ vậy
const disableElasticScroll = document.body.style.overscrollBehavior === undefined
&& document.body.style.scrollBoundaryBehavior === undefined;
// …
mouseenter -> addEventListener("wheel", onWheel, { passive: !disableElasticScroll }) // ưu tiên "mousewheel" nếu trình duyệt có
mouseleave -> removeEventListener("wheel", …)
scroll -> throttle(update, 50, { trailing: true }) // chỉ khi gkx("1283332") bật
mouseup / mousedown -> update
js
- CSS của khung có
overscroll-behavior: contain. Cuộn tới mép thì dừng, không "chain" sang trang cha. Trình duyệt không hỗ trợ thuộc tính này (cả tên cũscroll-boundary-behavior) thìonWheelgọipreventDefault()khi cuộn xuống lúc đã ở đáy hoặc cuộn lên lúc đã ở đỉnh. Ngày nay mọi trình duyệt chính đều cóoverscroll-behavior(Safari từ bản 16, 2022), nên nhánh này chỉ còn ý nghĩa với trình duyệt cũ. - Listener
wheelphải non-passive mới gọi đượcpreventDefault. Mà listener non-passive làm trình duyệt phải chờ JS trước khi cuộn (chậm). Vì vậy họ chỉ gắn nó khi chuột đang ở trên khung chat (mouseenter), và gỡ khi rời đi. Trình duyệt hỗ trợoverscroll-behaviorthì listener là passive. - Sự kiện
scrollđược throttle 50ms (trailing) và nằm sau một gatekeeper. Khi cờ tắt, vị trí chỉ được cập nhật ởwheel,mouseup,mousedown. Có lẽ họ đang đo xem nghescrollcó đáng không (suy đoán).
Ngoài ra, scroller còn cung cấp BaseScrollableAreaContext. Nhờ đó popover, tooltip mở bên trong khung chat biết container cuộn của mình để tự định vị lại khi cuộn (xem bài #12 về popover). Imperative handle có scrollToBottom() và scrollToElement(el, offset): scrollIntoView rồi scrollBy(0, -offset) để chừa chỗ cho header.
#Bên trong trình duyệt: scroll anchoring và column-reverse
Hai "đường tắt" hay được nhắc tới, kèm lý do Facebook không dựa vào chúng:
Scroll anchoring (overflow-anchor). Chrome và Firefox có cơ chế tự giữ vị trí khi nội dung phía trên thay đổi. Trình duyệt chọn một "anchor node" đang hiện trong khung, và sau mỗi lần layout dịch scrollTop để anchor đứng yên. Đó chính xác là thứ ta cần khi chèn tin cũ. Nhưng:
- Hỗ trợ không đồng đều: Safari có một thời gian dài không hỗ trợ
overflow-anchor(tình trạng hiện tại: xem caniuse). Khung chat phải chạy đúng trên iPhone. - Trình duyệt tự chọn anchor theo heuristic, và nó không biết khái niệm "dính đáy".
- Code tự khôi phục vị trí cộng với anchoring của trình duyệt có thể bù hai lần.
Vì vậy bản viết lại đặt overflow-anchor: none cho khung chat và tự làm mọi thứ, để hành vi giống nhau ở mọi nơi. Khung "ngây thơ" trong demo cũng đặt overflow-anchor: none để mô phỏng trình duyệt không có anchoring.
flex-direction: column-reverse. Đảo chiều flex làm vị trí cuộn bắt đầu từ đáy: tin mới tự dính đáy và chèn tin cũ (vào cuối DOM) không làm nhảy. Rất gọn, nhưng:
- Thứ tự DOM ngược với thứ tự hiển thị. Trình đọc màn hình đọc từ tin mới nhất ngược lên, bôi đen chữ hay
Tabcũng đi ngược. Muốn đúng phải đảo cả dữ liệu. scrollTopmang giá trị âm (0 là đáy). Các trình duyệt từng không thống nhất chuyện này, và code nào giả địnhscrollTop >= 0đều sai.- Vẫn phải tự xử lý "ở giữa thì đừng nhảy khi có tin mới".
#Viết lại: thay "khoảng cách tới đáy" bằng anchor
Khi viết demo, mình làm theo đúng Facebook (lưu khoảng cách tới đáy cho NearTop) và gặp ngay một lỗi. Lỗi này đến từ cách demo hiển thị trạng thái tải, không phải lỗi của Messenger, nhưng rất dễ gặp trong app của bạn. Ngay khi chạm đỉnh, demo hiện dòng "Đang tải tin cũ…" ở đầu danh sách, bên trong vùng cuộn, phía trên tin nhắn. Dòng này cao khoảng 30px, chèn lên trên, nên vị trí được khôi phục về scrollTop ≈ 30, tức là trạng thái chuyển từ NearTop sang Middle. 800ms sau, tin cũ về, nhưng lúc này đang là Middle nên không còn được giữ chỗ. Đo bằng Playwright: tin #1000 nhảy từ 11px xuống 529px.
Nói cách khác, chiến lược "giữ khoảng cách tới đáy" chỉ đúng khi mọi thay đổi xảy ra phía trên chỗ đang đọc. Còn chiến lược "không làm gì" chỉ đúng khi mọi thay đổi xảy ra phía dưới. Spinner, ảnh tải chậm, tin mới đều có thể xảy ra ở cả hai phía.
Lời giải là neo theo phần tử (giống tinh thần của scroll anchoring):
- Mỗi lần cuộn, ghi lại tin nhắn đầu tiên đang hiện ở mép trên khung và khoảng cách của nó tới mép trên.
- Sau khi DOM đổi, đo lại xem tin đó bị đẩy đi bao nhiêu và cộng đúng bấy nhiêu vào
scrollTop. - Đang ở đáy thì vẫn dính đáy như Facebook.
Sau khi sửa, cùng kịch bản đó tin #1000 đứng ở 11px → 10px (lệch 1px do làm tròn).
Tìm anchor: con đầu tiên còn nhìn thấy ở mép trên khung.
src/lib/useChatScroller.ts
/** Phần tử "neo": con đầu tiên còn nhìn thấy ở mép trên khung, kèm khoảng cách tới mép trên. */
interface Anchor {
element: Element;
offset: number;
}
function findAnchor(scroller: HTMLElement, content: Element, itemSelector?: string): Anchor | null {
const viewportTop = scroller.getBoundingClientRect().top;
const children = itemSelector ? content.querySelectorAll(`:scope > ${itemSelector}`) : content.children;
// Tìm nhị phân con đầu tiên có đáy nằm dưới mép trên khung: O(log n) lần đo.
// GIẢ ĐỊNH: vị trí các con tăng dần theo thứ tự DOM. Header sticky (luôn dính ở trên),
// phần tử display:none (rect = 0) hay absolute phá giả định này -> loại bằng itemSelector.
let lo = 0;
let hi = children.length - 1;
let found = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (children[mid].getBoundingClientRect().bottom > viewportTop) {
found = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
if (found === -1) return null;
const element = children[found];
return { element, offset: element.getBoundingClientRect().top - viewportTop };
}
ts
Khôi phục và cập nhật vị trí:
src/lib/useChatScroller.ts
/** Đặt lại scrollTop theo vị trí đã ghi nhận. Idempotent: gọi nhiều lần không sao. */
const restore = useCallback(() => {
if (!scroller) return;
const pos = positionRef.current;
if (pos.kind === 'bottom') {
scroller.scrollTop = scroller.scrollHeight;
return;
}
const anchor = anchorRef.current;
if (anchor && anchor.element.isConnected) {
// Neo bị đẩy xuống/kéo lên bao nhiêu thì bù scrollTop đúng bấy nhiêu.
const viewportTop = scroller.getBoundingClientRect().top;
const delta = anchor.element.getBoundingClientRect().top - viewportTop - anchor.offset;
if (Math.abs(delta) >= 1) scroller.scrollTop += delta;
} else {
// Không còn neo (bị xoá): quay về cách của Facebook, giữ khoảng cách tới đáy.
scroller.scrollTop = scroller.scrollHeight - (pos.fromBottom + scroller.clientHeight);
}
}, [scroller]);
// Ghi nhận vị trí + neo, và bắn sự kiện khi CHUYỂN trạng thái (edge-triggered, giống Facebook):
// nằm yên ở đỉnh không gọi tải tin cũ liên tục.
const update = useCallback(() => {
if (!scroller) return;
const prev = positionRef.current;
const next = measureChatPosition(scroller, threshold);
positionRef.current = next;
const contentEl = getContent();
anchorRef.current =
next.kind === 'bottom' || !contentEl ? null : findAnchor(scroller, contentEl, itemSelector);
if (next.kind !== prev.kind) optionsRef.current.onPositionChange?.(next);
if (next.kind === 'top' && prev.kind !== 'top') optionsRef.current.onReachTop?.();
if (next.kind === 'bottom' && prev.kind !== 'bottom') optionsRef.current.onReachBottom?.();
}, [scroller, threshold, getContent, itemSelector]);
ts
Xem toàn bộ src/lib/useChatScroller.ts
src/lib/useChatScroller.ts
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
/**
* Khung cuộn cho chat (ý tưởng từ MWChatConversationScroller của Facebook Messenger):
* - đang ở đáy thì tin mới đến vẫn dính đáy
* - nội dung phía trên thay đổi (tải tin cũ, spinner, ảnh tải xong) thì chỗ đang đọc đứng yên
* - chạm đỉnh -> gọi tải tin cũ; nội dung chưa lấp đầy khung -> gọi tải thêm ngay
* - cuộn tới mép không "lan" ra trang bên ngoài (overscroll containment)
*
* Bí quyết: ghi lại vị trí TRƯỚC khi nội dung đổi (trong sự kiện scroll), rồi khôi phục SAU khi
* DOM đổi nhưng TRƯỚC khi trình duyệt vẽ (useLayoutEffect / ResizeObserver).
*/
export type ChatScrollPosition =
| { kind: 'bottom' }
| { kind: 'top'; fromBottom: number }
| { kind: 'middle'; fromBottom: number };
export interface UseChatScrollerOptions {
/** vừa cuộn chạm đỉnh: tải tin cũ hơn */
onReachTop?: () => void;
/** vừa cuộn về đáy: đánh dấu đã đọc, tải tin mới hơn… */
onReachBottom?: () => void;
/** nội dung chưa đủ cao để cuộn: nên tải thêm luôn (người dùng không thể cuộn để kích hoạt) */
onNotFilled?: () => void;
onPositionChange?: (position: ChatScrollPosition) => void;
/** dung sai coi là "ở đáy" / "ở đỉnh", Facebook dùng 20px */
threshold?: number;
/** theo dõi thay đổi kích thước không qua React (ảnh tải xong, bàn phím mobile bật lên…) */
observeResize?: boolean;
/**
* Chỉ chọn neo trong các con khớp selector (vd '[data-msg]'). Cần khi danh sách có phần tử
* không nằm theo thứ tự dọc thông thường: header ngày `position: sticky`, phần tử ẩn hoặc
* định vị tuyệt đối. Mặc định: mọi con trực tiếp.
*/
itemSelector?: string;
}
export function measureChatPosition(el: HTMLElement, threshold = 20): ChatScrollPosition {
const fromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
if (fromBottom <= threshold) return { kind: 'bottom' };
if (el.scrollTop <= threshold) return { kind: 'top', fromBottom };
return { kind: 'middle', fromBottom };
}
/** Phần tử "neo": con đầu tiên còn nhìn thấy ở mép trên khung, kèm khoảng cách tới mép trên. */
interface Anchor {
element: Element;
offset: number;
}
function findAnchor(scroller: HTMLElement, content: Element, itemSelector?: string): Anchor | null {
const viewportTop = scroller.getBoundingClientRect().top;
const children = itemSelector ? content.querySelectorAll(`:scope > ${itemSelector}`) : content.children;
// Tìm nhị phân con đầu tiên có đáy nằm dưới mép trên khung: O(log n) lần đo.
// GIẢ ĐỊNH: vị trí các con tăng dần theo thứ tự DOM. Header sticky (luôn dính ở trên),
// phần tử display:none (rect = 0) hay absolute phá giả định này -> loại bằng itemSelector.
let lo = 0;
let hi = children.length - 1;
let found = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (children[mid].getBoundingClientRect().bottom > viewportTop) {
found = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
if (found === -1) return null;
const element = children[found];
return { element, offset: element.getBoundingClientRect().top - viewportTop };
}
const supportsOverscrollBehavior =
typeof CSS !== 'undefined' && CSS.supports?.('overscroll-behavior', 'contain');
export function useChatScroller<S extends HTMLElement = HTMLDivElement, C extends HTMLElement = HTMLDivElement>(
options: UseChatScrollerOptions = {},
) {
const { threshold = 20, observeResize = true, itemSelector } = options;
const [scroller, setScroller] = useState<S | null>(null);
const [content, setContent] = useState<C | null>(null);
// Chat mở ra là đang ở tin mới nhất.
const positionRef = useRef<ChatScrollPosition>({ kind: 'bottom' });
const anchorRef = useRef<Anchor | null>(null);
// "latest ref" cho các callback: listener gắn 1 lần nhưng luôn gọi bản mới nhất
const optionsRef = useRef(options);
useLayoutEffect(() => {
optionsRef.current = options;
});
const getContent = useCallback(
() => content ?? scroller?.firstElementChild ?? null,
[content, scroller],
);
/** Đặt lại scrollTop theo vị trí đã ghi nhận. Idempotent: gọi nhiều lần không sao. */
const restore = useCallback(() => {
if (!scroller) return;
const pos = positionRef.current;
if (pos.kind === 'bottom') {
scroller.scrollTop = scroller.scrollHeight;
return;
}
const anchor = anchorRef.current;
if (anchor && anchor.element.isConnected) {
// Neo bị đẩy xuống/kéo lên bao nhiêu thì bù scrollTop đúng bấy nhiêu.
const viewportTop = scroller.getBoundingClientRect().top;
const delta = anchor.element.getBoundingClientRect().top - viewportTop - anchor.offset;
if (Math.abs(delta) >= 1) scroller.scrollTop += delta;
} else {
// Không còn neo (bị xoá): quay về cách của Facebook, giữ khoảng cách tới đáy.
scroller.scrollTop = scroller.scrollHeight - (pos.fromBottom + scroller.clientHeight);
}
}, [scroller]);
// Ghi nhận vị trí + neo, và bắn sự kiện khi CHUYỂN trạng thái (edge-triggered, giống Facebook):
// nằm yên ở đỉnh không gọi tải tin cũ liên tục.
const update = useCallback(() => {
if (!scroller) return;
const prev = positionRef.current;
const next = measureChatPosition(scroller, threshold);
positionRef.current = next;
const contentEl = getContent();
anchorRef.current =
next.kind === 'bottom' || !contentEl ? null : findAnchor(scroller, contentEl, itemSelector);
if (next.kind !== prev.kind) optionsRef.current.onPositionChange?.(next);
if (next.kind === 'top' && prev.kind !== 'top') optionsRef.current.onReachTop?.();
if (next.kind === 'bottom' && prev.kind !== 'bottom') optionsRef.current.onReachBottom?.();
}, [scroller, threshold, getContent, itemSelector]);
// Sau MỖI lần render (không deps): DOM đã đổi nhưng chưa vẽ -> khôi phục vị trí.
useLayoutEffect(() => {
if (!scroller) return;
restore();
if (scroller.clientHeight > 0 && Math.abs(scroller.scrollHeight - scroller.clientHeight) < threshold) {
optionsRef.current.onNotFilled?.();
}
});
useEffect(() => {
if (!scroller) return;
// Tắt scroll anchoring của trình duyệt: ta tự neo, để hành vi giống nhau trên mọi trình
// duyệt (hỗ trợ overflow-anchor không đồng đều, Safari có thời gian dài không có) và để
// không bị bù hai lần.
scroller.style.overflowAnchor = 'none';
scroller.style.overscrollBehavior = 'contain';
// Chỉ đọc layout (không ghi), nên chạy ở mỗi sự kiện scroll vẫn rẻ.
scroller.addEventListener('scroll', update, { passive: true });
// Fallback cho trình duyệt CŨ không có overscroll-behavior (vd Safari < 16): chặn wheel ở
// mép bằng preventDefault. Trình duyệt hiện đại không chạy nhánh này. Listener non-passive
// làm chậm cuộn, nên chỉ gắn khi chuột đang ở trên khung chat.
const onWheel = (event: WheelEvent) => {
const atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= 0;
if ((event.deltaY > 0 && atBottom) || (event.deltaY < 0 && scroller.scrollTop <= 0)) {
event.preventDefault();
}
};
const onEnter = () => scroller.addEventListener('wheel', onWheel, { passive: false });
const onLeave = () => scroller.removeEventListener('wheel', onWheel);
if (!supportsOverscrollBehavior) {
scroller.addEventListener('mouseenter', onEnter);
scroller.addEventListener('mouseleave', onLeave);
}
return () => {
scroller.removeEventListener('scroll', update);
scroller.removeEventListener('mouseenter', onEnter);
scroller.removeEventListener('mouseleave', onLeave);
onLeave();
};
}, [scroller, update]);
// Kích thước đổi KHÔNG qua React render: ảnh/video tải xong, font tải xong, bàn phím ảo bật
// làm khung chat thấp đi… useLayoutEffect không chạy trong các trường hợp này.
useEffect(() => {
if (!scroller || !observeResize || typeof ResizeObserver === 'undefined') return;
// Callback của ResizeObserver chạy sau layout, trước paint -> sửa vị trí không bị giật.
const observer = new ResizeObserver(() => restore());
observer.observe(scroller);
const contentEl = getContent();
if (contentEl) observer.observe(contentEl);
return () => observer.disconnect();
}, [scroller, observeResize, restore, getContent]);
const scrollToBottom = useCallback(() => {
if (!scroller) return;
positionRef.current = { kind: 'bottom' };
anchorRef.current = null;
scroller.scrollTop = scroller.scrollHeight;
}, [scroller]);
return {
/** gắn vào phần tử có overflow-y: auto */
scrollerRef: setScroller,
/** gắn vào phần tử bọc trực tiếp danh sách tin nhắn (mỗi con = một tin) */
contentRef: setContent,
scrollToBottom,
getPosition: () => positionRef.current,
};
}
ts
#Đi qua code
1. measureChatPosition. Giữ đúng ba trạng thái và dung sai 20px của Facebook. Biến thể AtBottomWithMoreAfter được bỏ cho gọn: nếu bạn có tính năng "nhảy tới tin cũ" thì nên thêm lại, vì lúc đó đáy khung chưa phải tin mới nhất.
2. update, chạy ở mỗi sự kiện scroll.
- Đo trạng thái mới và ghi anchor.
- Nếu trạng thái đổi thì gọi
onPositionChange. Nếu đổi vào đỉnh thì gọionReachTop, nếu đổi về đáy thì gọionReachBottom. - Không throttle như Facebook. Hàm chỉ đọc layout (
scrollTop,scrollHeight,getBoundingClientRect), không ghi gì, nên không gây "layout thrashing". Sự kiệnscrollvốn chỉ bắn tối đa một lần mỗi khung hình. Throttle còn có cái giá: trong 50ms trễ, một lần render có thể khôi phục theo vị trí cũ.
3. findAnchor. Tìm nhị phân trên các con của content cho con đầu tiên có bottom > mép trên khung. Với 5.000 tin nhắn chỉ cần khoảng 13 lần đo thay vì 5.000. Đây là lý do hook cần contentRef gắn vào phần tử bọc trực tiếp các tin nhắn. Tìm nhị phân có một giả định: vị trí các con tăng dần theo thứ tự DOM. Header ngày position: sticky (luôn dính ở trên), phần tử display: none (rect bằng 0) hay định vị tuyệt đối phá giả định đó và có thể làm chọn sai anchor. Khi có những phần tử như vậy, truyền itemSelector (vd '[data-msg]') để chỉ xét tin nhắn. Scroll anchoring của trình duyệt cũng loại phần tử sticky/fixed khỏi danh sách ứng viên anchor vì cùng lý do.
4. restore.
bottom→scrollTop = scrollHeight.- Còn lại: nếu anchor còn trong DOM, bù đúng độ lệch. Nếu anchor đã bị xoá (ví dụ tin bị thu hồi), quay về cách của Facebook: giữ khoảng cách tới đáy.
- Hàm idempotent: gọi lại khi không có gì đổi thì độ lệch bằng 0, không làm gì. Nhờ vậy gọi nó ở mỗi lần render và mỗi callback ResizeObserver đều an toàn.
5. useLayoutEffect không deps. Giống Facebook: sau mỗi lần render, trước khi vẽ. Kèm kiểm tra "chưa lấp đầy" để gọi onNotFilled.
6. ResizeObserver: phần Facebook không có. Ảnh trong tin nhắn tải xong, font web tải xong, bàn phím ảo bật lên làm khung chat thấp đi… tất cả đều đổi chiều cao mà không qua React render, nên useLayoutEffect không chạy. ResizeObserver theo dõi cả khung (clientHeight đổi) lẫn phần nội dung (chiều cao đổi). Callback của nó chạy sau layout, trước paint, trong cùng bước "update the rendering" của event loop, nên sửa vị trí ở đó cũng không bị giật. Demo: bật "Tin mới có ảnh tải chậm", đứng ở đáy. Có ResizeObserver thì vẫn dính đáy. Tắt đi thì sau khi ảnh tải xong, khung lệch khỏi đáy 140px (đúng chiều cao ảnh).
7. overflow-anchor: none và overscroll-behavior: contain được đặt từ JS để hook tự đủ, không phụ thuộc CSS bên ngoài. Fallback wheel giữ đúng mẹo của Facebook (chỉ gắn listener non-passive khi chuột ở trên khung), nhưng chỉ chạy trên trình duyệt cũ không có overscroll-behavior. Nếu không cần hỗ trợ Safari < 16, có thể xoá hẳn nhánh này.
Khác bản gốc: neo theo phần tử thay vì khoảng cách tới đáy; 3 trạng thái thay vì 4; nghe scroll không throttle; thêm ResizeObserver; có tuỳ chọn itemSelector.
#Tại sao không làm cách khác?
"Dùng useEffect thay useLayoutEffect?" useEffect chạy sau khi trình duyệt đã vẽ. Người dùng sẽ thấy một khung hình nội dung bị đẩy đi, rồi khung sau mới nhảy về: đúng cái "giật" ta đang muốn tránh.
"Lưu scrollTop thay vì khoảng cách tới đáy hay anchor?" scrollTop đo từ đỉnh. Chèn tin cũ lên đỉnh làm mọi thứ dịch xuống, nên scrollTop cũ chỉ vào nội dung khác. Đó chính là lỗi của cách ngây thơ.
"Sao không gọi tải tin cũ ở IntersectionObserver trên tin đầu tiên?" Hoàn toàn được, và nhẹ hơn (xem bài #1). Nhưng bạn vẫn cần nghe scroll để biết vị trí (đáy/giữa) và để ghi anchor, nên gộp lại một chỗ cho đơn giản.
"Virtual list?" Cuộc trò chuyện hàng chục nghìn tin thì nên ảo hoá. Nguyên lý giữ chỗ vẫn y hệt: đo chiều cao các mục mới, bù scrollTop. Các thư viện như react-virtuoso có sẵn tuỳ chọn followOutput (dính đáy) và firstItemIndex (chèn mục phía trên mà không nhảy) cho đúng bài toán này.
#Edge case và cạm bẫy
scroll-behavior: smoothtrên khung chat. Mọi lần gánscrollTopđể khôi phục sẽ biến thành một animation cuộn, và nội dung "trượt" về chỗ cũ thay vì đứng yên. Giữscroll-behavior: autocho container này. Muốn cuộn mượt khi bấm "↓ Xuống cuối" thì gọiscrollTo({ behavior: 'smooth' })riêng lúc đó.- Trang tin cũ quá ngắn. Tải 15 tin cũ mà tổng cao chưa tới 20px (hiếm, nhưng có thể với tin hệ thống) thì sau khi chèn vẫn đang ở đỉnh. Trạng thái không đổi nên
onReachTopkhông bắn lại. Hãy tải đủ nhiều tin mỗi trang, hoặc sau khi tải xong tự kiểm tragetPosition()và gọi tiếp. onNotFilledgọi ở mọi lần render khi nội dung chưa đầy. Phía gọi phải tự chặn bằng cờ "đang tải" (demo dùngloadingRef). Facebook cũng vậy: reducer phía sau tự bỏ qua khi đang tải.- Ảnh không có kích thước. ResizeObserver cứu được vị trí, nhưng tốt nhất vẫn là gửi kèm
width/heighttừ server và đặtaspect-ratiocho khung ảnh. Chiều cao đúng ngay từ đầu thì không có gì phải bù. - iOS và cuộn quán tính. Gán
scrollToptrong lúc người dùng đang cuộn quán tính (momentum) trên iOS có thể làm cú cuộn khựng lại. Nếu có thể, hãy dời việc chèn tin cũ cho tới khi cuộn dừng hẳn. - Header ngày (sticky), phần tử ẩn hoặc absolute trong danh sách. Chúng phá giả định "vị trí tăng dần theo thứ tự DOM" của tìm nhị phân, nên có thể làm chọn sai anchor (không chỉ là đo một phần tử vô nghĩa). Đánh dấu tin nhắn bằng
data-msgvà truyềnitemSelector: '[data-msg]'.
#Kiểm thử
Kiểm bằng trình duyệt thật là đáng tin nhất, vì jsdom không tính layout.
Ví dụ test Playwright (bản phác, không có trong repo)
import { expect, test } from '@playwright/test';
test('tải tin cũ không làm nhảy tin đang đọc', async ({ page }) => {
await page.goto('/#/chat-scroller');
const chat = page.getByTestId('chat');
const topOf = (id: number) =>
chat.evaluate((el, id) => {
const msg = [...el.querySelectorAll('.cs-msg')].find((m) => m.textContent!.startsWith(`#${id} `))!;
return msg.getBoundingClientRect().top - el.getBoundingClientRect().top;
}, id);
await page.getByLabel('Tin mới mỗi 2 giây').uncheck(); // tắt tin mới cho dễ đếm
await chat.evaluate((el) => (el.scrollTop = 0)); // chạm đỉnh -> tải tin cũ
const before = await topOf(1000);
await expect(chat.locator('.cs-msg')).toHaveCount(35); // 20 tin ban đầu + 15 tin cũ vừa tải
expect(Math.abs((await topOf(1000)) - before)).toBeLessThanOrEqual(1);
});
test('đang ở đáy thì tin mới dính đáy', async ({ page }) => {
await page.goto('/#/chat-scroller');
await page.waitForTimeout(4500); // 2 tin mới đến
const fromBottom = await page
.getByTestId('chat')
.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight);
expect(fromBottom).toBeLessThanOrEqual(1);
});
ts
#Dùng trong dự án
Kết hợp với useInfiniteQuery của TanStack Query, theo chiều ngược (trang mới nhất trước):
function Conversation({ threadId }: { threadId: string }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['messages', threadId],
queryFn: ({ pageParam }) => fetchMessages(threadId, pageParam), // trang cũ hơn
initialPageParam: null as string | null,
getNextPageParam: (last) => last.olderCursor,
});
const loadOlder = () => hasNextPage && !isFetchingNextPage && fetchNextPage();
const { scrollerRef, contentRef } = useChatScroller({
onReachTop: loadOlder,
onNotFilled: loadOlder,
itemSelector: '[data-msg]', // chỉ tin nhắn làm anchor, bỏ qua spinner/header ngày
});
// pages[0] là trang mới nhất -> đảo lại để tin cũ nằm trên
const messages = data?.pages.slice().reverse().flatMap((p) => p.items) ?? [];
return (
<div ref={scrollerRef} style={{ overflowY: 'auto', height: '100%' }}>
<div ref={contentRef}>
{isFetchingNextPage && <div className='spinner'>Đang tải tin cũ…</div>}
{messages.map((m) => (
// con TRỰC TIẾP của contentRef: đừng bọc thêm wrapper quanh từng tin
<MessageRow key={m.id} message={m} data-msg />
))}
</div>
</div>
);
}
tsx
Spinner "Đang tải tin cũ…" cứ đặt thoải mái ở đầu danh sách: anchor lo phần còn lại. Lưu ý mỗi tin (MessageRow, render ra phần tử có data-msg) phải là con trực tiếp của phần tử gắn contentRef; nếu bọc thêm một lớp, findAnchor sẽ không thấy tin nào.
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
- #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 (bài này)
- #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

