Web Totals

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ô.

hunghg255hunghg25525 min read
#facebook#memoization#weakmap#react#performance
Học từ Facebook #68: Memo nhiều đối số bằng Map/WeakMap và interning object

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

  • MultiKeyMap là một trie: mỗi tầng ứng với một đối số. Khoá nguyên thuỷ (string, number…) nằm trong Map, khoá là object nằm trong WeakMap. 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 trong Map mạnh, và khoá bigint hay Symbol.for(...) bị đưa vào WeakMap nên ném lỗi.
  • LoadObject intern các trạng thái mà giá trị thuộc tập undefined, null, false, true, 0, ''. Tạo "loading" 300 lần vẫn chỉ có một object, nên React.memo bỏ qua render.
  • LoadObjectMap xế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.

#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ớ"]
  1. Nút rỗng không được dọn. delete chỉ gỡ lá ở tầng cuối. Các nút trung gian vẫn nằm trong cache (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.
  2. Khoá bigint và Symbol.for ném lỗi. Hàm i chỉ coi number/string/boolean/null/undefined là nguyên thuỷ. Mọi thứ khác vào WeakMap, mà new WeakMap().set(1n, 1) ném TypeError: Invalid value used as weak map key. Symbol đã đăng ký (Symbol.for) cũng vậy.
  3. return e trong forEach ở nhánh $10 của LoadObjectMap.get chỉ thoát callback, không thoát get. Có lẽ không gây sai kết quả vì cuối hàm cũng return 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:

  • delete nhớ đườ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ự đếm size vì WeakMap không đếm được.
  • Mọi giá trị không phải object/hàm (kể cả symbol, bigint) đều vào Map mạnh, nên không bao giờ ném lỗi khoá WeakMap.
  • Có thêm memoizeWithArgs(fn) trả kèm stats (hit/miss), và hàm không đối số vẫn có khoá [undefined].
  • LoadState là object thường bị Object.freeze, không phụ thuộc Immutable.js. Khoá interning có typeof để 0, '', false khô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

  • isPrimitive trả true cho null và mọi thứ không phải object/function. Chỉ object/hàm mới đi vào WeakMap, đúng loại khoá mà WeakMap chắc chắn nhận.
  • setChild chỉ tăng size khi 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ào path. Sau khi gỡ lá, vòng lặp đi ngược: nếu node.size === 0 thì gỡ keys[i] khỏi path[i], rồi lên một tầng. Gặp nút còn con thì dừng.
  • make trong loadState.ts chỉ intern khi error === undefined và giá trị thuộc tập INTERNABLE. 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.load tra cache promise trước. Lần đầu thấy khoá thì tạo promise, đẩy vào hàng đợi và lên lịch flush một lần. flush cắt hàng đợi theo maxBatchSize, mỗi khúc một lần loadMany.

#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 WeakMap lồng WeakMap: 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ần Map.
  • So sánh nông (shallowEqual) trong React.memo thay 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ủ động delete khi user rời màn hình.
  • NaN và -0. Map dùng SameValueZero: NaN khớp NaN, còn 0 và -0 là 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 load cùng khoá trong một lô dùng chung một promise. Muốn tải lại thì phải markStale trước, giống setKeyStale củ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ưởng WeakMap nhưng chỉ cho một đối số object. MultiKeyMap mở rộng ra nhiều đối số, trộn cả khoá nguyên thuỷ.
  • Reselect 5 dùng weakMapMemoize làm mặc định, cũng là một cây Map/WeakMap theo 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). LoadObjectMap là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

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz