Web Totals

Học từ web lớn #63: Prefetch không làm sai số liệu, cờ dạng bit (Outlook)

Outlook gọi trước request cho group hay mở nhưng gắn header để server không đếm là lượt dùng thật, và gom các cờ giao diện vào một số nguyên.

hunghg255hunghg25524 min read
#web-patterns#outlook#prefetch#analytics#bit-flags
Học từ web lớn #63: Prefetch không làm sai số liệu, cờ dạng bit (Outlook)

Bài này gom hai kỹ thuật nhỏ của Outlook trên web. Chúng không liên quan về chức năng, nhưng có chung một tinh thần: dữ liệu sản phẩm (số liệu, cấu hình) cũng là một phần của API, phải giữ cho đúng như code.

  1. Prefetch có đánh dấu "synthetic". 10 giây sau khi khởi động, Outlook gọi trước một request rất nhẹ cho tối đa 5 group người dùng hay mở, để lúc họ bấm vào thì server đã sẵn sàng. Request đó mang một header riêng, để server không đếm nó là người dùng mở group. Không có header, mỗi lần prefetch là một "lượt mở" giả, số liệu engagement của tính năng Group tăng vọt mà không ai dùng thêm.
  2. Bit flags. Hàng chục lựa chọn bật/tắt của giao diện (ẩn ảnh người gửi, thu gọn cột thư mục, tắt tin tức…) được lưu trong một số nguyên 32 bit trong cấu hình người dùng trên server, mỗi lựa chọn một bit.

Tip

Tóm tắt

  • Prefetch, warm-up, kiểm tra sức khoẻ… là request do máy tạo ra. Gắn header để server và hệ thống số liệu tách chúng khỏi hành vi thật của người dùng.
  • Đo hiệu quả của prefetch ngay trong số liệu của hành động thật: Outlook ghi "đã warm bao lâu trước khi người dùng mở" vào datapoint chuyển group.
  • Bit flags tiết kiệm chỗ và số request ghi, nhưng vị trí bit là hợp đồng vĩnh viễn: không đổi, không dùng lại.
  • Code bit flags của Outlook có một lỗi nhỏ: chỉ ghi khi giá trị hiện tại khác 0, nên từ 0 thì không bật được cờ nào.

Bấm Khởi động, chờ 2 giây (Outlook là 10 giây) cho bốn group được đánh dấu "warm", rồi mở "Thiết kế" (khoảng 120ms) và "Pháp chế" (900ms vì chưa warm). Đổi sang Không gắn header rồi khởi động lại: người dùng chỉ mở 2 lần nhưng server đếm 6. Ở khung bit flags, bật "Mô phỏng bản gốc", về 0 rồi tick bất kỳ cờ nào: giá trị lưu vẫn là 0.

#Outlook làm thế nào?

#Warm-up group hay dùng

Code nằm ở owa-group-prefetch-actions/src/actions/warmUpFrequentlyUsedGroups.ts, được gọi một lần sau khi danh sách group trong thanh bên trái tải xong (owa-group-left-nav-actions/src/actions/loadGroups.ts).

const MAX_GROUPS_TO_WARM_UP = 5;
const GROUP_PREFETCH_DELAY_MS: number = 10000; // 10 seconds
const MAX_DAYS_SINCE_LAST_VISIT: number = 30; // Max days since last visit to be considered a groups user

// This needs to be in Sync with SyntheticActionSource in %SDXROOT%\sources\dev\services\src\Core\Search\NormalQueryView.cs
// so that these calls don't count for engagement
const SyntheticGroupActionSource = 'GroupModulePrefetch';
const ActionSourceHeaderName = 'X-OWA-ActionSource';
ts

Chú thích nói rõ mục đích: giá trị header phải khớp với một hằng số phía server (file C# của Exchange), để các lời gọi này "không bị tính vào engagement".

Luồng chạy:

  1. Cờ cấp module wasGroupsWarmUpTriggered bảo đảm chỉ chạy một lần cho cả phiên.
  2. Chờ 10 giây: lúc khởi động là lúc mạng và CPU bận nhất, việc phụ để sau.
  3. Nếu người dùng không mở group nào trong 30 ngày (isGroupsSpaceUser), bỏ qua: người không dùng group thì không tốn request.
  4. Chọn group: trước hết là group yêu thích có mở trong 30 ngày; chưa đủ 5 thì lấy thêm group không yêu thích nằm trong 5 vị trí đầu thanh bên (MAX_GROUP_NODES_WHEN_COLLAPSED = 5, số group hiện khi danh sách thu gọn) và cũng có mở trong 30 ngày.
  5. Với mỗi group, gọi API lấy danh sách thư nhưng chỉ 1 dòng, kèm hai header:
options.headers = new Headers();
options.headers.set(ActionSourceHeaderName, SyntheticGroupActionSource); // 'GroupModulePrefetch'
options.headers.set(ActionNameHeaderName, GroupWarmUpActionName); // 'GroupMailbox_WarmUp'
ts
  1. Lỗi của request warm-up bị nuốt: .catch(error => { return; }), kèm chú thích "để không đẩy lên hệ thống cảnh báo lỗi".

Kết quả của request (1 dòng thư) không được dùng ở client: promise trả về không ai đọc. Vậy mục đích là phía server: có lẽ để Exchange nạp mailbox của group vào bộ nhớ đệm, lần mở thật đầu tiên khỏi phải đọc từ đĩa (suy đoán từ cách dùng, code server không có trong dump).

#Đo xem warm-up có đáng không

Một kho nhỏ (owa-group-prefetch-store/src/store.ts) nhớ danh sách group đã warm và thời điểm warm. Khi người dùng mở group thật, owa-mail-list-actions/src/actions/table/loadInitialGroupTable.ts gắn vào datapoint hiệu năng của thao tác chuyển group:

const groupId = getGroupIdFromTableQuery(tableView.tableQuery);
if (wasGroupWarmedUp(groupId)) {
    const groupsWarmUpTime = getGroupsWarmUpTime();
    datapoint.addCustomProperty('TimeSinceWarmUp', Date.now() - groupsWarmUpTime);
}
ts

Nhờ vậy nhóm phát triển so được thời gian mở group có và không warm-up, và biết warm-up bao lâu thì còn tác dụng. Một tối ưu mà không đo thì không biết có nên giữ.

#Bit flags

Thư mục owa-bit-flags/src/utils/ có ba bộ cờ: giao diện danh sách thư, Focused Inbox, trợ năng. Bộ đầu tiên:

/* PLEASE DO NOT CHANGE THE BIT POSITIONS FOR THE FLAGS STORED IN ListViewBitFlagsMasks */
export const enum ListViewBitFlagsMasks {
    None = 0,
    DateHeadersDisabled = 1 << 1,
    FolderPaneCollapsed = 1 << 2,
    HideSenderImage = 1 << 3,
    LikeSurfaceActionFirstRun = 1 << 4,
    IsCommercialUserInitialized = 1 << 5,
    // … tới 1 << 12
    // MAX = 1 << 31
}
ts

Số nguyên này nằm trong ViewStateConfiguration của cấu hình người dùng, lưu ở server. Khi đổi một cờ, ví dụ thu gọn cột thư mục (owa-mail-layout/src/services/setFolderPaneCollapsedState.ts), cả số nguyên được ghi lên server với kiểu Integer32.

Bộ cờ Focused Inbox còn có chú thích thú vị: vài bit đầu của số nguyên đang được bản Outlook cũ (JsMvvm) dùng để nhớ tab cuối cùng người dùng xem, và chú thích ghi rõ bit nào còn trống. Hai thế hệ client cùng đọc ghi một số nguyên, nên bit nào đã có chủ thì bản mới phải tránh.

#Lỗi nhỏ: từ 0 thì không bật được

export function setBit(value: boolean, bitMask: ListViewBitFlagsMasks) {
    // Already same value, no need to set again and update user config
    if (getIsBitSet(bitMask) === value) {
        return;
    }

    updateUserConfiguration(userConfig => {
        if (userConfig.ViewStateConfiguration?.ListViewBitFlags) {
            if (value) {
                userConfig.ViewStateConfiguration.ListViewBitFlags |= bitMask;
            } else {
                userConfig.ViewStateConfiguration.ListViewBitFlags &= ~bitMask;
            }
        }
    });
}
ts

Điều kiện if (…ListViewBitFlags) kiểm "có giá trị", nhưng 0 là falsy trong JavaScript. Khi số nguyên đang là 0 (chưa bật cờ nào), setBit(true, …) không làm gì, rồi setFolderPaneCollapsedState ghi lên server đúng số 0 đó: lựa chọn của người dùng biến mất. Có lẽ ý định là "bỏ qua khi ViewStateConfiguration chưa có", nhưng viết thành kiểm cả số.

Trong thực tế lỗi này ít lộ ra: chỉ cần một cờ nào đó đã bật (ví dụ IsCommercialUserInitialized cho tài khoản doanh nghiệp) là số khác 0. Bộ cờ tương tự trong owa-favorites (favoritesBitFlagsActions.ts) không có điều kiện này, nên không bị.

#Bên trong: header, CORS và số liệu

flowchart LR
    A["App khởi động"] --> B{"Mở group trong 30 ngày?"}
    B -- "không" --> X["Bỏ qua"]
    B -- "có" --> C["Chờ 10 giây"]
    C --> D["Chọn tối đa 5 group"]
    D --> E["Request 1 dòng, header synthetic"]
    E --> F["Server nạp sẵn dữ liệu group"]
    E --> G["Hệ thống số liệu bỏ qua request synthetic"]
    H["Người dùng mở group"] --> I["Request thật, không header"]
    I --> F
    I --> J["Đếm 1 lượt mở, ghi TimeSinceWarmUp"]

Hai điều cần biết khi gắn header tự đặt:

  • Header tự đặt làm request "không đơn giản". Gửi sang origin khác, trình duyệt phải hỏi trước bằng một request OPTIONS (preflight CORS), và server phải liệt kê header đó trong Access-Control-Allow-Headers. Outlook gọi API cùng origin nên không gặp.
  • Đánh dấu ở client, lọc ở server. Client chỉ có thể khai báo; việc bỏ qua khi đếm phải nằm ở chỗ tính số liệu. Hai nơi phải dùng chung một hằng số, như chú thích "needs to be in Sync" của Outlook.

#Viết lại

Phần warm-up tách thành hàm chọn group (pickWarmUpTargets, hàm thuần, dễ test) và bộ chạy một lần:

src/lib/syntheticPrefetch.ts
export function createWarmUp({ candidates, warm, delayMs = 10_000, pick }: WarmUpOptions) {
  let triggered = false;
  let timer: ReturnType<typeof setTimeout> | undefined;
  let warmed = new Set<string>();
  let warmedAt = 0;

  return {
    /** gọi bao nhiêu lần cũng chỉ chạy một lần */
    trigger() {
      if (triggered) return;
      triggered = true;
      timer = setTimeout(() => {
        const ids = pickWarmUpTargets(candidates(), pick);
        // Outlook: nếu người dùng không mở group nào trong 30 ngày thì thôi, không warm gì cả
        if (ids.length === 0) return;
        warmedAt = Date.now();
        warmed = new Set(ids);
        for (const id of ids) {
          const headers = new Headers({ [SYNTHETIC_SOURCE_HEADER]: 'prefetch' });
          // lỗi của request phụ không được làm phiền ai: nuốt, không đẩy lên hệ thống báo lỗi
          warm(id, headers).catch(() => {});
        }
      }, delayMs);
    },
    /** để gắn vào số liệu khi người dùng mở group thật: group này có được warm không, cách bao lâu */
    measure(id: string): { warmed: boolean; msSinceWarmUp?: number } {
      return warmed.has(id) ? { warmed: true, msSinceWarmUp: Date.now() - warmedAt } : { warmed: false };
    },
    cancel() {
      clearTimeout(timer);
    },
  };
}
ts

Phần bit flags định nghĩa cờ bằng vị trí bit thay vì mặt nạ, kiểm trùng và chặn bit 31:

src/lib/syntheticPrefetch.ts
export function defineBitFlags<const T extends Record<string, number>>(positions: T) {
  for (const [name, bit] of Object.entries(positions)) {
    // bit 31 là bit dấu của Integer32 bên server: tránh luôn cho khỏi lẫn số âm
    if (!Number.isInteger(bit) || bit < 0 || bit > 30) throw new RangeError(`${name}: bit ${bit} ngoài 0–30`);
  }
  const bits = Object.values(positions);
  if (new Set(bits).size !== bits.length) throw new Error('hai cờ trùng một vị trí bit');

  const mask = (name: keyof T) => 1 << positions[name];
  return {
    has: (value: number | undefined, name: keyof T) => ((value ?? 0) & mask(name)) !== 0,
    /** trả về số mới; giá trị đang là 0 hoặc chưa có vẫn đặt được (xem "Khác bản gốc" trong bài) */
    set: (value: number | undefined, name: keyof T, on: boolean) =>
      on ? (value ?? 0) | mask(name) : (value ?? 0) & ~mask(name),
    names: Object.keys(positions) as (keyof T)[],
    positions,
  };
}
ts
Xem toàn bộ src/lib/syntheticPrefetch.ts
src/lib/syntheticPrefetch.ts
/**
 * Hai kỹ thuật nhỏ trong Outlook Web:
 *
 * 1. "Warm up" có đánh dấu synthetic (owa-group-prefetch-actions/warmUpFrequentlyUsedGroups.ts):
 *    10 giây sau khi khởi động, gọi trước một request rất nhẹ (lấy 1 dòng) cho tối đa 5 group
 *    người dùng hay mở, để server nạp sẵn dữ liệu của group vào cache. Request mang header
 *    `X-OWA-ActionSource: GroupModulePrefetch` để server KHÔNG tính nó là người dùng mở group.
 *
 * 2. Bit flags (owa-bit-flags): gom nhiều cờ bật/tắt của giao diện vào MỘT số nguyên lưu ở
 *    cấu hình người dùng trên server.
 */

// ---------------------------------------------------------------------------
// 1. Warm-up đánh dấu synthetic
// ---------------------------------------------------------------------------

export const SYNTHETIC_SOURCE_HEADER = 'X-Action-Source';

export interface WarmUpCandidate {
  id: string;
  favorite: boolean;
  /** lần cuối người dùng mở (ms since epoch), không có = chưa mở bao giờ */
  lastVisited?: number;
  /** vị trí trong thanh điều hướng (0 = trên cùng) */
  rank: number;
}

export interface PickOptions {
  max?: number;
  /** chỉ warm group mở trong N ngày gần đây */
  maxDaysSinceVisit?: number;
  /** chỉ xét group không yêu thích nằm trong N vị trí đầu (Outlook: số group hiện khi thu gọn) */
  topNonFavorites?: number;
  now?: number;
}

const DAY = 86_400_000;

/** Chọn group để warm: group yêu thích trước, thiếu thì lấy thêm group đứng đầu danh sách. */
export function pickWarmUpTargets(
  candidates: WarmUpCandidate[],
  { max = 5, maxDaysSinceVisit = 30, topNonFavorites = 5, now = Date.now() }: PickOptions = {},
): string[] {
  const recent = (c: WarmUpCandidate) => c.lastVisited !== undefined && now - c.lastVisited <= maxDaysSinceVisit * DAY;
  const sorted = [...candidates].sort((a, b) => a.rank - b.rank);
  const favorites = sorted.filter((c) => c.favorite && recent(c));
  const others = sorted.filter((c) => !c.favorite && c.rank < topNonFavorites && recent(c));
  return [...favorites, ...others].slice(0, max).map((c) => c.id);
}

export interface WarmUpOptions {
  candidates: () => WarmUpCandidate[];
  /** gửi request warm-up; PHẢI chuyển `headers` xuống request thật */
  warm: (id: string, headers: Headers) => Promise<unknown>;
  delayMs?: number;
  pick?: PickOptions;
}

export function createWarmUp({ candidates, warm, delayMs = 10_000, pick }: WarmUpOptions) {
  let triggered = false;
  let timer: ReturnType<typeof setTimeout> | undefined;
  let warmed = new Set<string>();
  let warmedAt = 0;

  return {
    /** gọi bao nhiêu lần cũng chỉ chạy một lần */
    trigger() {
      if (triggered) return;
      triggered = true;
      timer = setTimeout(() => {
        const ids = pickWarmUpTargets(candidates(), pick);
        // Outlook: nếu người dùng không mở group nào trong 30 ngày thì thôi, không warm gì cả
        if (ids.length === 0) return;
        warmedAt = Date.now();
        warmed = new Set(ids);
        for (const id of ids) {
          const headers = new Headers({ [SYNTHETIC_SOURCE_HEADER]: 'prefetch' });
          // lỗi của request phụ không được làm phiền ai: nuốt, không đẩy lên hệ thống báo lỗi
          warm(id, headers).catch(() => {});
        }
      }, delayMs);
    },
    /** để gắn vào số liệu khi người dùng mở group thật: group này có được warm không, cách bao lâu */
    measure(id: string): { warmed: boolean; msSinceWarmUp?: number } {
      return warmed.has(id) ? { warmed: true, msSinceWarmUp: Date.now() - warmedAt } : { warmed: false };
    },
    cancel() {
      clearTimeout(timer);
    },
  };
}

/** Phía server: request có header synthetic thì không cộng vào số liệu "người dùng mở". */
export const isSynthetic = (headers: Headers) => headers.has(SYNTHETIC_SOURCE_HEADER);

// ---------------------------------------------------------------------------
// 2. Bit flags
// ---------------------------------------------------------------------------

/**
 * Định nghĩa cờ bằng vị trí bit CỐ ĐỊNH. Giá trị đã nằm trong cấu hình của hàng triệu người
 * dùng, nên vị trí một khi đã dùng thì không bao giờ đổi hay dùng lại cho cờ khác.
 */
export function defineBitFlags<const T extends Record<string, number>>(positions: T) {
  for (const [name, bit] of Object.entries(positions)) {
    // bit 31 là bit dấu của Integer32 bên server: tránh luôn cho khỏi lẫn số âm
    if (!Number.isInteger(bit) || bit < 0 || bit > 30) throw new RangeError(`${name}: bit ${bit} ngoài 0–30`);
  }
  const bits = Object.values(positions);
  if (new Set(bits).size !== bits.length) throw new Error('hai cờ trùng một vị trí bit');

  const mask = (name: keyof T) => 1 << positions[name];
  return {
    has: (value: number | undefined, name: keyof T) => ((value ?? 0) & mask(name)) !== 0,
    /** trả về số mới; giá trị đang là 0 hoặc chưa có vẫn đặt được (xem "Khác bản gốc" trong bài) */
    set: (value: number | undefined, name: keyof T, on: boolean) =>
      on ? (value ?? 0) | mask(name) : (value ?? 0) & ~mask(name),
    names: Object.keys(positions) as (keyof T)[],
    positions,
  };
}

/**
 * Cập nhật một cờ và chỉ gọi `persist` khi giá trị thật sự đổi (Outlook cũng bỏ qua nếu cờ đã
 * đúng giá trị, đỡ một request ghi cấu hình).
 */
export function updateFlag<T extends Record<string, number>>(
  flags: ReturnType<typeof defineBitFlags<T>>,
  current: number | undefined,
  name: keyof T,
  on: boolean,
  persist: (next: number) => void,
): number {
  if (flags.has(current, name) === on) return current ?? 0;
  const next = flags.set(current, name, on);
  persist(next);
  return next;
}
ts

#Đi qua code

pickWarmUpTargets giữ đúng thứ tự ưu tiên của Outlook: group yêu thích có mở trong 30 ngày, rồi group không yêu thích trong top 5 và cũng có mở gần đây, cắt ở 5. Group chưa mở bao giờ (lastVisited không có) bị loại. Hàm nhận now để test không phụ thuộc đồng hồ thật.

createWarmUp thay cờ cấp module của Outlook bằng biến trong closure: mỗi lần tạo là một bộ đếm riêng, test không ảnh hưởng nhau. trigger() gọi bao nhiêu lần cũng chỉ hẹn giờ một lần. Request lỗi bị nuốt (.catch(() => {})), y như bản gốc: warm-up hỏng thì cùng lắm là lần mở thật chậm như bình thường. Nếu không chọn được group nào thì không ghi thời điểm warm, tương ứng với bước "người không dùng group thì bỏ qua".

measure(id) là phần đo: gắn kết quả vào số liệu của thao tác thật, giống TimeSinceWarmUp.

defineBitFlags nhận { tên: vị trí bit }. Kiểm hai lỗi hay gặp khi thêm cờ mới: trùng vị trí với cờ cũ, và dùng bit 31. Bit 31 là bit dấu của số nguyên 32 bit có dấu: 1 << 31 trong JavaScript ra -2147483648, server lưu Integer32 cũng ra số âm. Không sai, nhưng dễ gây nhầm khi so sánh hay in ra; tránh luôn cho gọn.

set coi undefined là 0 (value ?? 0), sửa đúng chỗ lỗi của bản gốc.

updateFlag chỉ gọi persist khi giá trị thật sự đổi, giữ lại tối ưu "đã đúng thì không ghi" của Outlook.

Khác bản gốc: Outlook dùng const enum với mặt nạ 1 << n và hàm get/set riêng cho từng bộ cờ (ba bản gần giống nhau). Bản viết lại dùng một hàm dựng chung, định nghĩa bằng vị trí bit, và không có lỗi "từ 0 không bật được".

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

Sao không dùng <link rel="prefetch"> hay Speculation Rules? Hai cách đó tải trước tài nguyên cho trình duyệt dùng lại (file, trang). Ở đây thứ cần "nóng" nằm ở server, và kết quả request bị bỏ đi. Một fetch thường với header riêng là đủ và rõ ý.

Sao không lọc request synthetic theo URL? Cùng một API (lấy danh sách thư) được gọi cả bởi người dùng lẫn bởi warm-up. Phân biệt bằng URL thì phải tạo endpoint riêng; header thì tái dùng nguyên API.

Sao không lưu mỗi cờ thành một trường riêng? Mỗi trường mới trong cấu hình người dùng là một lần đổi schema phía server, có khi phải đợi bên backend. Một số nguyên có sẵn 31 chỗ trống, thêm cờ chỉ là sửa client. Đổi lại: không đọc được bằng mắt, không có tên trong cơ sở dữ liệu, và vị trí bit thành hợp đồng không đổi được.

Sao không lưu JSON { hideNews: true, … }? Được, nếu backend cho. Bit flags hợp khi kho cấu hình chỉ nhận kiểu đơn giản (Outlook ghi valuetype: 'Integer32') hoặc khi cần gửi kèm trong mọi request với kích thước nhỏ nhất.

#Edge case và cạm bẫy

  • Warm-up tốn tài nguyên server. 5 request cho mỗi phiên của mỗi người dùng nhân với hàng triệu phiên là con số lớn. Outlook giới hạn chặt: chỉ người dùng group, chỉ 5 group, chỉ 1 dòng, chỉ một lần.
  • Header không đến được số liệu. Nếu số liệu được tính từ log của CDN hay proxy không đọc header, warm-up vẫn bị đếm. Kiểm tra cả đường đi của dữ liệu, không chỉ chỗ gửi.
  • Prefetch làm sai số liệu ở chỗ khác. Thời gian phản hồi trung bình của API đẹp lên nhờ nhiều request 1 dòng; tỉ lệ lỗi thay đổi. Dashboard hiệu năng cũng nên lọc theo header.
  • Bit flags không có lịch sử. Ai đó xoá một cờ khỏi enum rồi người khác dùng lại vị trí đó cho cờ mới: người dùng cũ tự nhiên "đã bật" cờ mới. Giữ vị trí cũ dưới dạng chú thích "đã bỏ, không dùng lại".
  • Ghi đè lẫn nhau. Hai tab cùng đổi hai cờ khác nhau, mỗi tab ghi cả số nguyên lên server: tab ghi sau xoá thay đổi của tab trước. Muốn chắc thì server nhận phép "bật bit X / tắt bit Y" thay vì nhận cả số.
  • Phép toán bit trong JavaScript là 32 bit có dấu. Muốn in dạng nhị phân cho đúng thì dùng (value >>> 0).toString(2).

#Dùng trong dự án

Warm-up kèm đánh dấu, với fetch thường:

import { createWarmUp } from './syntheticPrefetch';

const warmUp = createWarmUp({
  candidates: () => sidebarProjects.map((p, i) => ({ id: p.id, favorite: p.pinned, lastVisited: p.lastOpenedAt, rank: i })),
  warm: (id, headers) => fetch(`/api/projects/${id}/tasks?limit=1`, { headers }),
});
onSidebarLoaded(() => warmUp.trigger());

// khi người dùng mở project thật
const m = warmUp.measure(projectId);
analytics.track('project_open', { durationMs, warmed: m.warmed, msSinceWarmUp: m.msSinceWarmUp });
ts

Phía server, đọc header X-Action-Source và bỏ qua request synthetic khi ghi sự kiện "mở project".

Bit flags cho lựa chọn giao diện:

import { defineBitFlags, updateFlag } from './syntheticPrefetch';

const UI_FLAGS = defineBitFlags({ compactMode: 0, hideAvatars: 1, newsDismissed: 2 }); // vị trí đã dùng thì giữ nguyên mãi
let flags = user.uiFlags; // số nguyên từ server

flags = updateFlag(UI_FLAGS, flags, 'hideAvatars', true, (next) =>
  fetch('/api/me/ui-flags', { method: 'PUT', body: String(next) }),
);
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createWarmUp, defineBitFlags, isSynthetic, pickWarmUpTargets, updateFlag } from './syntheticPrefetch';

const DAY = 86_400_000;
const NOW = 100 * DAY;

describe('pickWarmUpTargets', () => {
  it('ưu tiên group yêu thích, bỏ group lâu không mở', () => {
    const ids = pickWarmUpTargets(
      [
        { id: 'a', favorite: false, lastVisited: NOW - DAY, rank: 0 },
        { id: 'old', favorite: true, lastVisited: NOW - 40 * DAY, rank: 1 },
        { id: 'fav', favorite: true, lastVisited: NOW - DAY, rank: 9 },
        { id: 'far', favorite: false, lastVisited: NOW - DAY, rank: 8 },
        { id: 'never', favorite: false, rank: 2 },
      ],
      { now: NOW, max: 5, topNonFavorites: 5 },
    );
    expect(ids).toEqual(['fav', 'a']);
  });
});

describe('createWarmUp', () => {
  afterEach(() => vi.useRealTimers());

  it('chạy một lần sau delay, gắn header synthetic, nuốt lỗi', () => {
    vi.useFakeTimers();
    const calls: [string, Headers][] = [];
    const warmUp = createWarmUp({
      candidates: () => [{ id: 'g1', favorite: true, lastVisited: Date.now(), rank: 0 }],
      warm: async (id, headers) => {
        calls.push([id, headers]);
        throw new Error('500');
      },
      delayMs: 1000,
    });
    warmUp.trigger();
    warmUp.trigger();
    vi.advanceTimersByTime(1000);
    expect(calls.map((c) => c[0])).toEqual(['g1']);
    expect(isSynthetic(calls[0][1])).toBe(true);
    expect(warmUp.measure('g1').warmed).toBe(true);
  });
});

describe('bit flags', () => {
  const flags = defineBitFlags({ a: 1, b: 3 });

  it('đặt được cờ khi giá trị đang là 0 hoặc chưa có', () => {
    expect(flags.set(undefined, 'a', true)).toBe(2);
    expect(flags.set(0, 'b', true)).toBe(8);
    expect(flags.set(10, 'a', false)).toBe(8);
  });

  it('không ghi khi cờ đã đúng giá trị', () => {
    const persist = vi.fn();
    expect(updateFlag(flags, 8, 'b', true, persist)).toBe(8);
    expect(persist).not.toHaveBeenCalled();
    expect(updateFlag(flags, 8, 'a', true, persist)).toBe(10);
    expect(persist).toHaveBeenCalledWith(10);
  });

  it('chặn bit 31 và hai cờ trùng vị trí', () => {
    expect(() => defineBitFlags({ x: 31 })).toThrow(RangeError);
    expect(() => defineBitFlags({ x: 2, y: 2 })).toThrow();
  });
});
ts

#So với cách làm phổ biến khác

  • Header Purpose / Sec-Purpose. Khi trình duyệt tự prefetch (qua <link rel="prefetch"> hay Speculation Rules), nó gửi kèm header Sec-Purpose: prefetch để server phân biệt. Ý tưởng giống hệt header của Outlook, chỉ là do trình duyệt đặt. Với fetch tự gọi thì phải tự gắn header của mình.
  • Công cụ analytics thường có cách đánh dấu sự kiện không do người dùng tương tác (ví dụ nonInteraction trong Universal Analytics). Cùng một tinh thần: đừng để máy làm đẹp số liệu.
  • Bit flags là kỹ thuật rất cũ (cờ quyền truy cập file Unix, cờ trong giao thức mạng). Trong JavaScript, chỉ cần nhớ phép toán bit chạy trên số 32 bit có dấu.

Bài này thuộc series Học từ mã nguồn các web lớn (65 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Mở đầu

Phần 1: trải nghiệm người dùng

Phần 2: hiệu năng và đo lường

Phần 3: nhiều tab, phiên đăng nhập, cập nhật

Phần 4: kiến trúc

Phần 5: soạn thảo và hiển thị nội dung

Phần 6: tương tác

Phần 7: mạng, dữ liệu và async

Phần 8: hiệu năng và vận hành

Phần 9: thuật toán và dữ liệu phía client

Phần 10: thời gian, ngôn ngữ, định dạng

Phần 11: tương tác và giao diện

Phần 12: media

Phần 13: đo lường, bảo mật, vận hành

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