Học từ Facebook #25: Tìm kiếm gõ không dấu và tô đậm đúng đầu từ
Cách ô tìm kiếm của Facebook gấp dấu, phiên âm Cyrillic và tô đậm đầu từ, hai lỗi khiến "nguyen" không ra "Nguyễn", và bản viết lại dùng Unicode NFD.
Người Việt gõ tìm bạn bè gần như luôn gõ không dấu: "nguyen van an", "hung", "duc". Ô tìm kiếm phải:
- Coi "Nguyễn" và "nguyen" là một.
- Cho từ cuối là tiền tố khi người dùng còn đang gõ ("nguy" khớp "Nguyễn"), nhưng từ đã gõ xong (có dấu cách phía sau) thì phải khớp đủ.
- Tô đậm phần khớp ở đầu từ, không tô giữa từ ("an" không được tô "Văn").
- Không tô đậm khi chữ viết có ghép nét (Ả Rập, Hindi, Thái…), vì chèn
<b>vào giữa sẽ làm vỡ hình chữ.
Facebook làm đủ bốn việc trên trong hai module nhỏ, nhưng bảng gấp dấu của họ không có chữ Việt. Gõ "nguyen" trên bản mô phỏng không ra "Nguyễn".
Tip
Tóm tắt
- Facebook gấp chữ bằng bảng tra từng ký tự (Latin-1, Latin Extended-A, Cyrillic): "Hùng" → "hung" nhưng "Hưng", "Nguyễn" giữ nguyên dấu.
- Highlight thêm một dấu cách vào đầu chuỗi rồi tìm
( từ1| từ2)để chỉ khớp đầu từ; regex nhận diện chữ ghép nét gõ nhầm dấu gạch dài "–", nên "Hello – World" mất highlight. - Bản viết lại:
normalize('NFD')+ bỏ dấu (\p{M}), riêng "đ" xử lý tay; highlight tính trên chuỗi đã gấp rồi ánh xạ ngược về chuỗi gốc. - Nhận diện chữ ghép nét bằng
\p{Script=…}thay vì liệt kê dải ký tự bằng tay.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Facebook làm thế nào?
Hai module liên quan nằm trong bundle của facebook-codebase:
TokenizeUtil(file35.js): gấp chữ, tách từ, các kiểu so khớp.cometSearchTypeaheadHighlightUtils(file6.js, có bản gần giống têncometTypeaheadHighlightUtilstrong thư mục00 CometAppLoggedInNavigation/Search/) vàmayHaveConnectedCharacters.
#Gấp chữ bằng bảng tra
TokenizeUtil khai báo một object ánh xạ "chữ Latin → các biến thể", rồi đảo lại thành bảng tra từng ký tự (rút gọn, đã đổi tên biến):
var n = {
a: "а à á â ã ä å ā", // chữ đầu tiên là "а" Cyrillic
d: "д ð ď đ",
e: "э е è é ê ë ě ē",
u: "у ю ü û ú ù ů ū",
ts: "ц", ch: "ч", sh: "ш", ya: "я", ss: "ß",
// ...
};
function flatten(a) {
a = a.toLowerCase();
var b = "";
for (var d = a.length; d--; ) b = (m[a.charAt(d)] || a.charAt(d)) + b;
return b.replace(/[ ]+/g, " ");
}
js
Cách này gộp luôn phiên âm Cyrillic ("Привет" → "privet") và chữ ghép ("æ" → "ae", "ß" → "ss"). Nhưng bảng chỉ có những gì người viết liệt kê:
- Có
à á â ã,è é ê,ù ú,đ, nên "Hùng" → "hung". Nhưng "Đức" → "dức": "đ" có trong bảng, "ứ" thì không. - Không có
ư ơ ă, không có các chữ hai dấu nhưễ ặ ữ(dải Latin Extended Additional, U+1E00–U+1EFF). Kết quảflatten("Nguyễn") = "nguyễn",flatten("Hưng") = "hưng", nên gõ "nguyen" hay "hung" đều không ra. - Cyrillic cũng thiếu: không có
ж,щ,ё.
Bảng kết quả trong demo cho thấy rõ những chữ nào gấp đúng, chữ nào không.
#Tách từ và các kiểu khớp
Sau khi gấp, TokenizeUtil tách từ theo dấu cách hoặc theo một tập dấu câu rất rộng (có cả dấu câu CJK, Ả Rập, Devanagari). Kết quả parse được cache, tối đa 1000 mục; đầy thì xoá sạch cache rồi làm lại.
Hàm so khớp có 5 chế độ dùng chung một vòng lặp:
| Chế độ | Ý nghĩa |
|---|---|
exact |
mọi từ query phải trùng một từ đích |
query |
như exact, riêng từ cuối là tiền tố nếu query không kết thúc bằng dấu cách |
query_punc |
như query nhưng giữ dấu câu khi tách |
aligned |
các từ phải khớp đúng thứ tự |
prefix |
mọi từ đều là tiền tố, từ dài xét trước |
Mỗi từ đích chỉ được "dùng" một lần (mảng cờ j[d] = !0): query "an an" không khớp "Nguyễn Văn An" vì chỉ có một từ "an".
#Highlight chỉ ở đầu từ
function g(query, text, markMatched) {
if (mayHaveConnectedCharacters(text)) return [{ content: text, isMatched: false }];
var re = new RegExp("( " + escapeRegex(query).split(/\s+/).join("| ") + ")", "gi");
text = " " + text; // để từ đầu tiên cũng có dấu cách đứng trước
// ... cắt text thành các đoạn khớp / không khớp theo re.exec
}
js
Mẹo ở đây: thêm một dấu cách vào đầu chuỗi, rồi mỗi nhánh regex đều bắt đầu bằng dấu cách. Vậy regex chỉ khớp ở sau dấu cách, tức là đầu từ. Gõ "an" sẽ tô "An" nhưng không tô "Van".
Cách này có ba chỗ yếu:
- Regex chạy trên chuỗi gốc, không gấp dấu. Dù phần so khớp có đúng thì gõ "nguyen" cũng không tô được "Nguyễn".
- "Đầu từ" chỉ là "sau dấu cách": "(An)" hay "Anh-Quân" không được tô.
- Query có dấu cách ở cuối, ví dụ "anh ", tạo ra
splitcó phần tử rỗng, regex thành( anh| ): mọi dấu cách trong chuỗi đều bị đánh dấu là khớp. Mình chạy lại bằng Node: "Nguyễn Văn Nam" với query "ng " cho ra[Ng]uyễn[ ]Văn[ ]Nam.
#Bug dấu gạch dài trong mayHaveConnectedCharacters
a.match(/[ऀ-ॿ ... -ۿݐ–ݿࢠ–ࣿﭐ–﷿ﹰ– ...]/)
js
Ba dải cuối dùng dấu "–" (U+2013, en dash) thay cho dấu trừ "-". Trong một lớp ký tự (character class), ݐ–ݿ không phải là dải mà là ba ký tự: U+0750, "–", U+077F. Hệ quả (mình chạy lại bằng Node):
- Chữ Ả Rập dạng trình bày như "ﻻ" (U+FEFB) không được nhận ra là chữ ghép nét → bị chèn highlight.
- Chuỗi bất kỳ có ký tự "–" như "Hello – World" bị coi là chữ ghép nét → mất highlight. Gõ "hello" trong demo: cột Facebook vẫn tìm ra nhưng không tô đậm.
Có lẽ dấu "–" lọt vào khi ai đó chép bảng dải Unicode từ một trang web (trình soạn thảo tự đổi "-" thành "–").
flowchart LR
Q["query: nguyen"] --> F1["flatten bằng bảng tra"]
T["tên: Nguyễn Văn An"] --> F2["flatten bằng bảng tra"]
F1 --> C{"so khớp từng từ"}
F2 -->|"nguyễn văn an"| C
C -->|"nguyen khác nguyễn"| X["không có kết quả"]
#Bên trong trình duyệt: NFD và dấu tổ hợp
Unicode có hai cách viết "ễ":
- NFC (dạng dựng sẵn): một code point U+1EC5.
- NFD (dạng tách): "e" + U+0302 (dấu mũ) + U+0303 (dấu ngã).
String.prototype.normalize('NFD') chuyển sang dạng tách. Các dấu tổ hợp (combining mark) đều thuộc nhóm \p{M} trong regex Unicode, nên xoá chúng là ra chữ gốc:
'Nguyễn Hưng Đặng'.normalize('NFD').replace(/\p{M}/gu, '') // "Nguyen Hung Đang"
js
Chỉ còn "đ" không tách được (nó là chữ riêng, không phải "d" + dấu), nên phải xử lý tay. Tương tự với "ł", "ø", "æ", "ß".
#Viết lại
Gấp từng ký tự và giữ bảng ánh xạ vị trí để sau này cắt highlight trên chuỗi gốc:
src/lib/searchTokenize.ts
const COMBINING_MARKS = /\p{M}/gu;
/** Gấp MỘT ký tự (code point) thành chuỗi Latin thường, có thể rỗng hoặc dài hơn 1. */
export function foldChar(ch: string): string {
const lower = ch.toLowerCase();
if (lower in SPECIAL) return SPECIAL[lower];
if (lower in CYRILLIC) return CYRILLIC[lower];
// NFD tách "ễ" thành "e" + dấu mũ + dấu ngã; bỏ hết dấu (category M)
return lower.normalize('NFD').replace(COMBINING_MARKS, '');
}
/**
* Gấp cả chuỗi và giữ bảng ánh xạ: map[i] = vị trí (UTF-16) trong chuỗi gốc
* của ký tự gấp thứ i. Cần cho highlight trên chuỗi gốc.
*/
export function foldWithMap(text: string): { folded: string; map: number[] } {
let folded = '';
const map: number[] = [];
let offset = 0;
// for..of duyệt theo code point, không cắt đôi cặp surrogate (emoji, chữ Hán hiếm)
for (const ch of text) {
const f = foldChar(ch);
for (let k = 0; k < f.length; k++) map.push(offset);
folded += f;
offset += ch.length;
}
map.push(offset); // vị trí "sau ký tự cuối", để cắt substring
return { folded, map };
}
export const fold = (text: string) => foldWithMap(text).folded;
ts
Vài điểm:
- Duyệt bằng
for...ofđể đi theo code point, không cắt đôi cặp surrogate của emoji hay chữ Hán hiếm. - Một ký tự gốc có thể gấp thành nhiều ký tự ("щ" → "shch", "ß" → "ss") hoặc không ký tự nào ("ъ", hay dấu tổ hợp nếu chuỗi gốc đã ở dạng NFD).
map[i]luôn trỏ về vị trí của ký tự gốc sinh ra ký tự gấp thứi.
So khớp giữ đúng ngữ nghĩa của Facebook (từ cuối là tiền tố, mỗi từ đích dùng một lần), thêm một cải tiến: xét từ dài trước để "an anh" khớp được "Anh An" (nếu xét "an" trước, nó có thể chiếm mất từ "anh"):
src/lib/searchTokenize.ts
export function isQueryMatch(
query: string,
target: string,
mode: 'query' | 'prefix' = 'query',
): boolean {
const { tokens, lastIsPrefix } = parseQuery(query);
if (tokens.length === 0) return false;
const targetTokens = tokenize(fold(target));
const used = new Set<number>();
// từ dài trước: tránh từ ngắn "chiếm" mất từ đích mà từ dài cần (vd "an" và "anh")
const order = tokens.map((t, i) => ({ t, i })).sort((a, b) => b.t.length - a.t.length);
return order.every(({ t, i }) => {
const prefixOk = mode === 'prefix' || (lastIsPrefix && i === tokens.length - 1);
const index = targetTokens.findIndex(
(tt, j) => !used.has(j) && (tt === t || (prefixOk && tt.startsWith(t))),
);
if (index === -1) return false;
used.add(index);
return true;
});
}
ts
Highlight duyệt các từ trên chuỗi đã gấp (dãy chữ và số), tô phần đầu dài nhất trùng một từ của query, rồi đổi vị trí về chuỗi gốc qua map:
src/lib/searchTokenize.ts
export function highlightMatch(query: string, text: string): Segment[] {
const { tokens } = parseQuery(query);
if (tokens.length === 0 || mayHaveConnectedCharacters(text)) return [{ text, matched: false }];
const { folded, map } = foldWithMap(text);
// các khoảng [start, end) trên chuỗi GẤP cần tô
const ranges: Array<[number, number]> = [];
const wordStart = /[\p{L}\p{N}]+/gu;
for (let m = wordStart.exec(folded); m; m = wordStart.exec(folded)) {
// lấy từ query dài nhất là tiền tố của từ này (gõ "ng nguy" thì tô "Nguy")
let best = 0;
for (const t of tokens) if (t.length > best && m[0].startsWith(t)) best = t.length;
if (best > 0) ranges.push([m.index, m.index + best]);
}
if (ranges.length === 0) return [{ text, matched: false }];
const segments: Segment[] = [];
let cursor = 0;
for (const [start, end] of ranges) {
const from = map[start];
// end là "sau ký tự gấp cuối": lấy vị trí gốc của ký tự gấp kế tiếp. Một ký tự gốc
// có thể gấp ra nhiều ký tự ("щ" -> "shch"); khi đó tô trọn ký tự gốc.
let to = map[end];
if (end > start && map[end - 1] === map[end]) to = nextOriginalBoundary(map, end);
if (from > cursor) segments.push({ text: text.slice(cursor, from), matched: false });
segments.push({ text: text.slice(from, to), matched: true });
cursor = to;
}
if (cursor < text.length) segments.push({ text: text.slice(cursor), matched: false });
return segments;
}
ts
Khác bản gốc:
- Gấp dấu bằng NFD thay cho bảng tra, nên đủ mọi chữ Latin có dấu (Việt, Séc, Ba Lan…). Phiên âm Cyrillic vẫn dùng bảng, nhưng đủ chữ hơn.
- Highlight trên chuỗi gấp, "đầu từ" là sau bất kỳ ký tự không phải chữ/số, và bỏ qua phần tử rỗng của query (không còn lỗi tô dấu cách).
- Chữ ghép nét nhận diện bằng
\p{Script=Arabic},\p{Script=Devanagari}… Bản gốc có liệt kê cả dải chữ Hán (一-龯) dù chữ Hán không ghép nét; bản viết lại không gộp chữ Hán vào nhóm này. - Cache xoá mục cũ nhất thay vì xoá sạch.
Xem toàn bộ src/lib/searchTokenize.ts
src/lib/searchTokenize.ts
/**
* Tìm kiếm "gõ không dấu vẫn ra" + tô đậm đúng đầu từ (ý tưởng từ TokenizeUtil và
* cometSearchTypeaheadHighlightUtils của Facebook).
*
* Khác bản gốc:
* - Gấp dấu bằng Unicode NFD thay cho bảng tra từng ký tự, nên đủ cả tiếng Việt
* ("Nguyễn" -> "nguyen", "Hưng" -> "hung"); bảng của Facebook thiếu ư, ơ, ă, ễ…
* - Highlight tính trên chuỗi đã gấp rồi ánh xạ ngược về chuỗi gốc, nên gõ "nguyen"
* vẫn tô được "Nguyễn".
* - Nhận diện chữ "nối nét" bằng Unicode Script, không dùng dải ký tự viết tay
* (bản gốc gõ nhầm dấu gạch dài "–" nên vài dải không còn là dải).
*/
// ---- 1. Gấp chữ: lowercase + bỏ dấu + phiên âm Cyrillic ----
/** Các chữ không tách được bằng NFD (không phải "chữ gốc + dấu"). */
const SPECIAL: Record<string, string> = {
đ: 'd',
ð: 'd',
ł: 'l',
ø: 'o',
ħ: 'h',
ı: 'i',
æ: 'ae',
œ: 'oe',
ß: 'ss',
þ: 'th',
};
/** Phiên âm Cyrillic cơ bản (kiểu hộ chiếu Nga), để "privet" tìm ra "Привет". */
const CYRILLIC: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'i',
к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f',
х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'iu', я: 'ia',
і: 'i', ї: 'i', є: 'ie', ґ: 'g',
};
const COMBINING_MARKS = /\p{M}/gu;
/** Gấp MỘT ký tự (code point) thành chuỗi Latin thường, có thể rỗng hoặc dài hơn 1. */
export function foldChar(ch: string): string {
const lower = ch.toLowerCase();
if (lower in SPECIAL) return SPECIAL[lower];
if (lower in CYRILLIC) return CYRILLIC[lower];
// NFD tách "ễ" thành "e" + dấu mũ + dấu ngã; bỏ hết dấu (category M)
return lower.normalize('NFD').replace(COMBINING_MARKS, '');
}
/**
* Gấp cả chuỗi và giữ bảng ánh xạ: map[i] = vị trí (UTF-16) trong chuỗi gốc
* của ký tự gấp thứ i. Cần cho highlight trên chuỗi gốc.
*/
export function foldWithMap(text: string): { folded: string; map: number[] } {
let folded = '';
const map: number[] = [];
let offset = 0;
// for..of duyệt theo code point, không cắt đôi cặp surrogate (emoji, chữ Hán hiếm)
for (const ch of text) {
const f = foldChar(ch);
for (let k = 0; k < f.length; k++) map.push(offset);
folded += f;
offset += ch.length;
}
map.push(offset); // vị trí "sau ký tự cuối", để cắt substring
return { folded, map };
}
export const fold = (text: string) => foldWithMap(text).folded;
// ---- 2. Tách từ ----
/** Ranh giới từ: mọi thứ không phải chữ hoặc số (gồm cả dấu câu CJK, Ả Rập…). */
const SEPARATOR = /[^\p{L}\p{N}]+/u;
export function tokenize(folded: string): string[] {
return folded.split(SEPARATOR).filter(Boolean);
}
export interface ParsedQuery {
tokens: string[];
/** từ cuối đang gõ dở -> so khớp kiểu tiền tố */
lastIsPrefix: boolean;
}
// Cache kết quả parse: người dùng gõ "ng", "ngu", "nguy"… lặp lại cùng chuỗi rất nhiều.
// Facebook giới hạn 1000 mục và xoá SẠCH khi đầy; ở đây xoá mục cũ nhất (Map giữ thứ tự chèn).
const CACHE_LIMIT = 1000;
const parseCache = new Map<string, ParsedQuery>();
export function parseQuery(query: string): ParsedQuery {
const hit = parseCache.get(query);
if (hit) return hit;
const folded = fold(query);
const parsed = {
tokens: tokenize(folded),
// gõ "nguyen " (có dấu cách cuối) nghĩa là đã gõ xong từ đó
lastIsPrefix: folded.length > 0 && !/\s$/u.test(folded),
};
if (parseCache.size >= CACHE_LIMIT) parseCache.delete(parseCache.keys().next().value!);
parseCache.set(query, parsed);
return parsed;
}
/**
* Mọi từ của query phải khớp một từ KHÁC NHAU của target (không dùng lại một từ hai lần).
* Từ cuối đang gõ dở thì chỉ cần là tiền tố; các từ trước phải là tiền tố hoặc khớp đủ
* tuỳ `mode`.
*/
export function isQueryMatch(
query: string,
target: string,
mode: 'query' | 'prefix' = 'query',
): boolean {
const { tokens, lastIsPrefix } = parseQuery(query);
if (tokens.length === 0) return false;
const targetTokens = tokenize(fold(target));
const used = new Set<number>();
// từ dài trước: tránh từ ngắn "chiếm" mất từ đích mà từ dài cần (vd "an" và "anh")
const order = tokens.map((t, i) => ({ t, i })).sort((a, b) => b.t.length - a.t.length);
return order.every(({ t, i }) => {
const prefixOk = mode === 'prefix' || (lastIsPrefix && i === tokens.length - 1);
const index = targetTokens.findIndex(
(tt, j) => !used.has(j) && (tt === t || (prefixOk && tt.startsWith(t))),
);
if (index === -1) return false;
used.add(index);
return true;
});
}
// ---- 3. Highlight đầu từ ----
/**
* Chữ viết có ghép nét: chèn <b> vào giữa sẽ phá hình chữ (ligature, dạng đầu/giữa/cuối
* từ của chữ Ả Rập…), nên không highlight. Dùng Script property thay cho dải viết tay.
*/
const CONNECTED_SCRIPTS =
/[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}\p{Script=Sinhala}\p{Script=Thai}\p{Script=Lao}\p{Script=Tibetan}\p{Script=Myanmar}\p{Script=Khmer}\p{Script=Mongolian}]/u;
export const mayHaveConnectedCharacters = (text: string) => CONNECTED_SCRIPTS.test(text);
export interface Segment {
text: string;
matched: boolean;
}
/**
* Tô đậm phần đầu những từ khớp với query, tính trên chuỗi đã gấp rồi cắt trên chuỗi gốc.
* "nguyen" tô được "Nguyễn"; "an" tô được "(An)" vì đầu từ là sau bất kỳ ký tự không
* phải chữ/số, không chỉ sau dấu cách.
*/
export function highlightMatch(query: string, text: string): Segment[] {
const { tokens } = parseQuery(query);
if (tokens.length === 0 || mayHaveConnectedCharacters(text)) return [{ text, matched: false }];
const { folded, map } = foldWithMap(text);
// các khoảng [start, end) trên chuỗi GẤP cần tô
const ranges: Array<[number, number]> = [];
const wordStart = /[\p{L}\p{N}]+/gu;
for (let m = wordStart.exec(folded); m; m = wordStart.exec(folded)) {
// lấy từ query dài nhất là tiền tố của từ này (gõ "ng nguy" thì tô "Nguy")
let best = 0;
for (const t of tokens) if (t.length > best && m[0].startsWith(t)) best = t.length;
if (best > 0) ranges.push([m.index, m.index + best]);
}
if (ranges.length === 0) return [{ text, matched: false }];
const segments: Segment[] = [];
let cursor = 0;
for (const [start, end] of ranges) {
const from = map[start];
// end là "sau ký tự gấp cuối": lấy vị trí gốc của ký tự gấp kế tiếp. Một ký tự gốc
// có thể gấp ra nhiều ký tự ("щ" -> "shch"); khi đó tô trọn ký tự gốc.
let to = map[end];
if (end > start && map[end - 1] === map[end]) to = nextOriginalBoundary(map, end);
if (from > cursor) segments.push({ text: text.slice(cursor, from), matched: false });
segments.push({ text: text.slice(from, to), matched: true });
cursor = to;
}
if (cursor < text.length) segments.push({ text: text.slice(cursor), matched: false });
return segments;
}
function nextOriginalBoundary(map: number[], index: number) {
const current = map[index];
for (let i = index; i < map.length; i++) if (map[i] !== current) return map[i];
return map[map.length - 1];
}
ts
#Tại sao không làm cách khác?
Dùng localeCompare hay Intl.Collator với sensitivity: 'base'? Collator so sánh cả chuỗi ("nguyen" bằng "Nguyễn"), nhưng không cho biết tiền tố hay vị trí để highlight. Ta cần một chuỗi gấp sẵn để startsWith và để cắt.
Gấp cả chuỗi bằng một lần normalize('NFD').replace(...) rồi highlight theo chỉ số? Chỉ số trên chuỗi gấp lệch so với chuỗi gốc ngay khi gặp chữ có dấu (NFD dài hơn) hoặc chữ gấp ra nhiều ký tự. Phải có bảng ánh xạ như foldWithMap.
Tô highlight bằng regex không dấu cách, kiểu \b? \b trong JS chỉ hiểu [A-Za-z0-9_], nên coi "ễ" là ranh giới từ: "Nguyễn" bị cắt giữa từ. Dùng \p{L} với cờ u mới đúng cho chữ ngoài ASCII.
#Edge case và cạm bẫy
- Chuỗi gốc ở dạng NFD. Tên copy từ macOS (tên file) đôi khi ở dạng NFD.
foldWithMapvẫn đúng vì dấu tổ hợp gấp thành chuỗi rỗng và được gộp vào ký tự đứng trước khi cắt. - "đ" và "Đ". NFD không tách được, phải có trong bảng
SPECIAL. Quên dòng này là "Đức" không ra khi gõ "duc". - Phiên âm nhiều cách. "Щукин" có thể viết "Shchukin" hoặc "Schukin". Bảng chỉ chọn một cách; muốn tìm cả hai thì phải sinh nhiều biến thể cho chuỗi đích.
- Gõ tiếng Việt bằng bộ gõ. Khi đang gõ "ngu" + phím dấu, giá trị
inputcó thể ở trạng thái trung gian. Nếu tìm theoonChangethì vẫn chạy đúng vì mỗi lần đều gấp lại toàn bộ query. - Chữ Hán, chữ Nhật. Không có khái niệm "đầu từ" theo dấu cách. Bản viết lại coi cả cụm chữ liền nhau là một từ; muốn tách đúng thì dùng
Intl.Segmentervớigranularity: 'word'.
#Dùng trong dự án
const results = contacts.filter((c) => isQueryMatch(query, c.name));
{results.map((c) => (
<li key={c.id}>
{highlightMatch(query, c.name).map((s, i) => (s.matched ? <mark key={i}>{s.text}</mark> : s.text))}
</li>
))}
tsx
Với danh sách lớn (vài chục nghìn liên hệ), nên gấp sẵn tên một lần khi tải dữ liệu thay vì gấp lại mỗi lần gõ.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { fold, highlightMatch, isQueryMatch, mayHaveConnectedCharacters } from './searchTokenize';
const marks = (q: string, t: string) =>
highlightMatch(q, t).map((s) => (s.matched ? `[${s.text}]` : s.text)).join('');
describe('searchTokenize', () => {
it('gấp dấu tiếng Việt, đ và Cyrillic', () => {
expect(fold('Nguyễn Hưng Đặng')).toBe('nguyen hung dang');
expect(fold('Привет Жуков')).toBe('privet zhukov');
});
it('từ cuối là tiền tố, từ đã gõ xong phải khớp đủ', () => {
expect(isQueryMatch('nguy', 'Nguyễn Văn An')).toBe(true);
expect(isQueryMatch('nguy ', 'Nguyễn Văn An')).toBe(false);
expect(isQueryMatch('van ng', 'Nguyễn Văn An')).toBe(true);
expect(isQueryMatch('an an', 'Nguyễn Văn An')).toBe(false);
});
it('highlight trên chuỗi gốc, đầu từ sau dấu câu', () => {
expect(marks('nguyen', 'Nguyễn Văn An')).toBe('[Nguyễn] Văn An');
expect(marks('an', '(An) Bình')).toBe('([An]) Bình');
expect(marks('ng ', 'Nguyễn Văn Nam')).toBe('[Ng]uyễn Văn Nam');
});
it('nhận diện chữ ghép nét đúng, không nhầm dấu gạch dài', () => {
expect(mayHaveConnectedCharacters('ﻻ')).toBe(true);
expect(mayHaveConnectedCharacters('Hello – World')).toBe(false);
});
});
ts
#So với chuẩn web và thư viện
String.prototype.normalizevà regex Unicode property escapes (\p{M},\p{Script=…}) đều có trên các trình duyệt chính (Chrome, Firefox, Safari, tính tới 10/2026).Intl.Collatorhợp cho sắp xếp và so sánh bằng nhau không phân biệt dấu, không hợp cho tìm tiền tố và highlight.Intl.Segmentertách từ đúng cho cả tiếng Nhật, tiếng Thái; có thể thaySEPARATORkhi cần.- Các thư viện fuzzy search phổ biến (Fuse.js, MiniSearch…) cho phép cấu hình hàm chuẩn hoá; vẫn nên truyền hàm gấp dấu kiểu NFD như trên để người dùng gõ không dấu tìm đượ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
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ (bài này)
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

