Học từ Facebook #3: preload code và data ngay khi người dùng hover
Tận dụng vài trăm ms giữa hover và click để tải trước JS chunk và data như Facebook, và cái bẫy 300ms của Suspense khiến preload xong vẫn phải chờ.
Bạn đã code-split dialog "Xem profile" bằng React.lazy để bundle ban đầu nhẹ hơn. Kết quả: mỗi lần bấm nút, người dùng phải chờ tải chunk JS, rồi component mới render, rồi mới gọi API, nhìn spinner gần một giây. Code-split đã đổi thời gian tải trang lấy thời gian chờ lúc tương tác.
Facebook lấy lại khoảng thời gian đó rất gọn. Từ lúc con trỏ chạm vào nút tới lúc nhả chuột click thường có vài trăm mili-giây: người dùng phải di chuột tới, ngắm, nhấn xuống, nhả ra. Nếu bắt đầu tải ngay khi hover, phần lớn thời gian chờ đã trôi qua trước cả khi sự kiện click xảy ra.
Tip
Tóm tắt
- Hover đủ 50ms thì bắt đầu tải chunk JS và data song song; cảm ứng thì tải ở
pointerdown, bàn phím ởfocus. - Phần rẻ tải khi hover, phần đắt chờ tới khi nhấn xuống (giống chế độ
"button"của Facebook). - Preload thôi chưa đủ:
React.lazyvàuse()vẫn suspend thêm một nhịp với thứ đã tải xong, và Suspense throttle 300ms biến nhịp đó thành 300ms chờ vô ích. Cách sửa: thứ gì đã có thì đọc đồng bộ.
#Facebook làm thế nào?
Trong thư mục Prerenderer/ của facebook-codebase có useCometPreloaderImpl, useCometPrerendererImpl và CometMouseActivity. Đọc kỹ thì thấy:
- Chờ 50ms rồi mới preload. Khi hover, hook đặt một timer
50ms(hằng sốj = 50). Rời chuột trước 50ms thìclearTimeout. Lướt chuột ngang qua một danh sách 20 nút sẽ không bắn 20 request. - Timer chạy ở độ ưu tiên "user blocking". Nếu module
setTimeoutCometInternalscó sẵn, timer được đặt bằngsetTimeoutAtPriority_DO_NOT_USE(JSScheduler.priorities.unstable_UserBlocking, …). Không có thì quay vềsetTimeoutthường. Preload là việc người dùng sắp cần nên được ưu tiên cao. - Kiểm tra
pointerType === "mouse". Handler cho sự kiện nhấn xuống bỏ qua mọi thứ không phải chuột. - Nhiều mức độ ("mode").
"tooltip": hover đủ 50ms thì preload cả code lẫn data, đồng thời bật prerender."button": hover chỉ preload một trong hai thứ (code), nhấn chuột xuống mới preload thứ còn lại (data) và bật prerender. Tức là phần rẻ đi trước, phần đắt chờ tới khi ý định rõ ràng hơn."button_aggressive": nhưbutton, thêm một lần preload khi chuột dừng lại trên nút.CometMouseActivitydùng một listenermousemovetoàn cục. Không cómousemovenào trong 50ms thì coi là chuột đã dừng và gọi các callback đã đăng ký.
- Có cả đường "speculative". Hook còn trả về một hàm thứ tư nhận
shouldPreload: boolean. Khitrue, cả code và data được preload quaJSScheduler.scheduleSpeculativeCallback, tức là chỉ chạy khi main thread rảnh. - Preload hai thứ: code (
JSResource, tương đươngimport()) và data (query của Relay). - Prerender: render sẵn component vào cây ẩn (
shouldPrerender), tới lúc mở thì chỉ cần hiện ra.
Còn một chi tiết nằm ở chỗ khác, trong BootloaderResource.read (thư mục lazyLoadComponent/), và nó chính là lời giải cho cái bẫy ở cuối bài:
function read(ref) {
const module = ref.getModuleIfRequireable();
if (module == null) {
// chưa có -> suspend; xong (thành công hay lỗi) thì xoá promise để lần sau được thử lại
pending[id] ??= ref.load().finally(() => delete pending[id]);
throw pending[id];
}
return module; // có rồi -> trả về ngay
}
js
(đã đổi tên biến cho dễ đọc). Module đã tải xong thì được trả về đồng bộ, không bao giờ suspend lần nữa.
#Dòng thời gian: không preload và có preload
sequenceDiagram participant U as Người dùng participant B as Nút participant N as Mạng Note over U,N: Không preload U->>B: hover U->>B: click sau khoảng 300ms B->>N: tải chunk JS N-->>B: 600ms sau B->>N: gọi API N-->>B: 700ms sau, hiện nội dung Note over U,N: Có preload U->>B: hover B->>N: sau 50ms, tải chunk JS và gọi API song song U->>B: click N-->>B: phần còn lại tới, hiện nội dung
Preload mang lại hai cái lợi tách bạch:
- Bắt đầu sớm hơn khoảng thời gian từ hover tới click.
- Bỏ được thác nước (waterfall): không preload thì API chỉ được gọi khi component đã tải xong và render. Preload thì code và data chạy song song.
Lưu ý về demo bên dưới: nút "không preload" cũng tải code và data song song khi click (tốt hơn thực tế, nơi thường là thác nước), nên khác biệt bạn thấy chỉ đến từ việc bắt đầu sớm hơn.
#Viết lại
Ba thứ cần có:
lazyWithPreload:React.lazykèm hàmpreload()dùng chung một promise.createResource: cache promise data theo key, dùng được vớiuse()của React 19.usePreloadOnHover: trả về các event handler để gắn vào nút.
Xem toàn bộ src/lib/preload.ts
src/lib/preload.ts
import {
createElement,
lazy,
useCallback,
useEffect,
useRef,
type ComponentProps,
type ComponentType,
type KeyboardEvent,
type PointerEvent,
} from 'react';
/**
* Preload code + data khi người dùng có "ý định" bấm (hover, focus, nhấn xuống),
* lấy ý tưởng từ useCometPreloader / useCometPrerenderer của Facebook.
*/
export type PreloadableComponent<T extends ComponentType<any>> = ComponentType<
ComponentProps<T>
> & {
preload: () => Promise<{ default: T }>;
};
/**
* React.lazy kèm hàm `preload()` để tải trước chunk JS.
*
* Lưu ý: lazy() luôn suspend ở lần render đầu, kể cả khi chunk đã tải xong, và React
* throttle việc hiện lại nội dung sau Suspense (~300ms). Vì vậy khi module đã có sẵn,
* ta render thẳng component thật, bỏ qua lazy.
*/
export function lazyWithPreload<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>,
): PreloadableComponent<T> {
let promise: Promise<{ default: T }> | null = null;
let loaded: T | null = null;
const load = () => {
promise ??= factory().then(
(module) => {
loaded = module.default;
return module;
},
(error) => {
promise = null; // lỗi mạng thì lần sau được thử lại
throw error;
},
);
return promise;
};
const LazyComponent = lazy(load);
function Preloadable(props: ComponentProps<T>) {
return createElement(loaded ?? LazyComponent, props);
}
return Object.assign(Preloadable, { preload: load });
}
type TrackedPromise<T> = Promise<T> & { status?: string; value?: T; reason?: unknown };
/**
* Cache promise theo key để vừa preload được, vừa dùng được với `use()` / Suspense.
* Gắn `status`/`value` lên promise: `use()` của React đọc 2 field này, thấy promise
* đã xong thì trả về giá trị ngay, không suspend.
*/
export function createResource<T>(fetcher: (key: string) => Promise<T>) {
const cache = new Map<string, Promise<T>>();
const read = (key: string): Promise<T> => {
let promise = cache.get(key);
if (!promise) {
const tracked = fetcher(key) as TrackedPromise<T>;
tracked.status = 'pending';
tracked.then(
(value) => {
tracked.status = 'fulfilled';
tracked.value = value;
},
(reason) => {
tracked.status = 'rejected';
tracked.reason = reason;
cache.delete(key);
},
);
cache.set(key, tracked);
promise = tracked;
}
return promise;
};
return { read, preload: (key: string) => void read(key) };
}
export interface UsePreloadOnHoverOptions {
/** chờ bao lâu khi hover thì mới preload, để lướt chuột qua không tốn request */
delay?: number;
/**
* Phần "đắt" (data lớn, ảnh…) chỉ tải khi ý định mạnh hơn: nhấn chuột/ngón tay xuống, hoặc
* Enter/Space. Giống chế độ "button" của Facebook: hover tải phần rẻ, nhấn mới tải phần đắt.
*/
preloadOnPress?: () => void;
}
/**
* Trả về các event handler để gắn vào nút/link.
* - Chuột: hover đủ `delay` ms mới preload, rời chuột trước đó thì huỷ.
* - Cảm ứng: không có hover -> preload ngay khi ngón tay chạm xuống (trước click ~100ms).
* - Bàn phím: preload khi focus.
*/
export function usePreloadOnHover(
preload: () => void,
{ delay = 50, preloadOnPress }: UsePreloadOnHoverOptions = {},
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// "latest ref": handler ổn định nhưng luôn gọi hàm preload mới nhất. Dùng useEffect là đủ
// vì ref chỉ được đọc trong event handler, không đọc trong lúc render hay layout.
const preloadRef = useRef(preload);
const pressRef = useRef(preloadOnPress);
useEffect(() => {
preloadRef.current = preload;
pressRef.current = preloadOnPress;
});
const cancel = useCallback(() => {
if (timerRef.current != null) clearTimeout(timerRef.current);
timerRef.current = null;
}, []);
useEffect(() => cancel, [cancel]);
const onPointerEnter = useCallback(
(event: PointerEvent) => {
if (event.pointerType !== 'mouse') return;
cancel();
timerRef.current = setTimeout(() => {
timerRef.current = null;
preloadRef.current();
}, delay);
},
[cancel, delay],
);
const onPointerDown = useCallback(() => {
cancel();
preloadRef.current();
pressRef.current?.();
}, [cancel]);
const onFocus = useCallback(() => preloadRef.current(), []);
const onKeyDown = useCallback((event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
preloadRef.current();
pressRef.current?.();
}
}, []);
return { onPointerEnter, onPointerLeave: cancel, onPointerDown, onFocus, onKeyDown };
}
ts
#Đi qua code
lazyWithPreload
src/lib/preload.ts
export function lazyWithPreload<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>,
): PreloadableComponent<T> {
let promise: Promise<{ default: T }> | null = null;
let loaded: T | null = null;
const load = () => {
promise ??= factory().then(
(module) => {
loaded = module.default;
return module;
},
(error) => {
promise = null; // lỗi mạng thì lần sau được thử lại
throw error;
},
);
return promise;
};
const LazyComponent = lazy(load);
function Preloadable(props: ComponentProps<T>) {
return createElement(loaded ?? LazyComponent, props);
}
return Object.assign(Preloadable, { preload: load });
}
ts
promise ??= factory()…đảm bảo dù gọipreload()10 lần (hover, focus, click) thìimport()chỉ chạy một lần. Thực ra trình duyệt cũng tự cache module ES theo URL, nhưng giữ promise ở đây giúp mọi nơi cùng chờ một promise và biết chính xác lúc nào module có sẵn.promise = nullở nhánh lỗi: chunk tải lỗi (mạng chập chờn, deploy mới làm chunk cũ biến mất) thì lần gọi sau được thử lại, thay vì mãi mãi trả về một promise đã reject.loaded = module.default: đây là "cửa tắt" giốnggetModuleIfRequireablecủa Facebook.Preloadablelà một component bọc mỏng. Lần render nào cũng kiểm traloaded. Có rồi thì render thẳng component thật, chưa có thì đi quaLazyComponentđể suspend như bình thường.
createResource
src/lib/preload.ts
export function createResource<T>(fetcher: (key: string) => Promise<T>) {
const cache = new Map<string, Promise<T>>();
const read = (key: string): Promise<T> => {
let promise = cache.get(key);
if (!promise) {
const tracked = fetcher(key) as TrackedPromise<T>;
tracked.status = 'pending';
tracked.then(
(value) => {
tracked.status = 'fulfilled';
tracked.value = value;
},
(reason) => {
tracked.status = 'rejected';
tracked.reason = reason;
cache.delete(key);
},
);
cache.set(key, tracked);
promise = tracked;
}
return promise;
};
return { read, preload: (key: string) => void read(key) };
}
ts
- Cache promise, không cache giá trị. Nhờ vậy preload và render dùng chung một request đang bay.
- Gắn
status/value/reasonlên promise trong.then. Phần bên dưới giải thích vì sao. - Promise reject thì bị xoá khỏi cache, để lần sau gọi lại API.
usePreloadOnHover
src/lib/preload.ts
export function usePreloadOnHover(
preload: () => void,
{ delay = 50, preloadOnPress }: UsePreloadOnHoverOptions = {},
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// "latest ref": handler ổn định nhưng luôn gọi hàm preload mới nhất. Dùng useEffect là đủ
// vì ref chỉ được đọc trong event handler, không đọc trong lúc render hay layout.
const preloadRef = useRef(preload);
const pressRef = useRef(preloadOnPress);
useEffect(() => {
preloadRef.current = preload;
pressRef.current = preloadOnPress;
});
const cancel = useCallback(() => {
if (timerRef.current != null) clearTimeout(timerRef.current);
timerRef.current = null;
}, []);
useEffect(() => cancel, [cancel]);
const onPointerEnter = useCallback(
(event: PointerEvent) => {
if (event.pointerType !== 'mouse') return;
cancel();
timerRef.current = setTimeout(() => {
timerRef.current = null;
preloadRef.current();
}, delay);
},
[cancel, delay],
);
const onPointerDown = useCallback(() => {
cancel();
preloadRef.current();
pressRef.current?.();
}, [cancel]);
const onFocus = useCallback(() => preloadRef.current(), []);
const onKeyDown = useCallback((event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
preloadRef.current();
pressRef.current?.();
}
}, []);
return { onPointerEnter, onPointerLeave: cancel, onPointerDown, onFocus, onKeyDown };
}
ts
| Sự kiện | Khi nào xảy ra | Hành động |
|---|---|---|
pointerenter (chuột) |
con trỏ đi vào nút | đặt timer delay ms, hết giờ thì preload |
pointerleave |
con trỏ rời nút | huỷ timer |
pointerdown (mọi loại) |
nhấn chuột / chạm ngón tay / bút | huỷ timer, preload ngay, cộng thêm preloadOnPress |
focus |
Tab tới nút | preload ngay |
keydown Enter/Space |
bàn phím "nhấn" nút | preload, cộng thêm preloadOnPress |
preloadOnPress là tuỳ chọn cho phần "đắt": hover chỉ tải phần rẻ, nhấn xuống mới tải phần còn lại, giống chế độ "button" của Facebook.
Hàm preload được giữ trong preloadRef (pattern "latest ref"). Các handler được useCallback nên ổn định qua các lần render, trong khi preload truyền vào có thể là arrow function mới mỗi lần. Ở đây cập nhật ref trong useEffect là đủ, vì ref chỉ được đọc trong event handler (luôn chạy sau commit), không đọc trong lúc render hay layout effect như ở bài #1. Từ React 19.2, useEffectEvent làm việc này gọn hơn.
#Bên trong: vì sao preload xong vẫn phải chờ 300ms?
Bản đầu tiên của mình chỉ là Object.assign(lazy(load), { preload: load }). Demo đo được: preload đã xong từ lâu mà sau khi click vẫn chờ đúng ~300ms. Muốn hiểu thì phải nhìn vào ba cơ chế bên trong React.
#1. React.lazy luôn suspend ở lần render đầu
lazy(load) trả về một object có _payload với trạng thái bắt đầu là Uninitialized (_status: -1). Lần render đầu tiên:
- React gọi
load()và nhận về một promise. - Gắn
.then()vào promise đó, chuyển trạng thái sang Pending (0). - Vì đang Pending nên React throw promise đó, tức là suspend.
Kể cả khi promise đã resolve từ trước (vì đã preload), callback của .then() vẫn chạy bất đồng bộ ở microtask sau. Tại thời điểm render, payload vẫn là Pending nên vẫn suspend. Chỉ sau microtask đó payload mới thành Resolved (1) và lần render sau mới thành công.
Preload qua lazy chỉ giúp file JS đã nằm sẵn, không giúp tránh được một vòng suspend.
#2. use(promise) đọc promise.status
use() của React 19 xử lý promise theo quy ước "thenable có trạng thái":
status === 'fulfilled'→ trả vềvaluengay, đồng bộ.status === 'rejected'→ throwreason(Error Boundary sẽ bắt).- Không có
status→ React tự gắnstatus = 'pending', đăng ký.thenđể cập nhật, rồi suspend.
Promise trả về từ fetch().then(...) không có field status. Vì vậy dù đã resolve, lần use() đầu tiên trên promise đó vẫn suspend. createResource tự gắn status/value ngay khi promise resolve, nên khi component render sau này, use() thấy 'fulfilled' và không suspend.
#3. Suspense throttle việc hiện lại nội dung
Khi một Suspense boundary vừa hiện fallback, React không vội thay nó bằng nội dung thật ngay khi dữ liệu về. Nó đợi để lần commit nội dung cách lần hiện fallback gần nhất ít nhất FALLBACK_THROTTLE_MS: 300ms ở React 19 (500ms ở React 18). Mục đích là tránh UI nháy liên tục kiểu spinner → nội dung → spinner khi nhiều thứ lần lượt về.
Gộp lại: suspend dù chỉ một microtask cũng đủ để fallback bị commit, và sau đó throttle bắt người dùng chờ thêm 300ms. Kết quả là ~300ms chờ vô ích cho thứ đã có sẵn.
Cách sửa nằm trong code ở trên: loaded cho component và status cho data. Sau khi sửa, thời gian từ click tới lúc hiện nội dung (đã preload) giảm từ ~300ms xuống ~1ms.
Tip
Quy tắc chung: đã có dữ liệu thì đừng bắt React phải suspend để biết điều đó. Thư viện data nào hỗ trợ Suspense tốt cũng có cơ chế đọc cache đồng bộ.
#Tại sao không làm cách khác?
Sao không preload ngay khi nút xuất hiện trong viewport? Đó là cách <Link> của Next.js làm với route. Nó hợp khi trang có ít link và chunk nhỏ. Một feed có 50 bài, mỗi bài có nút "Chia sẻ", "Báo cáo", "Xem ai đã thích" mà preload hết thì tốn băng thông cho những thứ 95% không ai bấm. Hover là tín hiệu "ý định" mạnh hơn nhiều.
Sao lại 50ms, không phải 0 hay 200ms? Bằng 0 thì chuột lướt ngang qua cũng bắn request. 200ms thì nuốt mất gần hết khoảng thời gian từ hover tới click mà ta đang muốn tận dụng. 50ms đủ để lọc chuyển động lướt qua mà vẫn còn nguyên phần lớn thời gian chờ. Facebook dùng đúng con số 50 cho cả delay hover lẫn "chuột dừng" trong CometMouseActivity.
Sao kiểm tra pointerType === 'mouse' cho pointerenter? Trên màn hình cảm ứng, một lần chạm tạo ra cả pointerenter ngay trước pointerdown. Nếu không lọc, ta sẽ đặt timer 50ms vô nghĩa rồi lại preload thêm lần nữa ở pointerdown. Tách bạch rõ ràng: chuột đi đường hover, cảm ứng và bút đi đường pointerdown. Khoảng từ pointerdown tới click khi chạm chỉ khoảng vài chục tới hơn trăm ms, ít hơn hover nhưng vẫn là thời gian miễn phí.
Note
Hook của Facebook chỉ xử lý pointerdown từ chuột. Preload ở pointerdown cho cả cảm ứng là phần mình thêm vào khi viết lại.
Sao không dùng onMouseEnter? Pointer Events gom chuột, cảm ứng và bút vào một API, kèm pointerType để phân biệt. Với mouse events, khi chạm trình duyệt còn bắn thêm các sự kiện chuột "giả lập" (compatibility mouse events) sau khi chạm, nên rất khó biết đâu là hover thật.
Sao không dùng onFocus cho chuột? Click chuột cũng làm nút nhận focus, nhưng lúc đó đã quá muộn. onFocus chỉ có ích cho người dùng bàn phím: họ Tab tới nút rồi mới nhấn Enter.
#Edge case và cạm bẫy
- Cache
createResourcelớn dần mãi. Map giữ mọi key từng được đọc. Một list 10.000 user mà hover qua hết thì có 10.000 promise nằm trong bộ nhớ, và dữ liệu cũng không bao giờ được làm mới. Bản production nên có TTL (hết hạn sau N phút) và/hoặc LRU (giữ tối đa N key). Đó là lý do nên dùng TanStack Query hay Relay thay vì tự viết: chúng cógcTime/staleTimesẵn. - SSR: cache dùng chung giữa các request.
createResourcetạo cache ở module scope. Trên server, module chỉ load một lần cho mọi request, nên user A có thể đọc được data đã cache của user B. Với data phụ thuộc người dùng, phải tạo resource theo request (qua context) hoặc chỉ dùng ở client.lazyWithPreloadthì không sao, vì code giống nhau cho mọi người. - Chunk lỗi sau khi deploy. Người dùng mở tab từ hôm qua, bạn deploy bản mới, chunk cũ bị xoá khỏi CDN,
import()sẽ 404. Nhánhpromise = nullcho phép thử lại, nhưng thử lại vẫn 404. Thường cần một Error Boundary hiện thông báo "Có bản cập nhật mới, tải lại trang". - StrictMode. Ở dev, effect chạy hai lần (mount → unmount → mount). Hook vẫn đúng vì cleanup
useEffect(() => cancel, [cancel])huỷ timer. Preload thì idempotent nên chạy hai lần cũng chỉ tạo một request. - Tiết kiệm dữ liệu. Trên Chromium có
navigator.connection.saveData(người dùng bật chế độ tiết kiệm dữ liệu) vàeffectiveType('2g','3g'…). Nên bỏ qua preload "đầu cơ" trong các trường hợp đó:
const connection = (navigator as any).connection;
const shouldPreload = !connection?.saveData && !/2g/.test(connection?.effectiveType ?? '');
ts
- Preload thứ gây side effect. Chỉ preload GET thuần (đọc dữ liệu, tải code). Không bao giờ "preload" một mutation, một request có ghi log "đã xem", hay một API tính phí theo lượt gọi.
#Demo
Dialog cần 1 chunk JS (giả lập 600ms) và 1 API (700ms). Cả hai nút đều tải code và data song song khi click. Nút thứ hai bắt đầu sớm hơn từ lúc hover. Rê chuột lên nút chừng nửa giây rồi mới bấm, rồi so sánh dòng "Hiện nội dung sau … ms". Bấm "Reset cache" để thử lại từ đầu.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Kiểm thử
Các phần không cần trình duyệt (lazyWithPreload, createResource) test được bằng Vitest thuần.
Ví dụ test (bản phác, không có trong repo)
preload.test.ts
import { describe, expect, it, vi } from 'vitest';
import { createResource, lazyWithPreload } from './preload';
describe('lazyWithPreload', () => {
it('chỉ gọi factory một lần dù preload nhiều lần', async () => {
const factory = vi.fn(async () => ({ default: () => null }));
const C = lazyWithPreload(factory);
await Promise.all([C.preload(), C.preload(), C.preload()]);
expect(factory).toHaveBeenCalledTimes(1);
});
it('cho thử lại sau khi lỗi', async () => {
const factory = vi
.fn()
.mockRejectedValueOnce(new Error('chunk 404'))
.mockResolvedValueOnce({ default: () => null });
const C = lazyWithPreload(factory);
await expect(C.preload()).rejects.toThrow('chunk 404');
await expect(C.preload()).resolves.toBeTruthy();
expect(factory).toHaveBeenCalledTimes(2);
});
});
describe('createResource', () => {
it('gắn status để use() đọc đồng bộ', async () => {
const user = createResource(async (id) => ({ id }));
const promise = user.read('42') as Promise<{ id: string }> & { status?: string };
expect(promise.status).toBe('pending');
await promise;
expect(promise.status).toBe('fulfilled');
expect(user.read('42')).toBe(promise); // cùng một promise
});
});
ts
Với usePreloadOnHover, test bằng Playwright trên trình duyệt thật là đáng tin nhất (page.hover(), rồi kiểm tra request đã được gửi bằng page.waitForRequest). jsdom hỗ trợ Pointer Events không đầy đủ.
#Dùng trong dự án
Kỹ thuật này hữu ích nhất cho:
- Dialog, drawer, popover nặng (rich text editor, picker, bảng chọn emoji…) đã tách chunk.
- Data cho trang chi tiết: hover vào một item trong list thì gọi
queryClient.prefetchQuery(...)(TanStack Query) với đúng query key mà trang chi tiết sẽ dùng.
const handlers = usePreloadOnHover(
() => void EmojiPicker.preload(), // rẻ: một chunk JS, tải khi hover
{
// đắt hơn: data, chỉ tải khi người dùng thực sự nhấn xuống
preloadOnPress: () =>
queryClient.prefetchQuery({ queryKey: ['emoji-recent', userId], queryFn: getRecentEmoji }),
},
);
<button {...handlers} onClick={open}>😀</button>;
tsx
Nếu nút đã có sẵn onFocus, onPointerDown hay onKeyDown riêng, đừng spread rồi ghi đè: spread {...handlers} sau props của bạn sẽ âm thầm thay handler của bạn, còn spread trước thì handler preload bị thay. Hãy gọi cả hai, ví dụ onFocus={(e) => { handlers.onFocus(); myOnFocus(e); }}.
So với những gì có sẵn trong hệ sinh thái:
- Next.js
<Link>ở bản production tự prefetch route khi link lọt vào viewport, và cũng prefetch khi hover. Hook này dùng cho những thứ không phải route: dialog, popover, tab. - React Router / Remix có
<Link prefetch="intent">, cùng ý tưởng: prefetch khi hover hoặc focus. - TanStack Query có
prefetchQuery, chỉ lo phần data. Bạn vẫn phải tự quyết định khi nào gọi nó, và hook trong bài trả lời câu hỏi đó.
Note
Đừng preload thứ quá nặng (video, file lớn) chỉ vì hover. Hover không đồng nghĩa với chắc chắn click. Preload hợp nhất với thứ nhỏ, cache được và gần như chắc chắn sẽ dùng.
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 (bài này)
- #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

