Web Totals

Học từ Facebook #50: Sửa hàng loạt và trạng thái seen/read bằng 2 bit

Cách Ads Manager sửa N chiến dịch trong một form mà không ghi đè field không đụng tới, và cách chuông thông báo dùng 2 bit cùng XOR để tách seen với read.

hunghg255hunghg25529 min read
#facebook#react#forms#state#notifications
Học từ Facebook #50: Sửa hàng loạt và trạng thái seen/read bằng 2 bit

Bạn chọn 3 chiến dịch quảng cáo trong một bảng rồi bấm "Sửa". Một form hiện ra. Ô "Ngân sách" nên hiển thị gì, khi 3 chiến dịch có 3 ngân sách khác nhau? Và nếu người dùng chỉ đổi "Tuổi tối thiểu" rồi bấm Lưu, thì ngân sách của 3 chiến dịch có được giữ nguyên không?

Cách làm nhanh nhất là lấy dòng đầu tiên làm giá trị cho cả form. Ô ngân sách hiện 500, người dùng không để ý, bấm Lưu, và cả 3 chiến dịch giờ đều có ngân sách 500. Với form quảng cáo, đó là lỗi tốn tiền thật.

Bài này xem hai mẩu code nhỏ của Facebook. Cả hai cùng giải một kiểu bài toán: một giá trị đại diện cho nhiều thứ cùng lúc, sao cho khi cập nhật, phần không đổi không bị đụng tới.

  1. AdsBulkValueUtils: mỗi field của form sửa hàng loạt là Empty, Uniform hoặc Mixed.
  2. NotificationSeenState: trạng thái "đã thấy" và "đã đọc" của thông báo gói trong 2 bit, dùng XOR để biết bit nào vừa đổi.

Tip

Tóm tắt

  • Form sửa N dòng không giữ một giá trị mà giữ một BulkValue: empty (mọi dòng rỗng), uniform (mọi dòng giống nhau) hoặc mixed (mảng N giá trị riêng). Ô mixed hiện placeholder "Nhiều giá trị".
  • Sửa một ô thì chỉ ô đó thành uniform; khi tách ngược ra N object, ô mixed trả lại đúng giá trị riêng của từng dòng.
  • Chuông thông báo lưu SEEN=1, READ=2. old ^ new cho biết bit nào đổi, nên badge chỉ vẽ lại khi bit SEEN đổi, danh sách chỉ vẽ lại khi bit READ đổi.
  • Payload lúc tải trang không được ghi đè trạng thái đã biết, vì nó có thể cũ hơn dữ liệu vừa nhận qua push.

#Facebook làm thế nào?

#Ba kiểu giá trị cho form sửa hàng loạt

Trong 99.js có bốn module đi cùng nhau: AdsEmptyValue, AdsUniformValue, AdsMixedValue và AdsBulkValueUtils. Mỗi kiểu có một hàm create(values) nhận mảng N giá trị, trả về instance nếu khớp, ngược lại trả null:

// AdsEmptyValue.create: mọi giá trị đều "rỗng"
values.every((a) =>
  a === undefined || a === null || a === '' ||
  (Array.isArray(a) && !a.length) ||
  (a[Symbol.iterator] && a.size === 0) ||                 // Set/Map rỗng
  (Object.prototype.toString.call(a) === '[object Object]' && !Object.keys(a).length)
) ? AdsEmptyValue.instance() : null;                       // Empty là singleton

// AdsUniformValue.create: mọi giá trị areEqual với phần tử đầu
values.every((a) => areEqual(a, values[0])) ? new AdsUniformValue(values[0]) : null;

// AdsMixedValue.create: luôn khớp
new AdsMixedValue(values);
js

AdsBulkValueUtils.aggregate thử các kiểu theo thứ tự trong AdsEmptyUniformMixedBulkValueTypes: Empty trước, rồi Uniform, cuối cùng Mixed. Kiểu nào create ra khác null thì dừng. Mỗi kiểu có match({ Empty, Uniform, Mixed }), giống pattern matching của kiểu tổng (sum type) trong các ngôn ngữ hàm.

#Gộp và tách sâu theo path

Form quảng cáo không phẳng: targeting.age_min, targeting.geo_locations.countries… Hàm aggregateDeep(spec, objects) đi đệ quy theo một object mẫu, và ở mỗi lá thì getByPath từ N object để tạo bulk value:

Với mỗi lá, hàm (tên rút gọn y()) lấy giá trị ở path đó từ N object, rồi thử lần lượt các kiểu như trên. Nếu cả N giá trị đều undefined thì chỉ Empty được chấp nhận.

deaggregateDeep(tree, n) đi ngược lại, tạo N object và setByPath cho từng lá:

function writeLeaf(target, index, bulk, path) {
  const v = bulk.getValueForIndex(index);   // Uniform: luôn value; Mixed: values[index]
  if (v !== undefined) setByPath(target, path, v); // Empty trả undefined: không ghi gì
}
js

Mấu chốt nằm ở đây: lá Mixed mà người dùng không đụng tới sẽ trả lại đúng giá trị riêng của từng dòng. Lá được sửa đã thành Uniform, nên mọi dòng nhận cùng giá trị mới. Độ sâu quá 5 cấp thì module ghi log cảnh báo hiệu năng.

Bộ util còn có getObjectDeepChanges trả về { new, old } cho từng lá đã đổi, dùng để chỉ gửi phần thay đổi lên server.

Hai chi tiết nhỏ khi đọc code:

  • AdsMixedValue.hashCode() chỉ trả về độ dài mảng, nên mọi Mixed cùng N phần tử cùng hash. Không sai, vì equals vẫn so từng phần tử, nhưng dùng làm khoá trong Map của Immutable.js thì mọi Mixed cùng độ dài rơi vào một bucket.
  • areBulkValuesEqual so bằng StrSet (tập chuỗi), nên Mixed[a, b] bằng Mixed[b, a] và bỏ qua phần tử trùng. Nếu thứ tự dòng có ý nghĩa thì hàm này trả true sai.

#Trạng thái thông báo gói trong 2 bit

Module NotificationSeenState (277.js) giữ một object { alert_id → số }:

const SEEN = 1, READ = 2;
const states = {
  UNSEEN_AND_UNREAD: 0,
  SEEN_BUT_UNREAD: SEEN,       // 1
  SEEN_AND_READ: 3,            // SEEN | READ
};
js

Không có trạng thái 2 (đã đọc nhưng chưa thấy), vì đọc thì phải thấy. Khi có cập nhật, hàm o() so trạng thái cũ và mới bằng XOR:

// trong vòng lặp qua từng alert_id; id chưa biết thì phát cả hai sự kiện
const diff = old ^ value;
if (diff & SEEN) seenChanged.push(id);
if (diff & READ) readChanged.push(id);
// cuối hàm: didUpdateSeenState(seenChanged), didUpdateReadState(readChanged) nếu khác rỗng
js

Mở chuông là old | 1. Bỏ đọc là old & ~2. Hai sự kiện tách biệt: badge đỏ trên chuông chỉ cần biết "seen", chấm xanh trên từng thông báo chỉ cần biết "read". Bấm mở chuông làm đổi seen của 20 thông báo, nhưng danh sách không phải vẽ lại vì không bit READ nào đổi.

Module còn có một luật về nguồn dữ liệu:

NotificationUpdates.subscribe('update-notifications', (_, data) => {
  const fromEndpoint = data.payloadsource == PayloadSourceType.ENDPOINT;
  const nodes = data.nodes.filter((n) => !fromEndpoint || known[n.alert_id] === undefined);
  apply(toStates(nodes));
});
js

Payload từ endpoint (lúc tải trang, lúc mở danh sách) không ghi đè id đã biết. Lý do (suy đoán, code không ghi chú): response của endpoint được tạo ở server tại một thời điểm; trong lúc nó bay về, người dùng có thể đã đọc thông báo ở tab khác và trạng thái mới đã tới qua push. Ghi đè thì thông báo đã đọc lại hiện chấm xanh.

#Danh sách "thin client"

CometNotificationsThinClientConnectionHandler (26.js) là một connection handler tự viết cho Relay. Khi refetch đầu danh sách (không có after/before), server có thể trả về edge kiểu NotifPageCachedNotificationRow, chỉ có id và vài field. Handler dựng bảng notif.id → record đầy đủ từ các edge đang có, rồi thay node rút gọn bằng record cũ:

if (node.getType() === 'NotifPageCachedNotificationRow') {
  const full = oldById[node.getLinkedRecord('notif').getValue('id')];
  if (full) { full.copyFieldsFrom(node); edge.setLinkedRecord(full, 'node'); out.push(edge); }
  // không có trong cache: bỏ qua, không báo gì
}
js

Server không phải gửi lại tiêu đề, ảnh, link của thông báo client đã có. Điểm yếu: hàng rút gọn mà client không có bản đầy đủ thì bị bỏ lặng lẽ. Có lẽ server dựa vào thông tin client gửi lên để chỉ dùng hàng rút gọn cho id client chắc chắn có, nhưng handler không tự bảo vệ trường hợp đó.

#Bên trong: vì sao XOR?

flowchart LR
  A["old = 01 (thấy, chưa đọc)"] --> X{"old ^ new"}
  B["new = 11 (đã đọc)"] --> X
  X --> D["diff = 10"]
  D -->|"diff & 1 = 0"| S["không phát seen"]
  D -->|"diff & 2 = 2"| R["phát read → danh sách vẽ lại"]

XOR của hai số cho ra bit 1 ở đúng những vị trí khác nhau. Với 2 bit, diff & 1 và diff & 2 trả lời ngay "bit nào đổi" mà không cần if cho từng tổ hợp trạng thái. Thêm bit thứ ba (ví dụ "đã ẩn") thì chỉ cần thêm một dòng if (diff & HIDDEN).

#Viết lại

#BulkValue

src/lib/bulkEdit.ts
export function aggregate<T>(values: T[], equal = deepEqual): BulkValue<T> {
  if (values.every(isEmptyLike)) return EMPTY;
  const first = values[0];
  if (values.every((v) => equal(v, first))) return { kind: 'uniform', value: first };
  return { kind: 'mixed', values };
}
ts
src/lib/bulkEdit.ts
export function deaggregateDeep(tree: BulkTree, count: number): Record<string, unknown>[] {
  const out = Array.from({ length: count }, () => ({}) as Record<string, unknown>);
  const walk = (node: BulkTree, path: string[]) => {
    for (const [key, child] of Object.entries(node)) {
      const childPath = [...path, key];
      if (isBulkValue(child)) {
        out.forEach((object, i) => {
          const value = valueAt(child, i);
          if (value !== undefined) setByPath(object, childPath, value);
        });
      } else {
        walk(child, childPath);
      }
    }
  };
  walk(tree, []);
  return out;
}
ts
Xem toàn bộ src/lib/bulkEdit.ts
src/lib/bulkEdit.ts
/**
 * Sửa hàng loạt kiểu Ads Manager (ý tưởng từ AdsBulkValueUtils / AdsEmptyValue /
 * AdsUniformValue / AdsMixedValue của Facebook).
 *
 * Chọn N chiến dịch rồi mở một form: mỗi field trong form không giữ "một giá trị" mà giữ
 * một BulkValue cho cả N dòng:
 *   - empty:   cả N dòng đều rỗng (null, '', [], Set rỗng, {} rỗng)
 *   - uniform: cả N dòng cùng một giá trị
 *   - mixed:   mỗi dòng một giá trị, giữ nguyên mảng N giá trị
 * Sửa một field thì chỉ field đó thành uniform; field không đụng tới giữ đúng giá trị
 * riêng của từng dòng khi tách ngược ra N object.
 */

export type BulkValue<T = unknown> =
  | { kind: 'empty' }
  | { kind: 'uniform'; value: T }
  | { kind: 'mixed'; values: T[] };

const EMPTY: BulkValue<never> = Object.freeze({ kind: 'empty' });

export function isEmptyLike(value: unknown): boolean {
  if (value === undefined || value === null || value === '') return true;
  if (Array.isArray(value)) return value.length === 0;
  if (value instanceof Set || value instanceof Map) return value.size === 0;
  if (Object.prototype.toString.call(value) === '[object Object]') {
    return Object.keys(value as object).length === 0;
  }
  return false;
}

/** So sánh sâu đơn giản cho dữ liệu JSON (đủ cho form). */
export function deepEqual(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const ka = Object.keys(a as object);
  const kb = Object.keys(b as object);
  if (ka.length !== kb.length) return false;
  return ka.every((k) =>
    deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
  );
}

/** Thử lần lượt empty → uniform → mixed, giống thứ tự AdsEmptyUniformMixedBulkValueTypes. */
export function aggregate<T>(values: T[], equal = deepEqual): BulkValue<T> {
  if (values.every(isEmptyLike)) return EMPTY;
  const first = values[0];
  if (values.every((v) => equal(v, first))) return { kind: 'uniform', value: first };
  return { kind: 'mixed', values };
}

export function match<T, R>(
  bulk: BulkValue<T>,
  cases: { empty: () => R; uniform: (value: T) => R; mixed: (values: T[]) => R },
): R {
  if (bulk.kind === 'empty') return cases.empty();
  if (bulk.kind === 'uniform') return cases.uniform(bulk.value);
  return cases.mixed(bulk.values);
}

/** Giá trị cho dòng thứ i; undefined nghĩa là "không ghi gì". */
export function valueAt<T>(bulk: BulkValue<T>, index: number): T | undefined {
  if (bulk.kind === 'empty') return undefined;
  if (bulk.kind === 'uniform') return bulk.value;
  return bulk.values[index];
}

// ---- gộp/tách sâu theo path ----

/** Cây field: lá là `true`, nhánh là object con. */
export type Shape = { [key: string]: true | Shape };
export type BulkTree = { [key: string]: BulkValue | BulkTree };

export function isBulkValue(value: unknown): value is BulkValue {
  return (
    typeof value === 'object' &&
    value !== null &&
    'kind' in value &&
    ['empty', 'uniform', 'mixed'].includes((value as { kind: string }).kind)
  );
}

export function getByPath(object: unknown, path: string[]): unknown {
  let current = object;
  for (const key of path) {
    if (current == null || typeof current !== 'object') return undefined;
    current = (current as Record<string, unknown>)[key];
  }
  return current;
}

export function setByPath(object: Record<string, unknown>, path: string[], value: unknown) {
  let current = object;
  path.slice(0, -1).forEach((key) => {
    if (typeof current[key] !== 'object' || current[key] === null) current[key] = {};
    current = current[key] as Record<string, unknown>;
  });
  current[path[path.length - 1]] = value;
}

/** N object → một cây BulkValue theo shape. */
export function aggregateDeep(objects: object[], shape: Shape, path: string[] = []): BulkTree {
  const tree: BulkTree = {};
  for (const [key, child] of Object.entries(shape)) {
    const childPath = [...path, key];
    tree[key] =
      child === true
        ? aggregate(objects.map((o) => getByPath(o, childPath)))
        : aggregateDeep(objects, child, childPath);
  }
  return tree;
}

/** Cây BulkValue → N object. Lá empty thì không ghi (giống hàm `z` của bản gốc). */
export function deaggregateDeep(tree: BulkTree, count: number): Record<string, unknown>[] {
  const out = Array.from({ length: count }, () => ({}) as Record<string, unknown>);
  const walk = (node: BulkTree, path: string[]) => {
    for (const [key, child] of Object.entries(node)) {
      const childPath = [...path, key];
      if (isBulkValue(child)) {
        out.forEach((object, i) => {
          const value = valueAt(child, i);
          if (value !== undefined) setByPath(object, childPath, value);
        });
      } else {
        walk(child, childPath);
      }
    }
  };
  walk(tree, []);
  return out;
}

/** Sửa một field trên form: lá đó thành uniform (hoặc empty nếu giá trị rỗng). */
export function editLeaf(tree: BulkTree, path: string[], value: unknown): BulkTree {
  const [head, ...rest] = path;
  const node = tree[head];
  if (rest.length === 0) return { ...tree, [head]: isEmptyLike(value) ? EMPTY : { kind: 'uniform', value } };
  if (!node || isBulkValue(node)) throw new Error(`Path không tồn tại: ${path.join('.')}`);
  return { ...tree, [head]: editLeaf(node, rest, value) };
}

/** Liệt kê các lá đã đổi so với cây gốc (dùng để chỉ gửi field bị sửa lên server). */
export function changedPaths(before: BulkTree, after: BulkTree, path: string[] = []): string[] {
  const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
  const out: string[] = [];
  keys.forEach((key) => {
    const a = before[key];
    const b = after[key];
    const childPath = [...path, key];
    if (isBulkValue(a) || isBulkValue(b)) {
      if (!deepEqual(a, b)) out.push(childPath.join('.'));
    } else if (a && b) {
      out.push(...changedPaths(a, b, childPath));
    }
  });
  return out;
}
ts

#Seen/read và refetch rút gọn

src/lib/seenState.ts
  function apply(next: Record<string, SeenBits>) {
    const seenChanged: string[] = [];
    const readChanged: string[] = [];
    for (const [id, value] of Object.entries(next)) {
      const old = states.get(id);
      states.set(id, value);
      if (old === undefined) {
        // id mới: cả badge lẫn danh sách đều cần biết
        seenChanged.push(id);
        readChanged.push(id);
        continue;
      }
      const diff = old ^ value;
      if (diff & SEEN) seenChanged.push(id);
      if (diff & READ) readChanged.push(id);
    }
    if (seenChanged.length) listeners.seen.forEach((l) => l(seenChanged));
    if (readChanged.length) listeners.read.forEach((l) => l(readChanged));
  }
ts
src/lib/seenState.ts
/**
 * Refetch đầu danh sách: ghép hàng rút gọn với bản đầy đủ đang có.
 * Bản gốc lặng lẽ bỏ hàng rút gọn không tìm thấy trong cache; ở đây trả `missing`
 * để gọi lại server lấy bản đầy đủ thay vì làm mất thông báo.
 */
export function mergeRefetch(current: FullRow[], incoming: ServerRow[]) {
  const byId = new Map(current.map((row) => [row.id, row]));
  const rows: FullRow[] = [];
  const missing: string[] = [];
  const seen = new Set<string>();
  for (const row of incoming) {
    if (seen.has(row.id)) continue;
    seen.add(row.id);
    if (!isCached(row)) {
      rows.push(row);
      continue;
    }
    const old = byId.get(row.id);
    if (old) rows.push({ ...old, state: row.state });
    else missing.push(row.id);
  }
  return { rows, missing };
}
ts
Xem toàn bộ src/lib/seenState.ts
src/lib/seenState.ts
/**
 * Trạng thái "đã thấy / đã đọc" của thông báo bằng 2 bit, và danh sách "thin client"
 * (ý tưởng từ NotificationSeenState và CometNotificationsThinClientConnectionHandler).
 *
 * SEEN = bit 1 (badge đỏ trên chuông), READ = bit 2 (chấm xanh trên từng thông báo).
 * Đọc kéo theo thấy, nên chỉ có 3 trạng thái hợp lệ: 0, 1, 3.
 * Khi cập nhật, `old ^ new` cho biết bit nào đổi, rồi phát hai sự kiện riêng:
 * badge chỉ nghe "seen", danh sách chỉ nghe "read".
 */

export const SEEN = 1;
export const READ = 2;
export const UNSEEN_AND_UNREAD = 0;
export const SEEN_BUT_UNREAD = SEEN;
export const SEEN_AND_READ = SEEN | READ;

export type SeenBits = 0 | 1 | 3;
export type PayloadSource = 'endpoint' | 'push' | 'sync';
type Listener = (ids: string[]) => void;

export function createSeenStore() {
  const states = new Map<string, SeenBits>();
  const listeners = { seen: new Set<Listener>(), read: new Set<Listener>() };

  /** Áp một loạt trạng thái mới, phát sự kiện theo bit thực sự đổi. */
  function apply(next: Record<string, SeenBits>) {
    const seenChanged: string[] = [];
    const readChanged: string[] = [];
    for (const [id, value] of Object.entries(next)) {
      const old = states.get(id);
      states.set(id, value);
      if (old === undefined) {
        // id mới: cả badge lẫn danh sách đều cần biết
        seenChanged.push(id);
        readChanged.push(id);
        continue;
      }
      const diff = old ^ value;
      if (diff & SEEN) seenChanged.push(id);
      if (diff & READ) readChanged.push(id);
    }
    if (seenChanged.length) listeners.seen.forEach((l) => l(seenChanged));
    if (readChanged.length) listeners.read.forEach((l) => l(readChanged));
  }

  return {
    /**
     * Payload tải trang ('endpoint') không được ghi đè id đã biết: dữ liệu đó có thể cũ hơn
     * trạng thái vừa nhận qua push/sync. Push và sync thì luôn ghi đè.
     */
    receive(items: Array<{ id: string; state: SeenBits }>, source: PayloadSource) {
      const next: Record<string, SeenBits> = {};
      items.forEach(({ id, state }) => {
        if (source === 'endpoint' && states.has(id)) return;
        next[id] = state;
      });
      apply(next);
    },
    /** Mở chuông: mọi id được đánh dấu đã thấy (giữ nguyên bit đọc). */
    markSeen(ids: string[]) {
      const next: Record<string, SeenBits> = {};
      ids.forEach((id) => {
        const old = states.get(id);
        if (old !== undefined) next[id] = (old | SEEN) as SeenBits;
      });
      apply(next);
    },
    /** Đọc thì kéo theo đã thấy; bỏ đọc chỉ xoá bit READ. */
    markRead(ids: string[], read = true) {
      const next: Record<string, SeenBits> = {};
      ids.forEach((id) => {
        const old = states.get(id);
        if (read) next[id] = SEEN_AND_READ as SeenBits;
        else if (old !== undefined) next[id] = (old & ~READ) as SeenBits;
      });
      apply(next);
    },
    get: (id: string) => states.get(id),
    unseenCount: () => [...states.values()].filter((s) => s === UNSEEN_AND_UNREAD).length,
    unreadCount: () => [...states.values()].filter((s) => s !== SEEN_AND_READ).length,
    on(type: 'seen' | 'read', listener: Listener) {
      listeners[type].add(listener);
      return () => listeners[type].delete(listener);
    },
  };
}

export type SeenStore = ReturnType<typeof createSeenStore>;

// ---- "thin client": server chỉ gửi id cho thông báo client đã có ----

export interface FullRow {
  id: string;
  text: string;
  time: number;
  state: SeenBits;
}
/** Hàng rút gọn: server biết client đã có nội dung, chỉ gửi id và trạng thái mới. */
export interface CachedRow {
  id: string;
  cached: true;
  state: SeenBits;
}
export type ServerRow = FullRow | CachedRow;

export const isCached = (row: ServerRow): row is CachedRow => 'cached' in row;

/**
 * Refetch đầu danh sách: ghép hàng rút gọn với bản đầy đủ đang có.
 * Bản gốc lặng lẽ bỏ hàng rút gọn không tìm thấy trong cache; ở đây trả `missing`
 * để gọi lại server lấy bản đầy đủ thay vì làm mất thông báo.
 */
export function mergeRefetch(current: FullRow[], incoming: ServerRow[]) {
  const byId = new Map(current.map((row) => [row.id, row]));
  const rows: FullRow[] = [];
  const missing: string[] = [];
  const seen = new Set<string>();
  for (const row of incoming) {
    if (seen.has(row.id)) continue;
    seen.add(row.id);
    if (!isCached(row)) {
      rows.push(row);
      continue;
    }
    const old = byId.get(row.id);
    if (old) rows.push({ ...old, state: row.state });
    else missing.push(row.id);
  }
  return { rows, missing };
}

/** Phân trang tiếp (after = endCursor): nối vào cuối, khử trùng theo id. */
export function appendPage(current: FullRow[], page: FullRow[]) {
  const ids = new Set(current.map((r) => r.id));
  return [...current, ...page.filter((r) => !ids.has(r.id))];
}

/** Ước lượng số byte JSON, để demo so sánh hàng đầy đủ với hàng rút gọn. */
export const jsonBytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).length;
ts

#Đi qua code

  • aggregate dùng deepEqual thay cho ===, vì giá trị trong form có thể là mảng hoặc object (danh sách quốc gia, khoảng tuổi).
  • valueAt gom logic "dòng thứ i nhận gì" vào một chỗ. Trả undefined nghĩa là không ghi, giống getValueForIndex của bản gốc.
  • editLeaf không sửa cây gốc mà trả về cây mới, để changedPaths(original, edited) so được trước và sau.
  • Trong demo, khi Lưu, chỉ các path có trong changedPaths được ghi vào từng dòng. Như vậy dù cây có lá mixed, request gửi lên server chỉ chứa field người dùng thật sự đổi.
  • createSeenStore.apply giữ đúng ý của o() trong bản gốc: id mới thì phát cả hai sự kiện, id cũ thì XOR.
  • receive(items, 'endpoint') bỏ qua id đã biết. push và sync thì luôn ghi đè.
  • mergeRefetch trả thêm missing: những id server gửi dạng rút gọn mà client không có. Gọi lại server lấy bản đầy đủ thay vì làm mất thông báo.

Khác bản gốc: bản viết lại dùng kiểu union của TypeScript thay cho ba class; match là hàm thay vì method. mergeRefetch báo id thiếu thay vì bỏ qua. Bộ seen/read dùng Map và listener riêng cho từng sự kiện thay cho hệ thống pub/sub NotificationUpdates.

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

Dùng null cho ô có nhiều giá trị? Khi đó không phân biệt được "mọi dòng đều trống" với "mỗi dòng một giá trị". Một bên nên hiện ô trống, một bên nên hiện "Nhiều giá trị". Khi Lưu, ô trống do người dùng xoá phải ghi null cho mọi dòng, còn ô mixed không đụng tới thì không được ghi gì.

Gửi lên server cả N object đầy đủ? Được, nếu không có ai khác sửa cùng lúc. Nhưng Ads Manager là công cụ nhiều người dùng chung một tài khoản quảng cáo. Gửi đủ mọi field có nghĩa là ghi đè thay đổi của người khác lên những field mình không đụng tới.

Hai boolean isSeen, isRead thay cho 2 bit? Logic tương đương, nhưng phải tự so từng boolean để biết sự kiện nào cần phát. Bitmask gọn hơn, và lưu được trong một số nguyên khi gửi qua mạng.

Mỗi thông báo tự nghe store và tự vẽ lại? Với vài chục thông báo thì không sao. Tách sự kiện seen/read giúp tránh cả danh sách vẽ lại khi chỉ có badge cần đổi, một việc xảy ra mỗi lần người dùng bấm chuông.

#Edge case và cạm bẫy

  • Rỗng thế nào mới là rỗng? 0 và false không rỗng. Nếu coi 0 là rỗng, ngân sách 0 của mọi dòng sẽ hiện ô trống thay vì 0.
  • Đặt về rỗng có chủ đích. Người dùng xoá trắng ô ngân sách: editLeaf biến lá thành empty, và deaggregateDeep sẽ không ghi gì. Demo xử lý bằng cách ghi null cho path có trong changedPaths, để "xoá" vẫn được gửi đi. Nếu dùng nguyên deaggregateDeep mà không có danh sách thay đổi, thao tác xoá sẽ mất.
  • So sánh object có thứ tự key khác nhau. deepEqual trong bài so theo key nên {a, b} và {b, a} bằng nhau. Mảng thì so theo thứ tự.
  • Thông báo bị "bỏ đọc" khi chưa thấy? Bản gốc không có trạng thái 2, và markRead(false) chỉ xoá bit READ nên không bao giờ tạo ra trạng thái này.
  • Id lạ trong markSeen. Bản gốc chỉ cập nhật id đã biết; id chưa từng nhận thì bị bỏ qua. Bản viết lại giữ hành vi đó.

#Dùng trong dự án

const shape = { status: true, budget: true, targeting: { minAge: true } } as const;

function BulkEditForm({ rows, onSave }: { rows: Campaign[]; onSave: (patches: Patch[]) => void }) {
  const original = useMemo(() => aggregateDeep(rows, shape), [rows]);
  const [tree, setTree] = useState(original);
  const changes = changedPaths(original, tree);

  const save = () => {
    const values = deaggregateDeep(tree, rows.length);
    onSave(rows.map((row, i) => ({
      id: row.id,
      // chỉ gửi field đã đổi; field xoá trắng gửi null
      changes: Object.fromEntries(changes.map((p) => [p, getByPath(values[i], p.split('.')) ?? null])),
    })));
  };
  // input: value = leaf.kind === 'uniform' ? leaf.value : '', placeholder = leaf.kind === 'mixed' ? 'Nhiều giá trị' : ''
}
tsx

Với thông báo:

const store = createSeenStore();
store.on('seen', () => setBadge(store.unseenCount()));   // component chuông
store.on('read', (ids) => rerenderRows(ids));            // danh sách
socket.on('notification_state', (items) => store.receive(items, 'push'));
fetchNotifications().then((items) => store.receive(items, 'endpoint'));
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { aggregate, aggregateDeep, changedPaths, deaggregateDeep, editLeaf } from './bulkEdit';
import { createSeenStore, mergeRefetch } from './seenState';

describe('bulk edit', () => {
  it('empty → uniform → mixed theo thứ tự', () => {
    expect(aggregate([null, '', [], {}])).toEqual({ kind: 'empty' });
    expect(aggregate([5, 5, 5])).toEqual({ kind: 'uniform', value: 5 });
    expect(aggregate([5, 3])).toEqual({ kind: 'mixed', values: [5, 3] });
  });

  it('sửa một lá chỉ đổi lá đó, lá mixed giữ giá trị từng dòng', () => {
    const rows = [
      { budget: 500, targeting: { minAge: 18 } },
      { budget: 300, targeting: { minAge: 18 } },
    ];
    const tree = aggregateDeep(rows, { budget: true, targeting: { minAge: true } });
    const edited = editLeaf(tree, ['targeting', 'minAge'], 21);
    expect(changedPaths(tree, edited)).toEqual(['targeting.minAge']);
    expect(deaggregateDeep(edited, 2)).toEqual([
      { budget: 500, targeting: { minAge: 21 } },
      { budget: 300, targeting: { minAge: 21 } },
    ]);
  });
});

describe('seen/read', () => {
  it('XOR tách sự kiện seen và read', () => {
    const store = createSeenStore();
    store.receive([{ id: 'a', state: 0 }, { id: 'b', state: 0 }], 'endpoint');
    const seen = vi.fn();
    const read = vi.fn();
    store.on('seen', seen);
    store.on('read', read);
    store.markSeen(['a', 'b']);
    expect(seen).toHaveBeenCalledWith(['a', 'b']);
    expect(read).not.toHaveBeenCalled();
    store.markRead(['a']);
    expect(read).toHaveBeenCalledWith(['a']);
    expect(seen).toHaveBeenCalledTimes(1);
  });

  it('payload endpoint không ghi đè id đã biết', () => {
    const store = createSeenStore();
    store.receive([{ id: 'a', state: 0 }], 'endpoint');
    store.markRead(['a']);
    store.receive([{ id: 'a', state: 0 }], 'endpoint');
    expect(store.get('a')).toBe(3);
  });

  it('refetch rút gọn báo id thiếu', () => {
    const { rows, missing } = mergeRefetch(
      [{ id: 'a', text: 'A', time: 1, state: 0 }],
      [{ id: 'a', cached: true, state: 3 }, { id: 'z', cached: true, state: 0 }],
    );
    expect(rows[0]).toMatchObject({ id: 'a', text: 'A', state: 3 });
    expect(missing).toEqual(['z']);
  });
});
ts

#So với thư viện

  • Form library như React Hook Form hay Formik không có khái niệm "giá trị cho N bản ghi". Cách phổ biến là tự giữ dirtyFields và chỉ gửi field đã đổi; BulkValue là bước tiếp theo, thêm trạng thái mixed để hiển thị đúng.
  • JSON Patch (RFC 6902) và JSON Merge Patch (RFC 7396) là chuẩn để mô tả "chỉ những field đã đổi" khi gửi lên server. changedPaths trong bài tương đương với việc tạo các thao tác replace của JSON Patch.
  • Bitmask cho trạng thái là kỹ thuật cũ, quen thuộc ở quyền file Unix (rwx). Các API như Notification của trình duyệt không quản lý seen/read; đó luôn là logic của ứng dụng.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

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