Học từ Facebook #68: Memo nhiều đối số bằng Map/WeakMap và interning object
MultiKeyMap của Facebook là trie lai Map/WeakMap để memo hàm nhiều đối số, LoadObject intern trạng thái rỗng, LoadObjectMap gom request theo lô.
useMemo và React.memo chỉ có tác dụng khi so sánh === cho kết quả đúng. Hai object có cùng nội dung nhưng tạo ở hai chỗ khác nhau thì vẫn khác nhau, nên component vẫn render lại. Ngược lại, nếu cache giữ object quá lâu thì bộ nhớ cứ phình ra.
Trong bundle của Facebook có ba module nhỏ cùng xoay quanh chuyện này. MultiKeyMap là bảng tra theo nhiều khoá, nhả object khi object chết. LoadObject đảm bảo mọi trạng thái "đang tải" là cùng một object. LoadObjectMap gom các lần xin dữ liệu trong một tick thành một request.
Tip
Tóm tắt
MultiKeyMaplà một trie: mỗi tầng ứng với một đối số. Khoá nguyên thuỷ (string, number…) nằm trongMap, khoá là object nằm trongWeakMap. Object bị GC thì cả nhánh cache của nó rụng theo.- Bản gốc có hai điểm yếu:
deleteđể lại các nút rỗng trongMapmạnh, và khoábiginthaySymbol.for(...)bị đưa vàoWeakMapnên ném lỗi. LoadObjectintern các trạng thái mà giá trị thuộc tậpundefined, null, false, true, 0, ''. Tạo "loading" 300 lần vẫn chỉ có một object, nênReact.memobỏ qua render.LoadObjectMapxếp khoá vào hàng đợi,setTimeout(0)rồi gọi loader theo lô có giới hạn kích thước, giống DataLoader.
▶ 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?
#MultiKeyMap: trie lai Map và WeakMap
Module MultiKeyMap nằm trong 25.js (một bản khác được đóng gói kèm StyleX). Mỗi nút trie là một object có hai bảng:
c.$2 = function () {
return { type: "map", cache: new Map(), weakCache: h ? new WeakMap() : new Map() };
};
// chọn bảng theo kiểu khoá
c.$5 = function (node, key) { return i(key) ? node.cache : node.weakCache; };
function i(a) { var b = typeof a; return b === "number" || b === "string" || b === "boolean" || a == null; }
js
set([a, b, c], v) đi xuống từng tầng theo a rồi b, tạo nút mới khi thiếu, và đặt lá {type: "value", value: v} ở tầng cuối dưới khoá c. Số khoá bị khoá cứng sau lần gọi đầu: gọi với số khoá khác sẽ ném "MultiKeyMap called with different number of keys".
Facebook dùng nó làm nền cho memoizeWithArgsWeak: memo một hàm nhận (viewer, story, options) mà không cần tự nghĩ ra khoá chuỗi, và khi story không còn ai giữ thì kết quả memo của nó cũng được thu hồi.
#LoadObject: intern trạng thái rỗng
LoadObject (trong 89.js) là một immutable.Record với bốn trường operation, value, error, internalHasValue. Constructor bị khoá bằng một chuỗi bí mật để không ai new trực tiếp:
a = [void 0, null, !1, !0, 0, ""];
var i = "SECRET_" + Math.random(),
j = new Map(a.map(v => [v, new Map([[!0, new Map()], [!1, new Map()]])]));
js
j là bảng ba tầng: giá trị → hasValue → operation → instance. Hàm $LoadObject2(operation, value, error, hasValue) trả về instance có sẵn trong bảng nếu không có lỗi và giá trị thuộc tập trên. Mọi phép biến đổi (setOperation, setValue, removeError…) đều thử tra bảng trước khi tạo object mới.
Kết quả: LoadObject.loading() gọi ở hai nơi khác nhau trả về cùng một object. Một danh sách 1.000 dòng đang tải không tạo ra 1.000 record, và prev === next đúng nên React bỏ qua render.
#LoadObjectMap: gom request trong một tick
LoadObjectMap (trong 99.js) giữ một immutable.Map từ khoá tới LoadObject. Hàm get(key) trả ngay trạng thái hiện có, và nếu cần tải thì xếp khoá vào hàng đợi:
c.add(a); // OrderedSet theo tên hàng đợi
this.$8.add(f); // đánh dấu "đã xếp hàng"
this.$9 === null && (this.$9 = window.setTimeout(function () {
d.$7.forEach(function (queue, name) { queue.size > 0 && d.$12(queue, name); });
/* … */
}, 0));
js
$12 cắt hàng đợi thành từng khúc theo __getChunkSize rồi gọi loader. Một lớp con có thể ghi đè __getChunkSize để đặt kích thước lô riêng. Ngoài ra setKeyStale(key) trả về một map mới với khoá đó bị đánh dấu cũ, lần get sau sẽ tải lại.
#Bên trong: ba lỗi nhỏ
flowchart LR R["root: Map + WeakMap"] -->|"user:1"| A["nút user:1"] A -->|"page 0"| B["nút page 0"] B -->|"vi_VN"| L["lá: value"] D["delete user:1, 0, vi_VN"] -.->|"chỉ gỡ lá"| L B -.->|"còn lại, rỗng"| X["rò bộ nhớ"]
- Nút rỗng không được dọn.
deletechỉ gỡ lá ở tầng cuối. Các nút trung gian vẫn nằm trongcache(Map mạnh) của cha. Thêm rồi xoá 2.000 bộ khoá(user, page, locale)thì trie vẫn giữ 2.401 nút, đúng như con số ở phần 1 của demo. - Khoá
bigintvàSymbol.forném lỗi. Hàmichỉ coi number/string/boolean/null/undefined là nguyên thuỷ. Mọi thứ khác vàoWeakMap, mànew WeakMap().set(1n, 1)némTypeError: Invalid value used as weak map key. Symbol đã đăng ký (Symbol.for) cũng vậy. return etrongforEachở nhánh$10củaLoadObjectMap.getchỉ thoát callback, không thoátget. Có lẽ không gây sai kết quả vì cuối hàm cũngreturn e, nhưng các hàng đợi khác vẫn tiếp tục được kiểm tra.
#Viết lại
Ba file trong demo: src/lib/multiKeyMap.ts cho trie, src/lib/loadState.ts cho interning và bộ gom request.
Phần chọn bảng theo kiểu khoá, có đếm số con để biết khi nào nút rỗng (WeakMap không có size):
src/lib/multiKeyMap.ts
const isPrimitive = (key: unknown): boolean =>
key === null || (typeof key !== 'object' && typeof key !== 'function');
const newNode = <V>(): Node<V> => ({ kind: 'node', strong: new Map(), weak: new WeakMap(), size: 0 });
function child<V>(node: Node<V>, key: unknown) {
return isPrimitive(key) ? node.strong.get(key) : node.weak.get(key as object);
}
function setChild<V>(node: Node<V>, key: unknown, value: Node<V> | Leaf<V>) {
const had = child(node, key) !== undefined;
if (isPrimitive(key)) node.strong.set(key, value);
else node.weak.set(key as object, value);
if (!had) node.size++;
}
function deleteChild<V>(node: Node<V>, key: unknown) {
const had = isPrimitive(key) ? node.strong.delete(key) : node.weak.delete(key as object);
if (had) node.size--;
return had;
}
ts
Phần delete có tỉa nút rỗng ngược về gốc:
src/lib/multiKeyMap.ts
delete(keys: K): boolean {
this.check(keys);
const path: Node<V>[] = [];
let node: Node<V> | Leaf<V> | undefined = this.root;
for (let i = 0; i < keys.length - 1; i++) {
if (node === undefined || node.kind !== 'node') return false;
path.push(node);
node = child(node, keys[i]);
}
if (node === undefined || node.kind !== 'node') return false;
if (!deleteChild(node, keys[keys.length - 1])) return false;
// tỉa ngược: nút nào hết con thì gỡ khỏi cha
for (let i = path.length - 1; i >= 0 && node.size === 0; i--) {
deleteChild(path[i], keys[i]);
node = path[i];
}
return true;
}
ts
Xem toàn bộ src/lib/multiKeyMap.ts
src/lib/multiKeyMap.ts
/**
* Map nhiều khoá dạng trie, lai Map/WeakMap (ý tưởng từ module MultiKeyMap của Facebook).
*
* Mỗi nút trie có hai bảng con:
* - `strong`: Map cho khoá nguyên thuỷ (string, number, boolean, null, undefined, symbol, bigint)
* - `weak`: WeakMap cho khoá là object/hàm, để object chết thì nhánh của nó được GC thu hồi
*
* Khác bản gốc: `delete` tỉa các nút rỗng trên đường về gốc (bản gốc để lại nút rỗng mãi
* trong các Map mạnh), và symbol được coi là khoá nguyên thuỷ (WeakMap chỉ nhận một số symbol).
*/
type Leaf<V> = { kind: 'value'; value: V };
interface Node<V> {
kind: 'node';
strong: Map<unknown, Node<V> | Leaf<V>>;
weak: WeakMap<object, Node<V> | Leaf<V>>;
/** số con trong `strong` + `weak` (WeakMap không đếm được nên tự đếm) */
size: number;
}
const isPrimitive = (key: unknown): boolean =>
key === null || (typeof key !== 'object' && typeof key !== 'function');
const newNode = <V>(): Node<V> => ({ kind: 'node', strong: new Map(), weak: new WeakMap(), size: 0 });
function child<V>(node: Node<V>, key: unknown) {
return isPrimitive(key) ? node.strong.get(key) : node.weak.get(key as object);
}
function setChild<V>(node: Node<V>, key: unknown, value: Node<V> | Leaf<V>) {
const had = child(node, key) !== undefined;
if (isPrimitive(key)) node.strong.set(key, value);
else node.weak.set(key as object, value);
if (!had) node.size++;
}
function deleteChild<V>(node: Node<V>, key: unknown) {
const had = isPrimitive(key) ? node.strong.delete(key) : node.weak.delete(key as object);
if (had) node.size--;
return had;
}
export class MultiKeyMap<K extends readonly unknown[], V> {
private root: Node<V> = newNode();
private arity: number | null = null;
private check(keys: K) {
if (keys.length === 0) throw new Error('MultiKeyMap: cần ít nhất một khoá');
this.arity ??= keys.length;
if (keys.length !== this.arity) {
throw new Error(`MultiKeyMap: luôn phải dùng ${this.arity} khoá, nhận ${keys.length}`);
}
}
set(keys: K, value: V): this {
this.check(keys);
let node = this.root;
for (let i = 0; i < keys.length - 1; i++) {
let next = child(node, keys[i]);
if (next === undefined) {
next = newNode<V>();
setChild(node, keys[i], next);
}
node = next as Node<V>;
}
setChild(node, keys[keys.length - 1], { kind: 'value', value });
return this;
}
get(keys: K): V | undefined {
this.check(keys);
let node: Node<V> | Leaf<V> | undefined = this.root;
for (const key of keys) {
if (node === undefined || node.kind !== 'node') return undefined;
node = child(node, key);
}
return node?.kind === 'value' ? node.value : undefined;
}
has(keys: K): boolean {
this.check(keys);
let node: Node<V> | Leaf<V> | undefined = this.root;
for (const key of keys) {
if (node === undefined || node.kind !== 'node') return false;
node = child(node, key);
}
return node?.kind === 'value';
}
delete(keys: K): boolean {
this.check(keys);
const path: Node<V>[] = [];
let node: Node<V> | Leaf<V> | undefined = this.root;
for (let i = 0; i < keys.length - 1; i++) {
if (node === undefined || node.kind !== 'node') return false;
path.push(node);
node = child(node, keys[i]);
}
if (node === undefined || node.kind !== 'node') return false;
if (!deleteChild(node, keys[keys.length - 1])) return false;
// tỉa ngược: nút nào hết con thì gỡ khỏi cha
for (let i = path.length - 1; i >= 0 && node.size === 0; i--) {
deleteChild(path[i], keys[i]);
node = path[i];
}
return true;
}
/** Số nút trie đang được giữ bằng tham chiếu mạnh (để đo rò bộ nhớ trong demo). */
countStrongNodes(): number {
const walk = (node: Node<V>): number => {
let n = 1;
node.strong.forEach((c) => {
if (c.kind === 'node') n += walk(c);
});
return n;
};
return walk(this.root);
}
}
export interface MemoStats {
hits: number;
misses: number;
}
/**
* Memo hoá hàm nhiều đối số. Đối số là object thì cache tự rụng khi object bị GC.
* (Facebook dựng `memoizeWithArgsWeak` trên MultiKeyMap theo đúng cách này.)
*/
export function memoizeWithArgs<A extends readonly unknown[], R>(fn: (...args: A) => R) {
const cache = new MultiKeyMap<A, R>();
const stats: MemoStats = { hits: 0, misses: 0 };
const memoized = (...args: A): R => {
// hàm không đối số vẫn cần một khoá
const keys = (args.length === 0 ? [undefined] : args) as unknown as A;
if (cache.has(keys)) {
stats.hits++;
return cache.get(keys) as R;
}
stats.misses++;
const result = fn(...args);
cache.set(keys, result);
return result;
};
return Object.assign(memoized, { stats, cache });
}
ts
Interning dùng một khoá chuỗi cho bộ (operation, hasValue, kiểu, giá trị) thay vì Map ba tầng như bản gốc, và bộ gom request nằm cùng file:
Xem toàn bộ src/lib/loadState.ts
src/lib/loadState.ts
/**
* Trạng thái tải bất biến có "interning", và bộ gom request kiểu DataLoader
* (ý tưởng từ LoadObject + LoadObjectMap của Facebook).
*
* Interning: các trạng thái "rỗng phổ biến" (đang tải, chưa có gì, value = null/0/''…)
* luôn trả về CÙNG một object. Nhờ vậy `prev === next` đúng, React.memo bỏ qua render,
* và 10.000 ô đang "loading" chỉ tốn một object thay vì 10.000.
*/
export type Operation = 'loading' | 'creating' | 'updating' | 'deleting' | undefined;
export interface LoadState<T> {
readonly operation: Operation;
readonly value: T | undefined;
readonly hasValue: boolean;
readonly error: unknown;
}
// Facebook intern cho đúng tập này: undefined, null, false, true, 0, ''
const INTERNABLE = new Set<unknown>([undefined, null, false, true, 0, '']);
const pool = new Map<string, LoadState<never>>();
function make<T>(operation: Operation, value: T | undefined, hasValue: boolean, error: unknown): LoadState<T> {
if (error === undefined && INTERNABLE.has(value)) {
// khoá chuỗi phân biệt được 0/''/false/null/undefined nhờ typeof
const key = `${operation}|${hasValue}|${typeof value}|${String(value)}`;
let shared = pool.get(key);
if (!shared) {
shared = Object.freeze({ operation, value, hasValue, error }) as LoadState<never>;
pool.set(key, shared);
}
return shared as LoadState<T>;
}
return Object.freeze({ operation, value, hasValue, error });
}
export const LoadState = {
empty: <T>() => make<T>(undefined, undefined, false, undefined),
loading: <T>() => make<T>('loading', undefined, false, undefined),
withValue: <T>(value: T) => make<T>(undefined, value, true, undefined),
withError: <T>(error: unknown) => make<T>(undefined, undefined, false, error),
setOperation: <T>(s: LoadState<T>, op: Operation) => make(op, s.value, s.hasValue, s.error),
done: <T>(s: LoadState<T>) => make(undefined, s.value, s.hasValue, s.error),
/** số object đang nằm trong pool (để demo) */
poolSize: () => pool.size,
};
// ---------------- bộ gom request ----------------
export interface BatchLoaderOptions<K, V> {
/** tải một lô khoá, trả về Map khoá → giá trị (khoá thiếu = lỗi "not found") */
loadMany: (keys: K[]) => Promise<Map<K, V>>;
/** tối đa bao nhiêu khoá một request, mặc định không giới hạn */
maxBatchSize?: number;
/** 'microtask' gom trong cùng tick (DataLoader); 'timeout' dùng setTimeout(0) như Facebook */
schedule?: 'microtask' | 'timeout';
/** đổi khoá thành chuỗi để chống trùng, mặc định String(key) */
cacheKey?: (key: K) => string;
}
export function createBatchLoader<K, V>({
loadMany,
maxBatchSize = Infinity,
schedule = 'microtask',
cacheKey = (k) => String(k),
}: BatchLoaderOptions<K, V>) {
const cache = new Map<string, Promise<V>>();
let queue: Array<{ key: K; resolve: (v: V) => void; reject: (e: unknown) => void }> = [];
let scheduled = false;
let requests = 0;
const flush = () => {
scheduled = false;
const batch = queue;
queue = [];
for (let i = 0; i < batch.length; i += maxBatchSize) {
const chunk = batch.slice(i, i + maxBatchSize);
requests++;
loadMany(chunk.map((item) => item.key)).then(
(result) => {
for (const item of chunk) {
if (result.has(item.key)) item.resolve(result.get(item.key) as V);
else item.reject(new Error(`not found: ${String(item.key)}`));
}
},
(error) => {
for (const item of chunk) {
cache.delete(cacheKey(item.key)); // lỗi mạng: lần sau được thử lại
item.reject(error);
}
},
);
}
};
return {
load(key: K): Promise<V> {
const id = cacheKey(key);
const hit = cache.get(id);
if (hit) return hit; // chống trùng: cùng khoá trong cùng lô (hoặc đã tải) dùng chung promise
const promise = new Promise<V>((resolve, reject) => queue.push({ key, resolve, reject }));
cache.set(id, promise);
if (!scheduled) {
scheduled = true;
if (schedule === 'microtask') queueMicrotask(flush);
else setTimeout(flush, 0);
}
return promise;
},
/** đánh dấu cũ: lần load sau sẽ gọi mạng lại (setKeyStale của Facebook) */
markStale(key: K) {
cache.delete(cacheKey(key));
},
get requestCount() {
return requests;
},
};
}
ts
Khác bản gốc:
deletenhớ đường đi rồi tỉa ngược: nút nào hết con thì gỡ khỏi cha. Mỗi nút tự đếmsizevìWeakMapkhông đếm được.- Mọi giá trị không phải object/hàm (kể cả
symbol,bigint) đều vàoMapmạnh, nên không bao giờ ném lỗi khoáWeakMap. - Có thêm
memoizeWithArgs(fn)trả kèmstats(hit/miss), và hàm không đối số vẫn có khoá[undefined]. LoadStatelà object thường bịObject.freeze, không phụ thuộc Immutable.js. Khoá interning cótypeofđể0,'',falsekhông trùng nhau khi đổi ra chuỗi.- Bộ gom mặc định dùng
queueMicrotask(như DataLoader) thay vìsetTimeout(0), chống trùng bằng cache promise, và xoá cache khi lỗi mạng để lần sau được thử lại.
#Đi qua code
isPrimitivetrảtruechonullvà mọi thứ không phảiobject/function. Chỉ object/hàm mới đi vàoWeakMap, đúng loại khoá màWeakMapchắc chắn nhận.setChildchỉ tăngsizekhi khoá chưa có. Ghi đè một lá không làm sai bộ đếm.deleteđi xuống tới tầng cha của lá, lưu từng nút vàopath. Sau khi gỡ lá, vòng lặp đi ngược: nếunode.size === 0thì gỡkeys[i]khỏipath[i], rồi lên một tầng. Gặp nút còn con thì dừng.maketrongloadState.tschỉ intern khierror === undefinedvà giá trị thuộc tậpINTERNABLE. Giá trị là object (danh sách bạn bè, bài viết…) thì luôn tạo object mới, vì so sánh nội dung tốn hơn lợi ích.createBatchLoader.loadtra cache promise trước. Lần đầu thấy khoá thì tạo promise, đẩy vào hàng đợi và lên lịchflushmột lần.flushcắt hàng đợi theomaxBatchSize, mỗi khúc một lầnloadMany.
#Tại sao không làm cách khác?
- Nối khoá thành chuỗi (
JSON.stringify(args)): không dùng được với object không serialize được, hai object khác nhau có cùng nội dung bị trùng khoá, và cache giữ kết quả mãi vì chuỗi không bao giờ bị GC "theo" object. - Một
WeakMaplồngWeakMap: không nhận khoá nguyên thuỷ. Phần lớn đối số thực tế là ID chuỗi hoặc số, nên vẫn cầnMap. - So sánh nông (
shallowEqual) trongReact.memothay cho interning: chạy được nhưng tốn công so sánh mỗi lần render, và không giảm số object trong bộ nhớ. Interning làm phép so sánh thành===, không tốn gì. - Gom bằng
requestAnimationFrame: trễ tới 16 ms và không chạy khi tab ẩn.setTimeout(0)hoặc microtask là đủ để gom các component mount trong cùng một lượt render.
#Edge case và cạm bẫy
- Khoá nguyên thuỷ vẫn giữ mạnh. Memo theo
(userId: string, ...)thì cache không bao giờ tự rụng. Cần giới hạn kích thước hoặc chủ độngdeletekhi user rời màn hình. NaNvà-0.Mapdùng SameValueZero:NaNkhớpNaN, còn0và-0là một khoá. Bình thường đó là điều mong muốn, nhưng nên biết.- Interning chỉ cho giá trị bất biến. Nếu ai đó sửa trực tiếp object đã intern thì mọi nơi dùng chung đều bị ảnh hưởng. Bản viết lại
Object.freezeđể chặn chuyện này. - Bộ gom và thứ tự. Hai lần
loadcùng khoá trong một lô dùng chung một promise. Muốn tải lại thì phảimarkStaletrước, giốngsetKeyStalecủa Facebook. - Loader trả thiếu khoá thì promise của khoá đó bị reject với "not found", không treo mãi.
#Dùng trong dự án
Memo selector nhận nhiều đối số mà một trong số đó là object (store, viewer…):
import { memoizeWithArgs } from './lib/multiKeyMap';
const visibleComments = memoizeWithArgs((post: Post, filter: string, viewerId: string) =>
post.comments.filter((c) => matches(c, filter) && canSee(c, viewerId)),
);
// post bị thay bằng object mới sau khi cập nhật → nhánh cache cũ rụng theo GC
ts
Gom request avatar trong một danh sách:
const users = createBatchLoader<string, User>({
loadMany: async (ids) => new Map((await api.users(ids)).map((u) => [u.id, u])),
maxBatchSize: 50,
});
function Avatar({ id }: { id: string }) {
const user = use(users.load(id)); // 100 Avatar mount cùng lúc → 2 request
return <img src={user.avatarUrl} alt={user.name} />;
}
ts
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { MultiKeyMap, memoizeWithArgs } from '../src/lib/multiKeyMap';
import { LoadState, createBatchLoader } from '../src/lib/loadState';
describe('MultiKeyMap', () => {
it('tách khoá nguyên thuỷ và object, tỉa nút rỗng khi xoá', () => {
const m = new MultiKeyMap<[string, object, number], string>();
const o = {};
m.set(['a', o, 1], 'x');
expect(m.get(['a', o, 1])).toBe('x');
expect(m.get(['a', {}, 1])).toBeUndefined();
m.set(['b', o, 1], 'y');
expect(m.countStrongNodes()).toBe(3); // root + a + b
m.delete(['a', o, 1]);
expect(m.countStrongNodes()).toBe(2);
});
it('nhận bigint và Symbol.for làm khoá', () => {
const m = new MultiKeyMap<[bigint, symbol], number>();
m.set([1n, Symbol.for('k')], 1);
expect(m.get([1n, Symbol.for('k')])).toBe(1);
});
it('báo lỗi khi số khoá thay đổi', () => {
const m = new MultiKeyMap<unknown[], number>();
m.set([1, 2], 3);
expect(() => m.get([1])).toThrow();
});
it('memoizeWithArgs đếm hit/miss', () => {
const f = memoizeWithArgs((a: number, b: number) => a + b);
f(1, 2);
f(1, 2);
expect(f.stats).toEqual({ hits: 1, misses: 1 });
});
});
describe('LoadState', () => {
it('intern trạng thái rỗng, không intern giá trị object', () => {
expect(LoadState.loading()).toBe(LoadState.loading());
expect(LoadState.withValue(0)).not.toBe(LoadState.withValue(''));
expect(LoadState.withValue({})).not.toBe(LoadState.withValue({}));
});
});
describe('createBatchLoader', () => {
it('gom theo lô và chống trùng', async () => {
const loadMany = vi.fn(async (ids: number[]) => new Map(ids.map((i) => [i, i * 10] as const)));
const l = createBatchLoader({ loadMany, maxBatchSize: 2 });
const r = await Promise.all([1, 2, 1, 3].map((k) => l.load(k)));
expect(r).toEqual([10, 20, 10, 30]);
expect(loadMany).toHaveBeenCalledTimes(2); // [1,2] và [3]
});
it('khoá thiếu thì reject', async () => {
const l = createBatchLoader<number, number>({ loadMany: async () => new Map() });
await expect(l.load(1)).rejects.toThrow('not found');
});
});
ts
#So với thư viện và chuẩn web
memoize-weak,@emotion/weak-memoize: cùng ý tưởngWeakMapnhưng chỉ cho một đối số object.MultiKeyMapmở rộng ra nhiều đối số, trộn cả khoá nguyên thuỷ.- Reselect 5 dùng
weakMapMemoizelàm mặc định, cũng là một câyMap/WeakMaptheo từng đối số (tính tới 10/2026). Đọc code của Reselect là cách nhanh để thấy một bản đã chạy thực tế lâu năm. - DataLoader (GraphQL) là chuẩn thực tế cho việc gom request: gom theo microtask, cache promise,
maxBatchSize,clear(key).LoadObjectMaplàm cùng việc nhưng giữ thêm trạng thái tải cho từng khoá để render. - Records & Tuples (đề xuất TC39 cho phép
#[a, b] === #[a, b]) đã bị rút khỏi tiến trình chuẩn hoá. Đề xuất thay thế "Composites" vẫn ở giai đoạn đầu (tính tới 10/2026), nên interning tự làm vẫn là cách thực tế.
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 (bài này)
- #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

