Web Totals

Học từ Facebook #100: Typeahead không nhảy, cache tiền tố, khoá hover

Ô tìm kiếm của Facebook lọc ngay trên kết quả của tiền tố đã có, không để nguồn chậm đẩy kết quả đã hiện xuống, và tắt pointer-events khi cuộn bằng phím.

hunghg255hunghg25527 min read
#facebook#search#typeahead#ux#cache
Học từ Facebook #100: Typeahead không nhảy, cache tiền tố, khoá hover

Gõ "ng" vào ô tìm kiếm. Danh sách hiện "Nguyễn An" ở dòng thứ ba, bạn đưa chuột tới định bấm. Đúng lúc đó kết quả "Bạn bè" về muộn, chèn lên trên, "Nguyễn An" tụt xuống dòng thứ sáu và bạn bấm nhầm vào một Trang lạ. Hoặc bạn bấm phím ↓ để chọn, danh sách cuộn, nội dung trượt qua dưới con trỏ chuột đang đứng yên, và mục đang chọn nhảy sang mục nằm dưới chuột.

Typeahead "nhảy" là lỗi giao diện rất phổ biến, và gần như không có cái nào xuất hiện trong unit test. Bài này đọc ba module nhỏ trong ô tìm kiếm của Facebook, mỗi module chống một kiểu nhảy:

Tip

Tóm tắt

  • Cache theo tiền tố: kết quả của "ng" được lọc lại ngay cho "ngu" mà không chờ mạng; kết quả mạng về sau được nối vào phía sau để mục đã hiện không đổi chỗ. Bản gốc chuẩn hoá key lúc ghi bằng trimLeft nhưng lúc đọc bằng trim: query có dấu cách cuối không bao giờ đọc lại được kết quả mạng của nó.
  • Ghép nhiều nguồn: nếu một nguồn phía trên chưa xong mà nguồn phía dưới đã có kết quả thì không cập nhật danh sách. Muốn bỏ luật này phải bật cờ tên là allowClownyReflow. Luật chỉ có tác dụng khi nguồn báo "đang chạy" ngay từ đầu.
  • Cuộn bằng phím: đặt pointer-events: none cho danh sách 100ms mỗi lần cuộn, để mouseover sinh ra do nội dung trượt không đổi mục đang chọn.
  • Bản viết lại: một hàm chuẩn hoá cho cả đọc lẫn ghi, coi nguồn "chưa trả lời" như "đang chạy", và trả pointer-events về giá trị của stylesheet khi xong.

#Facebook làm thế nào?

#Cache thu hẹp theo tiền tố

CometSearchTypeaheadBaseQueryDataCacheProvider (bundle 6.js). Cache là một Map từ query đã chuẩn hoá sang danh sách {entry, suffix}, trong đó suffix là phần nhãn còn lại sau query:

// cometSearchTypeaheadBaseLabelSuffixBuilder
const suffixOf = (entry, query) => {
  const label = entry.getLabel().trimLeft().toLowerCase();
  return query == null || !label.startsWith(query) ? '' : label.slice(query.length);
};

match(query) {
  query = query.trim().toLowerCase();
  const hit = this.map.get(query);
  if (hit != null) return hit.map((x) => x.entry);
  const { prefix, suffix } = this.findCachedPrefix(query); // cắt dần ký tự cuối tới khi gặp key có trong cache
  if (prefix.length > 0) {
    const narrowed = this.map.get(prefix)
      .filter((x) => x.suffix.startsWith(suffix))
      .map((x) => ({ entry: x.entry, suffix: x.suffix.slice(suffix.length) }));
    this.map.set(query, narrowed);
    return narrowed.map((x) => x.entry);
  }
  return [];
}
js

Ý tưởng giống một cây tiền tố (trie) "lười": không dựng cây, chỉ lưu kết quả của những query người dùng đã gõ. Query mới thì lùi về tiền tố dài nhất đã có, lọc tiếp bằng phép startsWith trên phần đuôi đã tính sẵn. Gõ "ng" rồi "ngu" rồi "nguy": mỗi phím chỉ lọc vài chục mục, kết quả hiện ngay trong cùng frame.

add(query, entries) khi mạng trả về: nếu key đã có thì nối kết quả mạng sau kết quả cũ, bỏ trùng (cometSearchTypeaheadBaseDedupEntries); nếu chưa có thì lấy kết quả thu hẹp từ tiền tố rồi nối kết quả mạng phía sau. Thứ tự này là chống nhảy: mục người dùng đã thấy (từ cache) giữ nguyên vị trí, mục mới chỉ xuất hiện bên dưới.

Lỗi, đọc code thấy và kiểm lại bằng node:

add(entries, query = '') { query = (query ?? '').trimLeft().toLowerCase(); … this.map.set(query, …) }
match(query)             { query = query.trim().toLowerCase(); … this.map.get(query) … }
js

Người dùng gõ "an " (có dấu cách cuối, rất hay gặp khi định gõ tiếp họ). Kết quả mạng được ghi dưới key "an ". Lần đọc sau dùng key "an". Hai key không bao giờ gặp nhau: kết quả mạng cho "an " không bao giờ được đọc ra từ cache, mỗi lần gõ lại là một request mới, và nếu "an" chưa từng được hỏi thì danh sách đứng trống dù mạng đã trả lời. Thêm nữa, remove() là hàm rỗng.

Một giới hạn thiết kế: phần đuôi chỉ có nghĩa khi cả nhãn bắt đầu bằng query. "Trần Nguyễn An" khớp "ng" (nhờ server tìm theo từ) nhưng suffix của nó là chuỗi rỗng, và ở "ngu" nó bị lọc mất cho tới khi mạng trả về và chèn lại vào cuối danh sách. Một mục nhảy từ giữa xuống cuối: đúng loại nhảy mà cache muốn tránh.

#Ghép nhiều nguồn mà không reflow

CompositeSearchSource (bundle 232.js) gọi mọi nguồn (bạn bè, nhóm, trang, lịch sử tìm kiếm…) song song. Mỗi nguồn gọi callback nhiều lần với (results, query, status), status là ACTIVE khi còn chờ thêm hoặc COMPLETE. Mỗi lần có nguồn trả về, nó ghép kết quả theo thứ tự nguồn, bỏ trùng theo getUniqueID(), rồi quyết định có báo ra ngoài không:

shouldEmit(results, statuses) {
  if (this.options.allowClownyReflow === true) return true;
  const firstActive = statuses.findIndex((s) => s === 'ACTIVE');
  if (firstActive !== -1)
    for (let i = firstActive + 1; i < results.length; i++)
      if (results[i].length > 0) return false;   // nguồn dưới có kết quả trong khi nguồn trên còn chạy
  return true;
}
js

Tên cờ nói lên tất cả: cho kết quả nhảy lung tung là "clowny" (kiểu chú hề), muốn làm thì phải tự ký tên vào. Ngoài ra có skipCallbackOnEmptyResults để không báo danh sách rỗng khi chưa xong, và createMemoized dùng memoizeWithArgsWeak để cùng một bộ nguồn thì dùng lại một đối tượng ghép.

Kẽ hở: trạng thái ban đầu của mỗi nguồn là undefined, không phải ACTIVE. Luật chỉ tìm ACTIVE, nên nếu nguồn phía trên chưa gọi callback lần nào (đang chờ mạng, chưa có gì trong cache) thì nó không chặn được gì. Nguồn dưới trả trước sẽ hiện trước, rồi bị đẩy xuống khi nguồn trên về. Luật chỉ đúng khi mọi nguồn gọi callback đồng bộ với ACTIVE ngay lúc bắt đầu, thường là với kết quả từ cache cục bộ. Các nguồn trong Comet có lẽ đều làm vậy, nhưng CompositeSearchSource không tự đảm bảo điều đó. Cột giữa của demo cho thấy chuyện gì xảy ra khi giả định này sai.

#Cuộn mà hover không giật

SUITokenizerItemListScroller (bundle 233.js), toàn bộ:

const timers = new Map();
function scroll(list, item) {
  const current = Scroll.getTop(list);
  const top = Math.max(0, item.offsetTop - list.offsetHeight / 2);
  if (current !== top) {
    list.style.pointerEvents = 'none';
    Scroll.setTop(list, top);
    const id = setTimeout(() => {
      if (timers.get(list) === id) { list.style.pointerEvents = 'all'; timers.delete(list); }
    }, 100);
    timers.set(list, id);
  }
}
function unmount(list) { const id = timers.get(list); id && clearTimeout(id); }
js

Khi nội dung cuộn dưới một con trỏ chuột đứng yên, phần tử nằm dưới con trỏ thay đổi, và trình duyệt bắn mouseover/mouseenter cho phần tử mới (Chrome làm việc này bằng một "mousemove giả" sau khi cuộn). Danh sách typeahead thường đổi mục đang chọn khi hover, nên bấm ↓ để chọn mục 12 thì mục 15 nằm dưới chuột "cướp" lựa chọn. Tắt pointer-events trong 100ms quanh lần cuộn làm danh sách vô hình với chuột đúng lúc đó. Map lưu timer mới nhất của mỗi danh sách, nên giữ phím ↓ liên tục thì chỉ timer cuối mới bật lại chuột.

Hai chỗ chưa gọn: gán 'all' ghi đè mọi giá trị pointer-events đặt trong stylesheet (nên gán '' để trả về stylesheet); unmount huỷ timer nhưng không trả lại pointer-events, nên nếu danh sách được dùng lại sau khi component unmount giữa chừng, nó sẽ không nhận chuột nữa. Công thức offsetTop − offsetHeight / 2 cũng chưa trừ nửa chiều cao của mục, nên mục nằm hơi dưới tâm.

sequenceDiagram
  participant U as Người dùng
  participant C as Composite
  participant A as Nguồn Bạn bè 900ms
  participant B as Nguồn Trang 150ms
  U->>C: gõ "an"
  C->>A: search
  C->>B: search
  B-->>C: 2 kết quả, COMPLETE
  alt A đã báo ACTIVE, hoặc bản viết lại coi "chưa trả lời" là đang chạy
    C--xU: không cập nhật, chờ A
  else A chưa gọi callback lần nào, luật gốc
    C-->>U: hiện 2 Trang
  end
  A-->>C: 3 bạn bè, COMPLETE
  C-->>U: Bạn bè ở trên, Trang ở dưới

#Bên trong trình duyệt

Ba loại nhảy có ba nguyên nhân khác nhau:

  • Thứ tự đổi khi dữ liệu về là chuyện của ứng dụng: chỉ cần quy ước "mục đã hiện không bao giờ bị đẩy xuống", nối thêm thì nối ở cuối.
  • Mouseover khi cuộn là hành vi chuẩn: đặc tả UI Events coi việc phần tử dưới con trỏ thay đổi (vì cuộn hay vì layout đổi) là lý do bắn sự kiện chuột. Chrome và Firefox bắn sau khi cuộn dừng một nhịp ngắn. Không tắt được bằng API nào khác ngoài pointer-events, hoặc bỏ qua hover trong một khoảng ngắn sau khi bấm phím.
  • Layout shift khi danh sách dài ra hay co lại: đo được bằng PerformanceObserver loại layout-shift (CLS), nhưng dịch chuyển xảy ra trong vòng 500ms sau thao tác của người dùng (gõ phím) không bị tính vào CLS. Typeahead nhảy vì thế thường không hiện trong số liệu Web Vitals, dù người dùng thấy rõ.

#Viết lại

Cache: một hàm chuẩn hoá cho cả đọc lẫn ghi (bỏ dấu tiếng Việt, gộp khoảng trắng). Thay phần đuôi bằng một hàm khớp "đơn điệu" do người dùng truyền vào, mặc định là khớp đầu từ, nên "Trần Nguyễn An" không rơi khỏi danh sách khi gõ thêm:

src/lib/stableTypeahead.ts
  /** Tiền tố dài nhất (ngắn hơn key) đã có trong cache. */
  private longestPrefix(key: string): string | undefined {
    for (let n = key.length - 1; n > 0; n--) {
      const p = key.slice(0, n);
      if (this.map.has(p)) return p;
    }
    return undefined;
  }

  private narrow(prefix: string, key: string): T[] {
    const match = this.o.matches ?? wordPrefixMatch;
    return (this.map.get(prefix) ?? []).filter((e) => match(this.o.label(e), key));
  }

  match(query: string): CacheHit<T> {
    const key = this.readKey(query);
    const hit = this.map.get(key);
    if (hit) return { entries: hit, via: 'exact' };
    const prefix = this.longestPrefix(key);
    if (prefix === undefined) return { entries: [], via: 'miss' };
    const entries = this.narrow(prefix, key);
    this.map.set(key, entries); // lần sau gõ lại là trúng ngay
    return { entries, via: 'narrowed', from: prefix };
  }

  /** Kết quả mạng: trộn sau kết quả đã thu hẹp (mục đã hiện giữ nguyên vị trí), bỏ trùng theo id. */
  add(query: string, fromNetwork: T[]) {
    const key = this.writeKey(query);
    const prefix = this.map.has(key) ? key : this.longestPrefix(key);
    const base = prefix === undefined ? [] : prefix === key ? this.map.get(key)! : this.narrow(prefix, key);
    const seen = new Set(base.map(this.o.id));
    this.map.set(key, [...base, ...fromNetwork.filter((e) => !seen.has(this.o.id(e)))]);
  }
ts

Ghép nguồn: nguồn chưa xong (kể cả chưa trả lời lần nào) chặn mọi nguồn bên dưới:

src/lib/stableTypeahead.ts
export function composeResults<T>(
  snaps: readonly SourceSnapshot<T>[],
  id: (e: T) => string,
  o: ComposeOptions = {},
): { results: T[]; status: 'active' | 'complete'; emit: boolean } {
  const seen = new Set<string>();
  const results: T[] = [];
  for (const s of snaps)
    for (const e of s.results) {
      const k = id(e);
      if (!seen.has(k)) {
        seen.add(k);
        results.push(e);
      }
    }
  const status = snaps.every((s) => s.status === 'complete') ? 'complete' : 'active';
  if (o.allowReflow) return { results, status, emit: true };
  // nguồn đầu tiên chưa xong: mọi nguồn bên dưới nó phải chờ, nếu không sẽ bị đẩy xuống
  const firstOpen = snaps.findIndex((s) => (o.legacyPending ? s.status === 'active' : s.status !== 'complete'));
  const blocked = firstOpen !== -1 && snaps.slice(firstOpen + 1).some((s) => s.results.length > 0);
  return { results, status, emit: !blocked };
}
ts

Cuộn:

src/lib/stableTypeahead.ts
const locks = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();

/**
 * Cuộn để `item` nằm giữa `list`. Trong lúc nội dung trượt dưới con trỏ chuột đứng yên,
 * trình duyệt bắn mouseover/mouseenter; tắt pointer-events một nhịp ngắn để hover không
 * đổi mục đang chọn bằng phím.
 */
export function scrollItemToCenter(list: HTMLElement, item: HTMLElement, lockMs = 100) {
  // list cần position: relative để item.offsetTop tính từ đầu list
  const top = Math.max(0, item.offsetTop - (list.clientHeight - item.offsetHeight) / 2);
  if (Math.abs(list.scrollTop - top) < 1) return;
  if (lockMs > 0) {
    list.style.pointerEvents = 'none';
    clearTimeout(locks.get(list));
    locks.set(
      list,
      setTimeout(() => {
        list.style.pointerEvents = ''; // trả về giá trị của stylesheet, không ghi đè "all"
        locks.delete(list);
      }, lockMs),
    );
  }
  list.scrollTop = top;
}

/** Gọi khi unmount: huỷ timer VÀ trả lại pointer-events. */
export function releaseScrollLock(list: HTMLElement) {
  clearTimeout(locks.get(list));
  locks.delete(list);
  list.style.pointerEvents = '';
}
ts
Xem toàn bộ src/lib/stableTypeahead.ts
src/lib/stableTypeahead.ts
/**
 * Typeahead không nhảy: cache kết quả theo tiền tố, ghép nhiều nguồn mà không đẩy mục
 * đã hiện xuống dưới, và cuộn bằng phím mà hover không "cướp" mục đang chọn.
 * Ý tưởng học từ CometSearchTypeaheadBaseQueryDataCacheProvider, CompositeSearchSource,
 * SUITokenizerItemListScroller của Facebook; viết lại từ đầu.
 */

// ---------------------------------------------------------------- 1. cache tiền tố

/** Chuẩn hoá MỘT chỗ duy nhất, cho cả lúc ghi lẫn lúc đọc cache. */
export function normalizeQuery(s: string): string {
  return s
    .normalize('NFD')
    .replace(/\p{M}/gu, '')
    .replace(/đ/g, 'd')
    .replace(/Đ/g, 'D')
    .toLowerCase()
    .replace(/\s+/g, ' ')
    .trim();
}

/** Mặc định: mỗi từ của query là tiền tố của một từ trong nhãn ("an ng" khớp "Nguyễn An"). */
export function wordPrefixMatch(label: string, query: string): boolean {
  const words = normalizeQuery(label).split(' ');
  return query.split(' ').every((q) => words.some((w) => w.startsWith(q)));
}

export interface PrefixCacheOptions<T> {
  id: (e: T) => string;
  label: (e: T) => string;
  /**
   * Phải "đơn điệu": khớp query dài thì cũng khớp mọi tiền tố của nó.
   * Nhờ vậy kết quả của "ngu" luôn nằm trong kết quả của "ng" và thu hẹp được.
   */
  matches?: (label: string, normalizedQuery: string) => boolean;
  /** Mô phỏng bản gốc: ghi bằng trimStart, đọc bằng trim. */
  legacyKeys?: boolean;
}

export type CacheHit<T> = { entries: T[]; via: 'exact' | 'narrowed' | 'miss'; from?: string };

export class PrefixCache<T> {
  private map = new Map<string, T[]>();
  constructor(private o: PrefixCacheOptions<T>) {}

  private writeKey(q: string) {
    return this.o.legacyKeys ? q.trimStart().toLowerCase() : normalizeQuery(q);
  }
  private readKey(q: string) {
    return this.o.legacyKeys ? q.trim().toLowerCase() : normalizeQuery(q);
  }

  /** Tiền tố dài nhất (ngắn hơn key) đã có trong cache. */
  private longestPrefix(key: string): string | undefined {
    for (let n = key.length - 1; n > 0; n--) {
      const p = key.slice(0, n);
      if (this.map.has(p)) return p;
    }
    return undefined;
  }

  private narrow(prefix: string, key: string): T[] {
    const match = this.o.matches ?? wordPrefixMatch;
    return (this.map.get(prefix) ?? []).filter((e) => match(this.o.label(e), key));
  }

  match(query: string): CacheHit<T> {
    const key = this.readKey(query);
    const hit = this.map.get(key);
    if (hit) return { entries: hit, via: 'exact' };
    const prefix = this.longestPrefix(key);
    if (prefix === undefined) return { entries: [], via: 'miss' };
    const entries = this.narrow(prefix, key);
    this.map.set(key, entries); // lần sau gõ lại là trúng ngay
    return { entries, via: 'narrowed', from: prefix };
  }

  /** Kết quả mạng: trộn sau kết quả đã thu hẹp (mục đã hiện giữ nguyên vị trí), bỏ trùng theo id. */
  add(query: string, fromNetwork: T[]) {
    const key = this.writeKey(query);
    const prefix = this.map.has(key) ? key : this.longestPrefix(key);
    const base = prefix === undefined ? [] : prefix === key ? this.map.get(key)! : this.narrow(prefix, key);
    const seen = new Set(base.map(this.o.id));
    this.map.set(key, [...base, ...fromNetwork.filter((e) => !seen.has(this.o.id(e)))]);
  }

  keys() {
    return [...this.map.keys()];
  }
  clear() {
    this.map.clear();
  }
}

// ---------------------------------------------------------------- 2. ghép nhiều nguồn

/** pending: chưa trả lần nào; active: đã có một phần, còn chờ mạng; complete: xong. */
export type SourceStatus = 'pending' | 'active' | 'complete';
export interface SourceSnapshot<T> {
  status: SourceStatus;
  results: T[];
}

export interface ComposeOptions {
  /** cho phép nguồn dưới hiện trước rồi bị đẩy xuống ("allowClownyReflow" ở bản gốc) */
  allowReflow?: boolean;
  /** Mô phỏng bản gốc: nguồn chưa trả lần nào không được coi là "còn chạy". */
  legacyPending?: boolean;
}

export function composeResults<T>(
  snaps: readonly SourceSnapshot<T>[],
  id: (e: T) => string,
  o: ComposeOptions = {},
): { results: T[]; status: 'active' | 'complete'; emit: boolean } {
  const seen = new Set<string>();
  const results: T[] = [];
  for (const s of snaps)
    for (const e of s.results) {
      const k = id(e);
      if (!seen.has(k)) {
        seen.add(k);
        results.push(e);
      }
    }
  const status = snaps.every((s) => s.status === 'complete') ? 'complete' : 'active';
  if (o.allowReflow) return { results, status, emit: true };
  // nguồn đầu tiên chưa xong: mọi nguồn bên dưới nó phải chờ, nếu không sẽ bị đẩy xuống
  const firstOpen = snaps.findIndex((s) => (o.legacyPending ? s.status === 'active' : s.status !== 'complete'));
  const blocked = firstOpen !== -1 && snaps.slice(firstOpen + 1).some((s) => s.results.length > 0);
  return { results, status, emit: !blocked };
}

// ---------------------------------------------------------------- 3. cuộn bằng phím

const locks = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();

/**
 * Cuộn để `item` nằm giữa `list`. Trong lúc nội dung trượt dưới con trỏ chuột đứng yên,
 * trình duyệt bắn mouseover/mouseenter; tắt pointer-events một nhịp ngắn để hover không
 * đổi mục đang chọn bằng phím.
 */
export function scrollItemToCenter(list: HTMLElement, item: HTMLElement, lockMs = 100) {
  // list cần position: relative để item.offsetTop tính từ đầu list
  const top = Math.max(0, item.offsetTop - (list.clientHeight - item.offsetHeight) / 2);
  if (Math.abs(list.scrollTop - top) < 1) return;
  if (lockMs > 0) {
    list.style.pointerEvents = 'none';
    clearTimeout(locks.get(list));
    locks.set(
      list,
      setTimeout(() => {
        list.style.pointerEvents = ''; // trả về giá trị của stylesheet, không ghi đè "all"
        locks.delete(list);
      }, lockMs),
    );
  }
  list.scrollTop = top;
}

/** Gọi khi unmount: huỷ timer VÀ trả lại pointer-events. */
export function releaseScrollLock(list: HTMLElement) {
  clearTimeout(locks.get(list));
  locks.delete(list);
  list.style.pointerEvents = '';
}
ts

#Đi qua code

  • normalizeQuery là nơi duy nhất quyết định key. legacyKeys chỉ để demo tái hiện lỗi trimLeft/trim.
  • match trả thêm via (exact, narrowed, miss) để lớp gọi quyết định có cần hỏi mạng không: exact thì không, hai loại còn lại thì có.
  • narrow dùng matches(label, query). Thu hẹp chỉ đúng khi hàm này đơn điệu: khớp "ngu" thì chắc chắn khớp "ng". Khớp đầu từ, khớp chuỗi con, khớp không dấu đều thoả; khớp mờ theo khoảng cách sửa (fuzzy) thì không.
  • add giữ kết quả cũ (từ cache hoặc thu hẹp) ở đầu và nối kết quả mạng sau, bỏ trùng theo id.
  • composeResults là hàm thuần trên ảnh chụp trạng thái các nguồn; việc gọi nguồn, đếm thời gian, huỷ request cũ là việc của lớp gọi (demo dùng Promise và sleep).
  • scrollItemToCenter trừ cả nửa chiều cao của mục khi căn giữa, chỉ khoá chuột khi thật sự cuộn, và trả pointer-events về ''. releaseScrollLock dùng khi unmount.

Khác bản gốc:

  • Key cache chuẩn hoá giống nhau lúc đọc và ghi; có bỏ dấu tiếng Việt (bản gốc chỉ toLowerCase, việc bỏ dấu nằm ở tầng tìm kiếm khác, xem #25).
  • Thu hẹp bằng hàm khớp thay cho phần đuôi của cả nhãn.
  • Nguồn chưa trả lời được coi là đang chạy.
  • Khoá cuộn dùng WeakMap và trả pointer-events về stylesheet; có hàm nhả khoá khi unmount.

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

  • Debounce ô tìm kiếm 300ms: giảm request nhưng làm mọi phím gõ chậm 300ms; cache tiền tố cho kết quả ngay ở phím đầu tiên sau một query đã có. Hai cách dùng chung được.
  • Chờ mọi nguồn xong rồi mới hiện: không nhảy, nhưng nhanh bằng nguồn chậm nhất. Luật của Facebook chỉ bắt chờ những nguồn phía trên; nếu nguồn nhanh nhất nằm trên cùng (lịch sử tìm kiếm cục bộ) thì người dùng thấy kết quả gần như tức thì.
  • Giữ chỗ cố định cho mỗi nguồn (mỗi nhóm một khung cao cố định với skeleton): không nhảy, nhưng tốn chỗ khi nguồn rỗng. Hợp với trang kết quả đầy đủ hơn là dropdown nhỏ.
  • Bỏ qua hover trong 300ms sau phím (thay cho pointer-events: none): không đụng CSS, nhưng phải sửa mọi handler hover, và cursor vẫn đổi hình khi lướt qua phần tử có cursor: pointer.

#Edge case và cạm bẫy

  • Kết quả mạng bị giới hạn số lượng: server trả 6 mục cho "n" thì kết quả thu hẹp cho "ng" chỉ là tập con của 6 mục đó, có thể thiếu. Đó là lý do vẫn phải hỏi mạng khi via === 'narrowed', và chỉ bỏ qua mạng khi trúng exact.
  • Cache không có hạn: người dùng đổi tên, kết bạn mới; cache sống suốt phiên sẽ cũ. Xoá cache khi đóng dropdown, hoặc đặt hạn theo thời gian.
  • Request về sai thứ tự: "ng" về sau "ngu" thì kết quả cũ đè kết quả mới. Demo kiểm latest.current === value trước khi hiện; thực tế dùng AbortController để huỷ request cũ.
  • pointer-events: none làm mất cả cuộn bằng bánh xe trong 100ms đó, vì sự kiện wheel đi xuyên xuống phần tử bên dưới. Giữ thời gian khoá ngắn.
  • Bàn phím ảo trên di động: không có hover, nhưng có cuộn bằng tay; luật chống reflow vẫn quan trọng vì ngón tay đang đặt sẵn ở một vị trí.

#Dùng trong dự án

import { PrefixCache, composeResults, scrollItemToCenter } from './stableTypeahead';

const cache = new PrefixCache<User>({ id: (u) => u.id, label: (u) => u.name });
let controller: AbortController | undefined;

async function onQuery(q: string, render: (users: User[]) => void) {
  const hit = cache.match(q);
  render(hit.entries);                         // ngay lập tức, kể cả khi thu hẹp
  if (hit.via === 'exact') return;
  controller?.abort();
  controller = new AbortController();
  const users = await fetch(`/api/users?q=${encodeURIComponent(q)}`, { signal: controller.signal }).then((r) => r.json());
  cache.add(q, users);
  render(cache.match(q).entries);              // mục cũ giữ chỗ, mục mới ở dưới
}

// khi người dùng bấm phím mũi tên
scrollItemToCenter(listEl, listEl.children[active] as HTMLElement);
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { composeResults, PrefixCache, type SourceSnapshot } from './stableTypeahead';

type P = { id: string; name: string };
const people: P[] = ['Nguyễn An', 'Nguyễn Bình', 'Trần Nguyễn An', 'Lê Ngọc'].map((name, i) => ({ id: String(i), name }));
const make = (legacyKeys = false) => new PrefixCache<P>({ id: (p) => p.id, label: (p) => p.name, legacyKeys });

describe('PrefixCache', () => {
  it('thu hẹp từ tiền tố, giữ cả mục khớp ở từ thứ hai', () => {
    const c = make();
    c.add('ng', people);
    const hit = c.match('ngu');
    expect(hit.via).toBe('narrowed');
    expect(hit.entries.map((p) => p.name)).toEqual(['Nguyễn An', 'Nguyễn Bình', 'Trần Nguyễn An']);
  });

  it('kết quả mạng nối phía sau, không trùng', () => {
    const c = make();
    c.add('ng', people.slice(0, 2));
    c.add('ngu', [people[2], people[0]]);
    expect(c.match('ngu').entries.map((p) => p.id)).toEqual(['0', '1', '2']);
  });

  it('dấu cách cuối: bản sửa đọc lại được, bản gốc thì không', () => {
    const fixed = make();
    fixed.add('an ', [people[0]]);
    expect(fixed.match('an ').via).toBe('exact');
    const legacy = make(true);
    legacy.add('an ', [people[0]]);
    expect(legacy.match('an ').via).toBe('miss');
  });
});

describe('composeResults', () => {
  const s = (status: SourceSnapshot<string>['status'], results: string[]) => ({ status, results });
  const id = (x: string) => x;
  it('nguồn trên chưa trả lời thì không hiện nguồn dưới', () => {
    expect(composeResults([s('pending', []), s('complete', ['b'])], id).emit).toBe(false);
    expect(composeResults([s('pending', []), s('complete', ['b'])], id, { legacyPending: true }).emit).toBe(true);
  });
  it('bỏ trùng theo thứ tự nguồn', () => {
    const r = composeResults([s('complete', ['a', 'b']), s('complete', ['b', 'c'])], id);
    expect(r).toEqual({ results: ['a', 'b', 'c'], status: 'complete', emit: true });
  });
  it('allowReflow luôn hiện', () => {
    expect(composeResults([s('active', []), s('complete', ['b'])], id, { allowReflow: true }).emit).toBe(true);
  });
});
ts

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

  • Downshift, React Aria useComboBox, Headless UI Combobox: lo phần bàn phím, ARIA (role="combobox", aria-activedescendant) và cuộn mục vào tầm nhìn; không lo chuyện thứ tự kết quả khi nhiều nguồn trả về lệch giờ.
  • TanStack Query / SWR: cache theo key chính xác; không có thu hẹp theo tiền tố. Có thể dùng placeholderData để hiện kết quả thu hẹp trong lúc chờ.
  • Element.scrollIntoView({ block: 'center' }): căn giữa bằng một dòng, nhưng cuộn cả các tổ tiên cuộn được (có thể kéo cả trang); scrollIntoViewIfNeeded không chuẩn. Tự đặt scrollTop cho riêng danh sách như bản viết lại thì kiểm soát được hơn.
  • CSS overflow-anchor: giữ vị trí cuộn khi nội dung phía trên thay đổi; không giúp gì khi chính danh sách kết quả bị chèn mục.
  • Bài liên quan: #25 Tìm kiếm gấp dấu, #54 Lưới phím mũi tên 2D, #65 Damerau-Levenshtein.

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