Học từ Facebook #78: setImmediate bằng postMessage, gom chuột theo frame
Facebook tự viết setImmediate bằng MessageChannel và postMessage để né mức kẹp 4ms của setTimeout, và gom mousemove thành một lần xử lý mỗi frame. Bản rút cạn hàng đợi gốc có thể bỏ đói trình duyệt.
Bạn muốn chạy một việc "ngay sau khi code hiện tại xong", nhưng không chạy luôn trong stack hiện tại. Cách quen tay là setTimeout(fn, 0). Vấn đề:
- Trình duyệt kẹp timer lồng nhau: từ lần lồng thứ 5 trở đi,
setTimeout(fn, 0)chờ tối thiểu 4ms (theo chuẩn HTML). Một chuỗi 300 việc gọi nối nhau mất hơn 1 giây chỉ để chờ. Promise.resolve().then(fn)thì quá nhanh: microtask chạy trước khi trình duyệt kịp vẽ hay xử lý input. Một chuỗi microtask dài làm đứng trang.
Cần một thứ ở giữa: một macrotask không bị kẹp 4ms. Đó là setImmediate, API mà chỉ IE và Node có. Facebook tự viết lại nó trong module ImmediateImplementation.
Cùng họ "đừng làm việc thừa trong vòng lặp sự kiện" là DOMMouseMoveTracker: chuột bắn mousemove 60 đến 1000 lần mỗi giây (chuột gaming), nhưng màn hình chỉ vẽ 60 đến 120 lần. Xử lý từng sự kiện là phí.
Tip
Tóm tắt
ImmediateImplementationxếp việc vào hàng đợi rồi tự đánh thức bằngMessageChannelhoặcwindow.postMessage: một macrotask, không bị kẹp 4ms. Khi đang ở trong một sự kiện người dùng thật (click,pointerup,submit…), nó ưu tiênpostMessage.- Bản gốc rút cạn hàng đợi, kể cả việc thêm vào trong lúc rút. Một chuỗi
setImmediateđệ quy vì vậy chạy hết trong một task duy nhất: trong thử nghiệm 20.000 bước, mộtsetTimeout(0)đặt trước không chạy được lần nào cho tới khi chuỗi xong. DOMMouseMoveTrackercộng dồn delta chuột, chỉ gọi handler một lần mỗi frame quarequestAnimationFrame, và xả phần còn lại khi thả chuột.- Bản viết lại chỉ rút những việc đã có trước lượt hiện tại, dùng Pointer Events + pointer capture; trình duyệt mới có sẵn
scheduler.postTaskcho vai trò tương tự.
▶ 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?
#ImmediateImplementation (bundle 22.js, 1.js; bản cũ không có nhánh thử nghiệm trong Scroll/TimeSlice.js)
Module là bản chuyển thể từ polyfill setImmediate quen thuộc (của YuzuJS), có thêm một nhánh thử nghiệm. Lõi là một danh sách liên kết và một vòng rút (đổi tên biến cho dễ đọc, bản đầy đủ trong spoiler):
while (!running && (task = head.next)) { // đi tới cuối danh sách, kể cả việc mới nối vào
head = task;
if ((cb = callbacks[task.handle])) { running = true; try { cb(); running = false; } finally { … } }
}
js
Code gốc đã đổi tên biến: hàng đợi và vòng rút
const callbacks = {}; // handle → callback
let head = {}, tail = head, running = false;
const token = 'setImmediate$' + Math.random() + '$';
function enqueue([fn, ...args]) {
callbacks[nextHandle] = () => fn(...args);
tail = tail.next = { handle: nextHandle++ }; // danh sách liên kết, head là nút đã chạy
return tail.handle;
}
function drain() {
let task, cb;
while (!running && (task = head.next)) { // chạy tới khi danh sách rỗng
head = task;
if ((cb = callbacks[task.handle])) {
running = true;
try { cb(); running = false; }
finally {
delete callbacks[task.handle];
if (running) { running = false; if (head.next) wake(drain); } // lỗi: hẹn lượt sau
}
}
}
}
function inTrustedUserEvent() {
const e = window.event;
return e && (e.isTrusted && ['change', 'click', 'contextmenu', 'dblclick', 'mouseup',
'pointerup', 'reset', 'submit', 'touchend'].includes(e.type)
|| e.type === 'message' && e.source === window && e.data.indexOf(token) === 0);
}
js
Bản trong Scroll/TimeSlice.js chỉ có phần polyfill: không có inTrustedUserEvent và cờ thử nghiệm. Bản trong 22.js và 1.js thì có. Phần đánh thức chọn đường theo thứ tự: postMessage (nếu trình duyệt gọi handler message bất đồng bộ) → MessageChannel → thẻ <script> với onreadystatechange (IE cũ) → setTimeout. Khi bật cờ thử nghiệm prefer_message_channel, nó dùng MessageChannel làm mặc định và chỉ quay sang postMessage (qua originalPostMessage nếu có) khi inTrustedUserEvent() đúng. Nhánh MessageChannel có cờ "đang bay", nên nhiều lần setImmediate liên tiếp chỉ gửi một message; nhánh postMessage gửi một message cho mỗi lần gọi (chuỗi token + handle), message đầu tiên rút hết, các message sau tới nơi thì thấy hàng đợi rỗng.
Vì sao ưu tiên postMessage khi đang trong một click thật? Code không giải thích. Có lẽ là để giữ "user activation": một số API (mở popup, vào fullscreen, phát âm thanh) chỉ cho phép khi đang trong cử chỉ người dùng. Các trình duyệt cũ coi việc chạy trong handler message của chính window gửi ngay sau click vẫn còn trong cử chỉ đó; MessageChannel có lẽ không được đối xử như vậy.
Khi message tới, drain đi theo head.next tới cuối danh sách: callback nào được setImmediate trong lúc rút cũng nối vào đuôi và được chạy luôn, trong cùng task. Đây là chỗ đáng chú ý (xem phần "Bên trong").
#DOMMouseMoveTracker (bundle 199.js)
Mỗi mousemove chỉ cộng dồn, việc thật để cho frame kế tiếp:
onMouseMove = (e) => {
this.dx += e.clientX - this.x; this.dy += e.clientY - this.y;
this.x = e.clientX; this.y = e.clientY;
this.frame ??= requestAnimationFramePolyfill(this.didMouseMove); // một lần onMove mỗi frame
};
js
onMouseUp gọi didMouseMove ngay nếu còn frame đang chờ, rồi mới onMoveEnd: phần dịch chuyển cuối không bị mất.
Code gốc đã đổi tên biến: DOMMouseMoveTracker
class DOMMouseMoveTracker {
captureMouseMoves(e) {
if (!this.moveListener) {
this.moveListener = listen(document, 'mousemove', this.onMouseMove);
this.upListener = listen(document, 'mouseup', this.onMouseUp);
}
this.isDragging = true; this.x = e.clientX; this.y = e.clientY; this.dx = this.dy = 0;
e.preventDefault();
}
onMouseMove = (e) => {
this.dx += e.clientX - this.x; this.dy += e.clientY - this.y; // chỉ cộng dồn
this.x = e.clientX; this.y = e.clientY;
this.frame ??= requestAnimationFramePolyfill(this.didMouseMove);
e.preventDefault();
};
didMouseMove = () => {
this.frame = null;
this.onMove(this.dx, this.dy); // một lần mỗi frame
this.dx = this.dy = 0;
};
onMouseUp = () => {
if (this.frame) this.didMouseMove(); // xả phần còn treo
this.onMoveEnd();
};
releaseMouseMoves() { /* gỡ listener, cancelAnimationFrame */ }
}
js
Nghe trên document để vẫn nhận sự kiện khi chuột chạy ra ngoài phần tử đang kéo (thanh cuộn tuỳ biến, cột bảng co giãn). Chỉ có chuột: không có touch hay bút.
sequenceDiagram participant M as Chuột participant T as Tracker participant R as rAF participant H as onMove M->>T: mousemove (dx += 3) T->>R: xin 1 frame M->>T: mousemove (dx += 4) M->>T: mousemove (dx += 2) R->>T: tới frame T->>H: onMove(9, ...) một lần M->>T: mousemove (dx += 1) M->>T: mouseup T->>H: xả onMove(1, ...)
#Bên trong: task, microtask và mức kẹp 4ms
Mỗi vòng của event loop: lấy một task (timer, message, sự kiện input) → chạy hết microtask → nếu tới lúc thì vẽ (chạy requestAnimationFrame, style, layout, paint). Có ba hệ quả:
setTimeout(fn, 0)không phải 0. Chuẩn HTML cho phép kẹp tối thiểu 4ms khi timer lồng quá 5 cấp; trình duyệt chính đều làm vậy. Tab ẩn còn bị kẹp 1 giây.- Message không bị kẹp.
MessageChannel.port.postMessagexếp một task mới ngay lập tức. React Scheduler cũng dùng đúng mẹo này. - Một task dài chặn tất cả. Trong lúc task chạy, không có vẽ, không có input. Nếu bộ rút hàng đợi chạy luôn những việc mới được thêm, một chuỗi
setImmediateđệ quy biến thành một task vô tận.
Thử nghiệm với Node (cùng logic, đổi MessageChannel của Node): đặt setTimeout(check, 0) rồi bắt đầu chuỗi 20.000 setImmediate nối nhau.
| Cách rút | Timer chạy khi chuỗi ở bước |
|---|---|
| Rút cạn kiểu Facebook | không chạy cho tới khi chuỗi xong |
| Chỉ rút việc có trước lượt (bản viết lại) | khoảng bước 1.000 |
Bản gốc có lẽ không gặp vấn đề vì code Facebook ít khi gọi setImmediate đệ quy dài; nhưng nếu bạn dùng nó để "chia nhỏ việc nặng cho khỏi đứng trang" thì nó không chia gì cả. Demo phần 1 cho thấy điều đó qua số frame vẽ được trong lúc chạy.
#Viết lại
Phần rút hàng đợi: chụp tail lúc bắt đầu, chỉ chạy tới đó.
src/lib/immediate.ts
function drain() {
wakeScheduled = false;
// Chỉ chạy những việc đã có TRƯỚC lượt này. Việc được thêm trong lúc chạy đợi lượt sau,
// nhờ vậy một chuỗi setImmediate gọi đệ quy vẫn nhường cho trình duyệt vẽ và xử lý input.
const end = tail;
let task: Task | null;
while (!running && cursor !== end && (task = cursor.next)) {
cursor = task;
const callback = callbacks.get(task.handle);
if (!callback) continue; // đã bị clearImmediate
callbacks.delete(task.handle);
running = true;
try {
callback();
running = false;
} finally {
if (running) {
// callback vừa ném lỗi: hẹn chạy tiếp phần còn lại rồi để lỗi bay ra ngoài
running = false;
if (cursor.next) wake();
}
}
}
if (!cursor.next) {
// hàng đợi rỗng: thu hồi danh sách để không giữ nút cũ trong bộ nhớ
head.next = null;
tail = cursor = head;
}
}
ts
Gom delta theo frame bằng Pointer Events:
src/lib/dragTracker.ts
const flush = () => {
frame = null;
if (dx === 0 && dy === 0) return;
stats.flushes++;
const x = dx;
const y = dy;
dx = dy = 0;
options.onMove(x, y);
};
const onMove = (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
stats.events++;
dx += event.clientX - lastX;
dy += event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
if (frame === null) frame = schedule(flush);
};
ts
#Đi qua code
const end = tailchụp đuôi danh sách lúc bắt đầu lượt. Vòngwhiledừng khicursortớiend, nên việc đượcsetImmediatetrong lúc chạy nằm sauendvà chờ lượt sau. Đó là toàn bộ khác biệt so với bản gốc, và là thứ làm chuỗi đệ quy nhường cho trình duyệt.runningchặn chạy lồng: nếu một callback tự gọi đồng bộ vào vòng rút (qua một đường lạ), vòng trong thoát ngay.- Callback ném lỗi thì
runningvẫn làtruekhi vàofinally. Đó là dấu hiệu để hẹn một lượt mới cho phần còn lại, rồi để lỗi bay ra ngoài chowindow.onerrorthấy. wake()(trong file đầy đủ) có cờwakeScheduled: 1.000 lầnsetImmediateliên tiếp chỉ gửi một message. Demo "Xếp 1000 việc cùng lúc" đếm đúng con số này.- Ở tracker,
flushđặtframe = nulltrước khi gọionMove, để nếuonMovechậm và cópointermovemới tới trong lúc đó, frame sau vẫn được xin.
Khác bản gốc:
- Chỉ rút những việc có trước lượt hiện tại; việc mới thêm chờ lượt sau, nên chuỗi đệ quy vẫn nhường cho trình duyệt vẽ.
- Xử lý lỗi giữ như bản gốc (hẹn lượt mới cho phần còn lại rồi để lỗi bay ra); thêm thu hồi danh sách khi rỗng và đếm số lần đánh thức.
- Bỏ nhánh
postMessage-trong-cử-chỉ và nhánh<script>của IE;postMessagecòn lại dùng token ngẫu nhiên và kiểm traevent.source. - Tracker dùng
pointermove+setPointerCapture(chuột, cảm ứng, bút), thêmcancel()vàpointercancelđể huỷ mà không xả delta.
Xem toàn bộ src/lib/immediate.ts
src/lib/immediate.ts
/**
* setImmediate tự viết (ý tưởng từ ImmediateImplementation của Facebook):
* - Hàng đợi FIFO bằng danh sách liên kết, gọi lại bằng handle.
* - Mỗi lần "đánh thức" chỉ gửi MỘT message; khi chạy thì làm hết các việc đã xếp hàng
* trước lượt đó trong cùng một task.
* - Callback ném lỗi: phần còn lại được hẹn lượt sau rồi lỗi mới được ném tiếp
* (không nuốt lỗi, không bỏ rơi các việc đang xếp hàng).
*/
export type ImmediateTransport = 'message-channel' | 'post-message' | 'timeout';
type Task = { handle: number; next: Task | null };
export interface Immediate {
setImmediate(fn: (...args: unknown[]) => void, ...args: unknown[]): number;
clearImmediate(handle: number): void;
readonly transport: ImmediateTransport;
/** số lần thực sự gửi tín hiệu đánh thức (message/timer) */
readonly wakes: number;
dispose(): void;
}
export function createImmediate(preferred?: ImmediateTransport): Immediate {
const callbacks = new Map<number, () => void>();
let nextHandle = 1;
const head: Task = { handle: 0, next: null }; // nút giả: head.next là việc kế tiếp
let tail = head;
let cursor = head;
let running = false;
let wakeScheduled = false;
let wakes = 0;
function drain() {
wakeScheduled = false;
// Chỉ chạy những việc đã có TRƯỚC lượt này. Việc được thêm trong lúc chạy đợi lượt sau,
// nhờ vậy một chuỗi setImmediate gọi đệ quy vẫn nhường cho trình duyệt vẽ và xử lý input.
const end = tail;
let task: Task | null;
while (!running && cursor !== end && (task = cursor.next)) {
cursor = task;
const callback = callbacks.get(task.handle);
if (!callback) continue; // đã bị clearImmediate
callbacks.delete(task.handle);
running = true;
try {
callback();
running = false;
} finally {
if (running) {
// callback vừa ném lỗi: hẹn chạy tiếp phần còn lại rồi để lỗi bay ra ngoài
running = false;
if (cursor.next) wake();
}
}
}
if (!cursor.next) {
// hàng đợi rỗng: thu hồi danh sách để không giữ nút cũ trong bộ nhớ
head.next = null;
tail = cursor = head;
}
}
const transport: ImmediateTransport =
preferred ??
(typeof MessageChannel !== 'undefined'
? 'message-channel'
: typeof window !== 'undefined' && typeof window.postMessage === 'function'
? 'post-message'
: 'timeout');
let wakeRaw: () => void;
let dispose = () => {};
if (transport === 'message-channel') {
const channel = new MessageChannel();
channel.port1.onmessage = drain;
wakeRaw = () => channel.port2.postMessage(null);
dispose = () => {
channel.port1.onmessage = null;
channel.port1.close();
};
} else if (transport === 'post-message') {
// token ngẫu nhiên để không nhầm với message của script khác trên trang
const token = `immediate$${Math.random().toString(36).slice(2)}`;
const onMessage = (event: MessageEvent) => {
if (event.source === window && event.data === token) drain();
};
window.addEventListener('message', onMessage);
wakeRaw = () => window.postMessage(token, '*');
dispose = () => window.removeEventListener('message', onMessage);
} else {
wakeRaw = () => setTimeout(drain, 0);
}
function wake() {
if (wakeScheduled) return; // đã có một lần đánh thức đang bay: các việc mới đi chung chuyến
wakeScheduled = true;
wakes++;
wakeRaw();
}
return {
transport,
get wakes() {
return wakes;
},
setImmediate(fn, ...args) {
const handle = nextHandle++;
callbacks.set(handle, () => fn(...args));
tail = tail.next = { handle, next: null };
wake();
return handle;
},
clearImmediate(handle) {
callbacks.delete(handle);
},
dispose,
};
}
ts
Xem toàn bộ src/lib/dragTracker.ts
src/lib/dragTracker.ts
/**
* Kéo bằng con trỏ, gom delta theo khung hình (ý tưởng từ DOMMouseMoveTracker của Facebook):
* mỗi pointermove chỉ CỘNG DỒN dx/dy, handler thật chạy tối đa một lần mỗi frame với tổng delta.
* Khác bản gốc: dùng Pointer Events + pointer capture (chuột, cảm ứng, bút), xả delta còn treo
* khi thả, và có cancel() để huỷ giữa chừng.
*/
export interface DragTrackerOptions {
/** gọi tối đa 1 lần/frame với tổng dịch chuyển kể từ lần gọi trước */
onMove: (dx: number, dy: number) => void;
onEnd?: (cancelled: boolean) => void;
/** đổi để test: mặc định requestAnimationFrame */
schedule?: (cb: () => void) => number;
cancelSchedule?: (id: number) => void;
}
export interface DragTracker {
start(event: PointerEvent): void;
cancel(): void;
readonly dragging: boolean;
/** số pointermove đã nhận và số lần onMove thực sự chạy, để so sánh */
readonly stats: { events: number; flushes: number };
}
export function createDragTracker(target: HTMLElement, options: DragTrackerOptions): DragTracker {
const schedule = options.schedule ?? ((cb) => requestAnimationFrame(cb));
const cancelSchedule = options.cancelSchedule ?? ((id) => cancelAnimationFrame(id));
let dragging = false;
let pointerId = -1;
let lastX = 0;
let lastY = 0;
let dx = 0;
let dy = 0;
let frame: number | null = null;
const stats = { events: 0, flushes: 0 };
const flush = () => {
frame = null;
if (dx === 0 && dy === 0) return;
stats.flushes++;
const x = dx;
const y = dy;
dx = dy = 0;
options.onMove(x, y);
};
const onMove = (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
stats.events++;
dx += event.clientX - lastX;
dy += event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
if (frame === null) frame = schedule(flush);
};
const stop = (cancelled: boolean) => {
if (!dragging) return;
if (frame !== null) cancelSchedule(frame);
if (!cancelled) flush(); // xả delta cuối, không để "hụt" vài pixel khi thả
frame = null;
dx = dy = 0;
dragging = false;
target.removeEventListener('pointermove', onMove);
target.removeEventListener('pointerup', onUp);
target.removeEventListener('pointercancel', onCancel);
if (target.hasPointerCapture?.(pointerId)) target.releasePointerCapture(pointerId);
options.onEnd?.(cancelled);
};
const onUp = (event: PointerEvent) => event.pointerId === pointerId && stop(false);
const onCancel = (event: PointerEvent) => event.pointerId === pointerId && stop(true);
return {
get dragging() {
return dragging;
},
stats,
start(event) {
if (dragging) return;
dragging = true;
pointerId = event.pointerId;
lastX = event.clientX;
lastY = event.clientY;
dx = dy = 0;
// capture: vẫn nhận move/up khi con trỏ ra khỏi phần tử (thay cho nghe trên document)
target.setPointerCapture?.(pointerId);
target.addEventListener('pointermove', onMove);
target.addEventListener('pointerup', onUp);
target.addEventListener('pointercancel', onCancel);
event.preventDefault();
},
cancel: () => stop(true),
};
}
ts
#Tại sao không làm cách khác?
queueMicrotask/Promise.then: nhanh nhất, nhưng microtask chạy hết trước khi trình duyệt làm bất cứ gì khác. Dùng để "chia nhỏ việc nặng" là sai chỗ.requestAnimationFrame: chỉ 60 đến 120 lần/giây, và dừng hẳn khi tab ẩn. Hợp cho việc gắn với vẽ (như tracker), không hợp cho hàng đợi việc chung.requestIdleCallback: chỉ chạy khi rảnh, có thể chờ rất lâu; Safari chưa bật mặc định (tính tới 10/2026).scheduler.postTask: đúng thứ cần (macrotask có độ ưu tiên, huỷ được bằngAbortSignal), nhưng chưa có ở Safari (tính tới 10/2026). Hàm tự viết vẫn cần làm fallback.- Xử lý từng
pointermove: đúng nhưng phí; với chuột 1000Hz và handler 6ms, trang không theo kịp. Demo phần 2 đếm số lần xử lý của hai cách.
#Edge case và cạm bẫy
window.postMessagedùng chung với mọi script: iframe, extension cũng nghemessage. Dùng token ngẫu nhiên, kiểm traevent.source === window, và đừngstopPropagationmessage không phải của mình.MessageChannelriêng tư hơn.- Tab ẩn: message không bị kẹp như timer, nên hàng đợi vẫn chạy khi tab ẩn. Tốt cho việc phải xong, nhưng tốn pin nếu là vòng lặp vô tận.
- Delta cuối khi thả: nếu chỉ huỷ rAF lúc
pointerup, phần dịch chuyển cuối bị mất, đối tượng "hụt" vài pixel. Phải xả trước khi kết thúc (bản gốc làm đúng). - Pointer capture thay cho nghe trên
document: capture giữ sự kiện cả khi con trỏ ra ngoài iframe hay cửa sổ, và tự giải phóng khipointerup. Nhớtouch-action: nonetrên phần tử kéo, nếu không trình duyệt cuộn trang và bắnpointercancel. getCoalescedEvents(): trình duyệt đã gộp sẵn nhiềupointermovethành một sự kiện mỗi frame. Nếu cần vẽ nét bút mượt, lấy các điểm gộp từ đây; nếu chỉ cần delta tổng thì như tracker là đủ.
#Dùng trong dự án
import { createImmediate } from './immediate';
const imm = createImmediate();
// chia việc nặng thành nhiều lượt, trình duyệt vẫn vẽ giữa các lượt
function processInChunks<T>(items: T[], work: (item: T) => void, chunk = 200) {
let i = 0;
const step = () => {
const end = Math.min(i + chunk, items.length);
for (; i < end; i++) work(items[i]);
if (i < items.length) imm.setImmediate(step);
};
imm.setImmediate(step);
}
ts
Tracker dùng cho tay nắm co giãn cột: createDragTracker(handle, { onMove: (dx) => column.setWidth(column.width + dx), onEnd: (cancelled) => !cancelled && column.save() }), rồi gọi tracker.start(e) trong pointerdown.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { createImmediate } from './immediate';
import { createDragTracker } from './dragTracker';
describe('createImmediate', () => {
it('nhiều việc cùng lúc chỉ gửi một tín hiệu', async () => {
const imm = createImmediate();
const seen: number[] = [];
for (let i = 0; i < 100; i++) imm.setImmediate(() => seen.push(i));
await new Promise((r) => setTimeout(r, 10));
expect(seen).toHaveLength(100);
expect(imm.wakes).toBe(1);
});
it('chuỗi đệ quy nhường cho timer', async () => {
const imm = createImmediate();
let steps = 0, timerAt = -1;
setTimeout(() => (timerAt = steps), 0);
await new Promise<void>((done) => {
const step = () => (++steps < 5000 ? imm.setImmediate(step) : done());
imm.setImmediate(step);
});
expect(timerAt).toBeGreaterThan(0);
expect(timerAt).toBeLessThan(5000);
});
it('clearImmediate bỏ việc chưa chạy', async () => {
const imm = createImmediate();
const fn = vi.fn();
imm.clearImmediate(imm.setImmediate(fn));
await new Promise((r) => setTimeout(r, 10));
expect(fn).not.toHaveBeenCalled();
});
});
describe('createDragTracker', () => {
it('cộng dồn delta, gọi một lần mỗi frame, xả khi thả', () => {
const el = document.createElement('div');
let frame: (() => void) | null = null;
const onMove = vi.fn();
const t = createDragTracker(el, { onMove, schedule: (cb) => ((frame = cb), 1), cancelSchedule: () => {} });
const ev = (type: string, x: number) => new PointerEvent(type, { pointerId: 1, clientX: x, clientY: 0 });
t.start(ev('pointerdown', 0));
el.dispatchEvent(ev('pointermove', 3));
el.dispatchEvent(ev('pointermove', 7));
frame!();
expect(onMove).toHaveBeenLastCalledWith(7, 0);
el.dispatchEvent(ev('pointermove', 9));
el.dispatchEvent(ev('pointerup', 9));
expect(onMove).toHaveBeenLastCalledWith(2, 0);
expect(onMove).toHaveBeenCalledTimes(2);
});
});
ts
#So với thư viện và chuẩn web
scheduler.postTask(fn, { priority })vàscheduler.yield(): có ở Chrome và Firefox, chưa có ở Safari (tính tới 10/2026).scheduler.yield()còn giữ ưu tiên cho phần tiếp theo, tốt hơn mọi mẹo message.- React Scheduler: dùng
MessageChannelđể đánh thức, và tự cắt lượt sau khoảng 5ms để nhường trình duyệt. Đó là cách giải đúng vấn đề "rút cạn". - Polyfill
setimmediate(YuzuJS): tổ tiên của bản Facebook, cùng thứ tự fallback; cũng rút cạn theo handle. PointerEvent.getCoalescedEvents()có ở mọi trình duyệt chính;getPredictedEvents()chưa có ở Safari (tính tới 10/2026).- Thư viện kéo thả (dnd-kit,
@use-gesture) đều gom theo rAF tương tự tracker.
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
- #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 (bài này)
- #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

