Web Totals

Học từ Facebook #42: Thread list theo thời gian, presence có version

Messenger web xin danh sách chat theo từng khoảng, thiếu thì xếp callback chờ chứ không vẽ lỗ, và lưu presence kèm version để tự bỏ dữ liệu của bản cũ.

hunghg255hunghg25527 min read
#facebook#messenger#pagination#storage#react
Học từ Facebook #42: Thread list theo thời gian, presence có version

Cột trái của Messenger là danh sách cuộc trò chuyện, xếp theo tin nhắn mới nhất. Nghe đơn giản, nhưng có ba thứ cùng lúc kéo danh sách này về ba phía:

  1. Phân trang. Người dùng có vài nghìn thread, không ai tải hết một lần. Trang đầu tải 20, cuộn xuống tải tiếp.
  2. Realtime. Một thread ở vị trí 87 vừa có tin mới: nó phải nhảy lên đầu ngay, kể cả khi trang hiện tại chưa tải tới đó.
  3. Nhảy cóc. Mở lại app ở vị trí cuộn cũ, hoặc lọc theo thư mục: UI xin "từ vị trí 120, lấy 8 cái" trong khi mới có 20 cái trong bộ nhớ.

Cách quen thuộc là items.slice(start, start + 8) rồi thiếu thì fetch. Kết quả là trong lúc chờ, UI hiện một nửa dữ liệu, nửa kia trống, rồi lần lượt lấp vào, thứ tự có thể đổi giữa chừng.

Tip

Tóm tắt

  • Messenger web giữ thread list bằng RangedCallbackManager: một mảng id sắp theo timestamp giảm dần, và một hàng callback chờ theo khoảng [start, start + limit).
  • Khoảng đã đủ dữ liệu thì callback chạy ngay; thiếu thì callback xếp chờ và lớp ngoài fetch đúng phần còn thiếu, nối tiếp phần đã có. UI không bao giờ thấy một khoảng có lỗ.
  • Presence (ai đang online, tab chat nào đang mở) được lưu qua điều hướng trang, kèm version schema. Đọc ra mà khác version thì coi như không có, khỏi phải viết code migrate.
  • Bản gốc có ý chặn tab cũ ghi đè dữ liệu của tab mới, nhưng điều kiện đó không bao giờ đúng. Bản viết lại sửa lỗi này.

#Facebook làm thế nào?

Hai module này nằm trong bundle Messenger của facebook.com: MercuryOrderedThreadlist (file 66.js), RangedCallbackManager (file 47.js) và PresenceCookieManager (file 96.js). "Mercury" là tên nội bộ của lớp dữ liệu chat đời đầu; xem thêm #31 hàng đợi gửi tin cũng thuộc lớp này.

#Một chỉ mục cho mỗi (thư mục × bộ lọc)

MercuryOrderedThreadlist không giữ một danh sách mà giữ một ma trận: mỗi thư mục (inbox, spam, tin nhắn chờ…) nhân với mỗi bộ lọc (tất cả, chưa đọc, nhóm…) có một RangedCallbackManager riêng. Đổi tên biến cho dễ đọc:

this.lists[folder][filter] = new RangedCallbackManager(
  (threadID) => MessengerState.getThreadMetaNow(fbid, threadID)?.timestamp, // key để sắp
  (a, b) => b - a,                                                          // mới nhất trước
  threadIDMap.canLinkExternally,                                            // bộ lọc "strict"
);
js

Chỉ mục chỉ giữ id. Thứ tự được tính từ key của từng id, đọc từ state chung (MessengerState.bs, viết bằng ReasonML). Thread có tin mới thì timestamp đổi, chỉ mục chỉ việc sắp lại.

#Xin một khoảng, thiếu thì chờ

UI gọi getFilteredThreadlist(start, limit, folder, filter, callback):

getFilteredThreadlist(start, limit, folder, filter, callback, strict, …) {
  const list = this.getThreadlistOrderMap(folder, filter);
  const requestId = list.executeOrEnqueue(start, limit, callback, strict);
  const missing = list.getUnavailableResources(requestId);
  if (missing.length) {
    if (list.getCurrentArraySize() < start) log.warn('range_with_gap', …);
    serverRequests.fetchThreadlistInfo(list.getCurrentArraySize(), missing.length, folder, filter, …);
  }
  return requestId;
}
js

Hai chi tiết đáng để ý:

  • Fetch luôn bắt đầu từ getCurrentArraySize(), tức nối tiếp phần đã có, không bao giờ xin riêng khoảng [120, 128). Xin từ 120 khi mới có 20 nghĩa là tải 108 thread. Đổi lại, mảng không bao giờ có lỗ. Trường hợp này được ghi log range_with_gap để đội Messenger biết nó xảy ra bao nhiêu lần.
  • getUnavailableResources trả danh sách chỉ số còn thiếu, nhưng code chỉ dùng .length của nó.

Phần quyết định callback có chạy được chưa nằm trong RangedCallbackManager:

// tên biến đã đổi
canRun(request, options) {
  const ids = this.filtered(options);                 // lọc "strict" nếu cần
  const end = request.start + request.limit;
  const force = !!options?.force;
  if (!force && !this.reachedEnd && !this.error && end > ids.length) return false; // chờ tiếp
  const slice = ids.slice(request.start, end);
  const error = end > ids.length ? this.error : null;
  return [slice, error];
}
js

Ba lối thoát cho callback đang chờ: đủ dữ liệu, server báo đã hết (setReachedEndOfArray), hoặc fetch lỗi (setError). Hai trường hợp sau trả phần đang có kèm lỗi nếu có, để UI không chờ mãi.

addResources bỏ id trùng bằng một object {id: true}, đẩy vào mảng, sắp lại bằng arrayStableSort rồi gọi runPossibleCallbacks(). Sắp ổn định quan trọng ở đây: hai thread cùng timestamp phải giữ thứ tự cũ, nếu không mỗi lần thêm dữ liệu chúng lại đổi chỗ cho nhau trên màn hình.

PresenceCookieManager lưu trạng thái chat của tab qua các lần chuyển trang. Mỗi module đăng ký một "sub-cookie" bằng register(name, fn); lúc ghi, manager gom kết quả của mọi fn thành một object {v, time, user, ...}, JSON.stringify rồi mã hoá bằng Dcode.

Ký tự đầu của chuỗi lưu cho biết dữ liệu nằm ở đâu:

function store(data, smallFlags) {
  const savedToSession = writeSessionStorage(data);   // sessionStorage['presence'] = 'E' + data
  if (savedToSession) setCookie('S' + smallFlags);    // cookie chỉ còn vài ký tự cờ
  else setCookie('E' + data);                         // không có sessionStorage: cookie chứa hết
}
js

Khi trình duyệt có sessionStorage, dữ liệu đầy đủ nằm ở đó, còn cookie presence chỉ mang vài ký tự cờ: f (tài khoản Facebook) hoặc w (Workplace), được bật khi trường t3 (hoặc t3w) trong state có dữ liệu. Code không ghi chú t3 là gì; có lẽ là danh sách tab chat đang mở, để server biết mà không cần đọc cả state. Cookie đi kèm mọi request, nên giữ cho nó nhỏ là tiết kiệm băng thông ở mỗi request.

Lúc đọc, dữ liệu bị bỏ qua nếu khác user hoặc khác version:

function read() {
  const m = JSON.parse(Dcode.decode(raw));
  if (m.user && m.user !== CurrentUser.getID()) return null;
  return m.v !== cookieVersion ? null : m;
}
js

Đây là cách migrate rẻ nhất: đổi schema thì tăng cookieVersion, dữ liệu cũ tự biến mất ở lần đọc kế tiếp.

#Lỗi trong bản gốc: chặn ghi đè không bao giờ chạy

Hàm ghi có một dòng chặn rất có lý:

function save() {
  const current = read();
  if (current && current.v && cookieVersion < current.v) return; // đừng ghi đè dữ liệu của bản mới hơn
  …
}
js

Ý đồ: người dùng mở hai tab, một tab đã tải bản JS mới (version 4), tab kia còn chạy bản cũ (version 3). Tab cũ không được ghi đè dữ liệu version 4. Nhưng read() đã trả null khi m.v !== cookieVersion, nên current luôn null đúng trong trường hợp version khác nhau. Điều kiện cookieVersion < current.v không bao giờ được xét, và tab cũ vẫn ghi đè. Hai tab sẽ giành nhau ghi, mỗi bên lần lượt xoá dữ liệu của bên kia.

flowchart LR
  UI["UI xin [120, 128)"] --> Q{"Đã có ≥ 128 id?"}
  Q -- có --> Run["Chạy callback với 8 id"]
  Q -- chưa --> Wait["Xếp callback chờ"]
  Wait --> Fetch["Fetch nối tiếp từ size hiện tại"]
  Fetch --> Add["addResources: bỏ trùng, sắp ổn định"]
  Add --> Check{"Đủ chưa? Hết? Lỗi?"}
  Check -- "đủ / hết / lỗi" --> Run
  Check -- "chưa" --> Wait
  RT["Delta realtime: thread có tin mới"] --> Resort["Sắp lại theo timestamp"]
  Resort --> Run

#Viết lại

Bản viết lại gom phần chỉ mục và hàng chờ vào một class RangedIndex<T>. Lớp ngoài chỉ cần biết hai thứ: xin một khoảng, và nếu thiếu thì thiếu bao nhiêu.

src/lib/threadList.ts
  /**
   * Xin khoảng [start, start + limit). Trả về id để huỷ chờ và số phần tử còn thiếu
   * (0 nghĩa là callback đã chạy xong ngay trong lần gọi này).
   */
  request(start: number, limit: number, callback: (result: RangeResult<T>) => void) {
    const waiter = { start, limit, callback };
    if (this.tryRun(waiter)) return { id: 0, missing: 0 };
    const id = this.nextWaiterId++;
    this.waiters.set(id, waiter);
    return { id, missing: start + limit - this.ids.length };
  }

  unsubscribe(id: number) {
    this.waiters.delete(id);
  }

  /** Thêm id (bỏ trùng), sắp lại rồi chạy những callback nay đã đủ dữ liệu. */
  add(newIds: T[]) {
    let changed = false;
    for (const id of newIds) {
      if (this.known.has(id)) continue;
      this.known.add(id);
      this.ids.push(id);
      changed = true;
    }
    if (!changed) return;
    this.error = null;
    this.resort();
    this.flush();
  }
ts

Phần quyết định có chạy callback được chưa:

src/lib/threadList.ts
  private tryRun(waiter: Waiter<T>) {
    const end = waiter.start + waiter.limit;
    const enough = end <= this.ids.length;
    if (!enough && !this.ended && this.error == null) return false;
    waiter.callback({
      items: this.ids.slice(waiter.start, end),
      error: enough ? null : this.error,
    });
    return true;
  }

  private flush() {
    for (const [id, waiter] of this.waiters) {
      if (this.tryRun(waiter)) this.waiters.delete(id);
    }
  }
ts

Presence store, với chỗ sửa lỗi ghi đè: đọc version thô của dữ liệu đang lưu, không qua bộ lọc version.

src/lib/threadList.ts
  /** version của dữ liệu đang nằm trong storage (kể cả khác version mình), để quyết định có ghi đè không. */
  function storedVersion(): number | null {
    const raw = readRaw();
    if (!raw) return null;
    try {
      const v = (JSON.parse(raw) as Partial<PresenceRecord>).v;
      return typeof v === 'number' ? v : null;
    } catch {
      return null;
    }
  }

  /**
   * Ghi. Trả false nếu dữ liệu đang có thuộc version MỚI HƠN: tab chạy bản cũ không được
   * ghi đè lên dữ liệu của tab đã lên bản mới.
   */
  function write(fields: Record<string, unknown>): boolean {
    const existing = storedVersion();
    if (existing !== null && existing > version) return false;
    const record: PresenceRecord = {
      ...fields,
      v: version,
      user: currentUser,
      time: Math.floor(now() / 1000),
    };
    const value = JSON.stringify(record);
    if (primary) {
      primary.set(key, value);
      fallback.remove(key);
    } else {
      fallback.set(key, value);
    }
    return true;
  }
ts
Xem toàn bộ src/lib/threadList.ts
src/lib/threadList.ts
/**
 * Danh sách thread sắp theo mốc hoạt động mới nhất, phân trang bằng "ranged callback"
 * (ý tưởng từ MercuryOrderedThreadlist + RangedCallbackManager của Messenger web).
 *
 * - Chỉ mục giữ id, thứ tự lấy từ key (timestamp) của từng id, sắp giảm dần.
 * - UI xin một khoảng [start, start + limit). Đủ dữ liệu thì callback chạy ngay;
 *   thiếu thì callback được XẾP CHỜ, đồng thời trả về số phần tử còn thiếu để gọi fetch.
 *   UI không bao giờ thấy một khoảng có "lỗ".
 * - Đã tới cuối danh sách hoặc fetch lỗi: callback chờ được chạy với phần đang có.
 */

export interface RangeResult<T> {
  items: T[];
  /** lỗi fetch nếu khoảng xin vượt quá phần đã có, ngược lại null */
  error: unknown;
}

type Waiter<T> = { start: number; limit: number; callback: (result: RangeResult<T>) => void };

export class RangedIndex<T> {
  private ids: T[] = [];
  private known = new Set<T>();
  private ended = false;
  private error: unknown = null;
  private waiters = new Map<number, Waiter<T>>();
  private nextWaiterId = 1;

  constructor(
    /** key để sắp; undefined thì xếp cuối */
    private getKey: (id: T) => number | undefined,
    /** mặc định: key lớn đứng trước (mới nhất trước) */
    private compare: (a: number, b: number) => number = (a, b) => b - a,
  ) {}

  /**
   * Xin khoảng [start, start + limit). Trả về id để huỷ chờ và số phần tử còn thiếu
   * (0 nghĩa là callback đã chạy xong ngay trong lần gọi này).
   */
  request(start: number, limit: number, callback: (result: RangeResult<T>) => void) {
    const waiter = { start, limit, callback };
    if (this.tryRun(waiter)) return { id: 0, missing: 0 };
    const id = this.nextWaiterId++;
    this.waiters.set(id, waiter);
    return { id, missing: start + limit - this.ids.length };
  }

  unsubscribe(id: number) {
    this.waiters.delete(id);
  }

  /** Thêm id (bỏ trùng), sắp lại rồi chạy những callback nay đã đủ dữ liệu. */
  add(newIds: T[]) {
    let changed = false;
    for (const id of newIds) {
      if (this.known.has(id)) continue;
      this.known.add(id);
      this.ids.push(id);
      changed = true;
    }
    if (!changed) return;
    this.error = null;
    this.resort();
    this.flush();
  }

  remove(ids: T[]) {
    const drop = new Set(ids.filter((id) => this.known.has(id)));
    if (drop.size === 0) return;
    drop.forEach((id) => this.known.delete(id));
    this.ids = this.ids.filter((id) => !drop.has(id));
  }

  /** Key của id đã đổi (vd: thread có tin mới): sắp lại. */
  resort() {
    const keyOf = (id: T) => this.getKey(id) ?? Number.NEGATIVE_INFINITY;
    // Array.prototype.sort đã ổn định (stable) từ ES2019: thread cùng key giữ thứ tự cũ
    this.ids.sort((a, b) => {
      const ka = keyOf(a);
      const kb = keyOf(b);
      if (ka === kb) return 0;
      if (ka === Number.NEGATIVE_INFINITY) return 1;
      if (kb === Number.NEGATIVE_INFINITY) return -1;
      return this.compare(ka, kb);
    });
  }

  /** Server báo không còn gì để tải: callback đang chờ nhận phần còn lại. */
  setEnded() {
    if (this.ended) return;
    this.ended = true;
    this.flush();
  }

  setError(error: unknown) {
    if (this.error === error) return;
    this.error = error;
    this.flush();
  }

  get size() {
    return this.ids.length;
  }

  get hasEnded() {
    return this.ended;
  }

  get pendingCount() {
    return this.waiters.size;
  }

  /** Số phần tử cần có để mọi callback đang chờ chạy được (0 nếu không ai chờ). */
  get wantedSize() {
    let max = 0;
    for (const w of this.waiters.values()) max = Math.max(max, w.start + w.limit);
    return max;
  }

  getAll() {
    return this.ids.slice();
  }

  /** Mọi id có key "mới hơn hoặc bằng" mốc cho trước (dùng cho "tải tới khi gặp tin X"). */
  getUntil(key: number) {
    const out: T[] = [];
    for (const id of this.ids) {
      const k = this.getKey(id);
      if (k !== undefined && this.compare(k, key) > 0) break;
      out.push(id);
    }
    return out;
  }

  private tryRun(waiter: Waiter<T>) {
    const end = waiter.start + waiter.limit;
    const enough = end <= this.ids.length;
    if (!enough && !this.ended && this.error == null) return false;
    waiter.callback({
      items: this.ids.slice(waiter.start, end),
      error: enough ? null : this.error,
    });
    return true;
  }

  private flush() {
    for (const [id, waiter] of this.waiters) {
      if (this.tryRun(waiter)) this.waiters.delete(id);
    }
  }
}

// ---------------------------------------------------------------------------
// Presence lưu qua điều hướng, tự vô hiệu khi đổi schema (ý tưởng từ PresenceCookieManager)
// ---------------------------------------------------------------------------

export interface KeyValueStore {
  get(key: string): string | null;
  set(key: string, value: string): void;
  remove(key: string): void;
}

export interface PresenceRecord {
  /** version schema của dữ liệu */
  v: number;
  user: string;
  /** giây, lúc ghi */
  time: number;
  [field: string]: unknown;
}

export interface PresenceStoreOptions {
  key?: string;
  version: number;
  currentUser: string;
  /** nơi lưu chính, vd sessionStorage; null nếu không dùng được */
  primary: KeyValueStore | null;
  /** nơi lưu dự phòng, vd cookie */
  fallback: KeyValueStore;
  now?: () => number;
}

export function createPresenceStore({
  key = 'presence',
  version,
  currentUser,
  primary,
  fallback,
  now = () => Date.now(),
}: PresenceStoreOptions) {
  const readRaw = () => primary?.get(key) ?? fallback.get(key);

  /** null nếu không có, hỏng, khác user hoặc khác version. */
  function read(): PresenceRecord | null {
    const raw = readRaw();
    if (!raw) return null;
    try {
      const record = JSON.parse(raw) as PresenceRecord;
      if (record.user !== currentUser) return null;
      if (record.v !== version) return null;
      return record;
    } catch {
      return null;
    }
  }

  /** version của dữ liệu đang nằm trong storage (kể cả khác version mình), để quyết định có ghi đè không. */
  function storedVersion(): number | null {
    const raw = readRaw();
    if (!raw) return null;
    try {
      const v = (JSON.parse(raw) as Partial<PresenceRecord>).v;
      return typeof v === 'number' ? v : null;
    } catch {
      return null;
    }
  }

  /**
   * Ghi. Trả false nếu dữ liệu đang có thuộc version MỚI HƠN: tab chạy bản cũ không được
   * ghi đè lên dữ liệu của tab đã lên bản mới.
   */
  function write(fields: Record<string, unknown>): boolean {
    const existing = storedVersion();
    if (existing !== null && existing > version) return false;
    const record: PresenceRecord = {
      ...fields,
      v: version,
      user: currentUser,
      time: Math.floor(now() / 1000),
    };
    const value = JSON.stringify(record);
    if (primary) {
      primary.set(key, value);
      fallback.remove(key);
    } else {
      fallback.set(key, value);
    }
    return true;
  }

  function clear() {
    primary?.remove(key);
    fallback.remove(key);
  }

  return { read, write, clear, storedVersion };
}

export function storageAdapter(storage: Storage | undefined): KeyValueStore | null {
  if (!storage) return null;
  try {
    const probe = '__probe__';
    storage.setItem(probe, '1');
    storage.removeItem(probe);
  } catch {
    return null; // Safari private mode cũ, quota = 0…
  }
  return {
    get: (k) => storage.getItem(k),
    set: (k, v) => storage.setItem(k, v),
    remove: (k) => storage.removeItem(k),
  };
}

export function memoryAdapter(): KeyValueStore & { dump(): Record<string, string> } {
  const map = new Map<string, string>();
  return {
    get: (k) => map.get(k) ?? null,
    set: (k, v) => void map.set(k, v),
    remove: (k) => void map.delete(k),
    dump: () => Object.fromEntries(map),
  };
}
ts

Khác bản gốc:

  • Callback chỉ chạy một lần rồi rời hàng chờ. CallbackManagerController của Facebook có thêm chế độ đăng ký lâu dài, nhưng thread list chỉ dùng kiểu một lần.
  • remove() không chạy lại callback, giống bản gốc: bỏ bớt id không làm một khoảng đang thiếu trở nên đủ.
  • Presence lưu JSON thuần, không qua Dcode. Lớp lưu trữ được truyền vào (KeyValueStore), nên test được bằng bộ nhớ.

#Đi qua code

  • request() thử chạy callback ngay. Không chạy được thì cấp một id để huỷ (unsubscribe) và trả về missing, số phần tử còn thiếu tính từ cuối mảng. Lớp ngoài dùng missing để fetch, giống getUnavailableResources(...).length của bản gốc.
  • add() bỏ trùng bằng Set, đẩy id mới vào rồi sắp lại toàn bộ. Sắp lại n phần tử mỗi lần thêm là O(n log n); với vài nghìn thread vẫn chỉ mất dưới một mili giây, và đổi lại code rất đơn giản.
  • resort() dùng Array.prototype.sort, vốn ổn định từ ES2019. Thread chưa có key (chưa nạp metadata) xếp cuối.
  • flush() duyệt hàng chờ và chạy mọi callback đã đủ điều kiện. Xoá phần tử của Map trong lúc duyệt bằng for…of là an toàn trong JavaScript.
  • wantedSize: demo dùng getter này để biết, khi một fetch vừa xong, có callback nào đang chờ một khoảng còn xa hơn không, để fetch tiếp. Bản gốc làm việc này ở lớp MercuryServerRequests.
  • write() đọc storedVersion() trước: nếu dữ liệu đang lưu thuộc version mới hơn thì từ chối ghi và trả false.

#Tại sao không làm cách khác?

Vẽ phần đang có, thiếu thì để skeleton từng dòng? Bật "Cách ngây thơ" trong demo rồi nhảy tới 120: tám dòng trống hiện ra, sau đó lấp đầy. Với danh sách chat thì còn tệ hơn, vì trong lúc chờ có thể có tin mới làm thứ tự đổi, các dòng đã vẽ bị đẩy xuống. Chờ đủ cả khoảng rồi vẽ một lần cho kết quả ổn định hơn.

Fetch đúng khoảng [120, 128) thay vì nối tiếp? Server phải hỗ trợ phân trang theo offset ổn định, mà danh sách chat đổi liên tục: giữa hai lần gọi, thread ở vị trí 120 có thể đã thành 121. Phân trang theo con trỏ nối tiếp tránh được chuyện trùng hay sót, đổi lại phải tải phần ở giữa. Nhảy xa là trường hợp hiếm, và có log range_with_gap để đo.

Lưu presence trong localStorage? localStorage dùng chung giữa mọi tab, trong khi trạng thái "tab chat nào đang mở" là của từng tab. sessionStorage sống theo tab và vẫn còn sau khi chuyển trang, khớp đúng nhu cầu.

Migrate dữ liệu cũ thay vì bỏ? Presence là dữ liệu tạm, mất thì tạo lại sau vài giây. Code migrate thì phải giữ mãi và test với mọi version cũ. Bỏ đi là lựa chọn hợp lý cho loại dữ liệu này.

#Edge case và cạm bẫy

  • Key đổi mà không sắp lại. Chỉ mục chỉ biết thứ tự đổi khi được gọi resort(). Ở bản gốc, delta update-threadlist từ server kéo theo cập nhật chỉ mục rồi MercuryThreadInformer.updatedThreadlist() báo view vẽ lại.
  • Callback chờ mãi. Fetch lỗi mà không gọi setError, hoặc server hết dữ liệu mà không gọi setEnded, thì callback treo vĩnh viễn. Lớp gọi fetch phải luôn kết thúc bằng một trong ba cách: add, setEnded, setError.
  • Component unmount khi đang chờ. Phải unsubscribe(id) trong cleanup, nếu không callback sẽ setState vào component đã gỡ. Demo làm việc này trong useEffect.
  • Sắp theo timestamp của client. Đồng hồ máy người dùng có thể lệch. Timestamp ở đây do server cấp, không phải Date.now() của trình duyệt.
  • sessionStorage bị chặn. Safari ở chế độ riêng tư (bản cũ) ném lỗi khi setItem. Hàm storageAdapter thử ghi một khoá mẫu và trả null nếu không được, store chuyển sang cookie.
  • Hai tab cùng ghi. Với sessionStorage, mỗi tab có bản riêng nên không giành nhau. Với cookie thì dùng chung, và đó là lúc lỗi "tab cũ ghi đè" của bản gốc xảy ra.

#Dùng trong dự án

Danh sách có phân trang và thứ tự đổi theo realtime (inbox, danh sách thông báo, bảng xếp hạng):

const index = new RangedIndex<string>((id) => cache.get(id)?.updatedAt);

function useRange(start: number, limit: number) {
  const [items, setItems] = useState<string[] | null>(null);
  useEffect(() => {
    const { id, missing } = index.request(start, limit, ({ items }) => setItems(items));
    if (missing > 0) loadMore(missing); // gọi index.add / setEnded / setError khi xong
    return () => index.unsubscribe(id);
  }, [start, limit]);
  return items; // null = đang chờ, hiện skeleton cả khoảng
}
ts

Dữ liệu tạm cần sống qua điều hướng, có version:

const presence = createPresenceStore({
  version: 4,
  currentUser: user.id,
  primary: storageAdapter(window.sessionStorage),
  fallback: cookieAdapter('presence'), // tự viết, nhớ giới hạn ~4KB của cookie
});
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { RangedIndex, createPresenceStore, memoryAdapter } from './threadList';

const ts = new Map<string, number>();
const index = () => new RangedIndex<string>((id) => ts.get(id));

describe('RangedIndex', () => {
  it('chạy callback ngay khi khoảng đã đủ, xếp chờ khi thiếu', () => {
    ts.clear();
    ['a', 'b', 'c'].forEach((id, i) => ts.set(id, 100 - i));
    const idx = index();
    idx.add(['c', 'a', 'b']);
    const now = vi.fn();
    expect(idx.request(0, 2, now)).toEqual({ id: 0, missing: 0 });
    expect(now).toHaveBeenCalledWith({ items: ['a', 'b'], error: null });

    const later = vi.fn();
    const { missing } = idx.request(2, 3, later);
    expect(missing).toBe(2);
    expect(later).not.toHaveBeenCalled();
    ['d', 'e'].forEach((id, i) => ts.set(id, 50 - i));
    idx.add(['d', 'e']);
    expect(later).toHaveBeenCalledWith({ items: ['c', 'd', 'e'], error: null });
  });

  it('hết dữ liệu hoặc lỗi: callback nhận phần đang có', () => {
    ts.clear();
    ts.set('x', 1);
    const idx = index();
    idx.add(['x']);
    const cb = vi.fn();
    idx.request(0, 5, cb);
    idx.setError('mạng lỗi');
    expect(cb).toHaveBeenCalledWith({ items: ['x'], error: 'mạng lỗi' });
  });

  it('thread có tin mới lên đầu sau resort, add bỏ trùng', () => {
    ts.clear();
    ['a', 'b', 'c'].forEach((id, i) => ts.set(id, 100 - i));
    const idx = index();
    idx.add(['a', 'b', 'c', 'b']);
    expect(idx.size).toBe(3);
    ts.set('c', 999);
    idx.resort();
    expect(idx.getAll()).toEqual(['c', 'a', 'b']);
  });
});

describe('presence store', () => {
  it('bỏ dữ liệu khác version, tab cũ không ghi đè dữ liệu của bản mới', () => {
    const shared = memoryAdapter();
    const v3 = createPresenceStore({ version: 3, currentUser: 'u', primary: shared, fallback: memoryAdapter() });
    const v4 = createPresenceStore({ version: 4, currentUser: 'u', primary: shared, fallback: memoryAdapter() });
    expect(v4.write({ active: 1 })).toBe(true);
    expect(v3.read()).toBeNull();
    expect(v3.write({ active: 0 })).toBe(false);
    expect(v4.read()?.active).toBe(1);
  });
});
ts

#So với thư viện và chuẩn web

  • TanStack Query có useInfiniteQuery cho phân trang nối tiếp theo con trỏ, nhưng dữ liệu được giữ theo từng trang chứ không theo một chỉ mục sắp theo key. Muốn một thread "nhảy lên đầu" khi có tin mới, bạn vẫn phải tự sửa cache.
  • Cache của Relay và Apollo có khái niệm connection (edges + pageInfo), cũng phân trang nối tiếp. Thứ tự lấy theo server, nên cập nhật realtime thường đi kèm hàm updater tự chèn edge vào đầu.
  • Storage: sessionStorage có ở mọi trình duyệt hiện đại, giới hạn khoảng 5MB mỗi origin (con số cụ thể tuỳ trình duyệt, tính tới 10/2026). Cookie bị giới hạn khoảng 4KB mỗi cookie và luôn đi kèm request, nên chỉ để những gì server thật sự cần đọc.

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