Web Totals

Học từ Facebook #55: Tách cụm emoji bằng máy trạng thái

EmojiRenderer của Facebook duyệt từng code point qua máy trạng thái 12 trạng thái để tách đúng emoji ghép ZWJ, cờ, màu da, keycap; so sánh với Intl.Segmenter.

hunghg255hunghg25522 min read
#facebook#emoji#unicode#i18n#text
Học từ Facebook #55: Tách cụm emoji bằng máy trạng thái

'👩🏽‍💻'.length bằng 7. Với người dùng đó là một emoji, với JavaScript là 7 đơn vị UTF-16, 4 code point: 👩, 🏽 (màu da), ZWJ (ký tự nối vô hình), 💻. Lá cờ 🇻🇳 là hai "regional indicator" đứng cạnh nhau. 1️⃣ là chữ số 1 + VS16 + dấu keycap. Cờ Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿 dài 14 đơn vị UTF-16.

Đếm sai thì giới hạn "tối đa 3 emoji" sai, cắt chuỗi giữa cụm thì ra ký tự vỡ, thay emoji bằng ảnh (để mọi máy thấy cùng kiểu vẽ) mà tách sai thì ra nửa người nửa laptop.

Tip

Tóm tắt

  • Một emoji người dùng thấy là một cụm nhiều code point: ZWJ, màu da, cặp regional indicator, keycap, chuỗi tag, VS15/VS16.
  • Facebook duyệt từng code point qua một máy trạng thái 12 trạng thái, trả về { emoji, offset, length } để thay bằng <img>.
  • #, chữ số, © chỉ là emoji khi có VS16 hoặc keycap; có VS15 thì là chữ. Lọc bước này nếu không sẽ đếm "#1" là emoji.
  • Ngày nay: Intl.Segmenter tách grapheme, rồi lọc grapheme nào là emoji. Cẩn thận: \p{Extended_Pictographic} một mình chưa đủ.

#Facebook làm thế nào?

Module EmojiRenderer (bundle 26.js) export bốn hàm: parse, render, containsEmoji, countEmoji. Dữ liệu (isEmoji, isEmojiModifierBase, isRegionalIndicator…) nằm ở EmojiRendererData: các hàm so sánh khoảng code point sinh tự động từ dữ liệu Unicode, không dùng regex.

Máy trạng thái có 12 trạng thái, đặt bằng số i=0 … t=11. Giải mã (đổi tên):

const EMOJI = 0, COMBINING = 1, VS16 = 2, FLAG_DONE = 3, MOD_BASE = 4, MODIFIED = 5,
      TAG = 6, ZWJ_SEEN = 7, NONE = 8, REGIONAL = 9, TEXT = 10, VS15 = 11;

switch (state) {
  case REGIONAL:  state = isRegionalIndicator(cp) ? FLAG_DONE : NONE; break;
  case MOD_BASE:  if (isEmojiModifier(cp)) { state = MODIFIED; break; } // rơi xuống case EMOJI
  case EMOJI:     state = isZWJ(cp) ? ZWJ_SEEN : isVS16(cp) ? VS16 : isVS15(cp) ? VS15
                        : isNonSpacingMark(cp) ? COMBINING : isTagSpec(cp) ? TAG : NONE; break;
  case ZWJ_SEEN:  state = isRegionalIndicator(cp) ? REGIONAL : isEmojiModifierBase(cp) ? MOD_BASE
                        : isEmoji(cp) ? EMOJI : NONE; break;
  case TAG:       state = isTagSpec(cp) || isTagTerm(cp) ? TAG : NONE; break;
  // …
}
if (state === NONE) {
  // không nối được: thử mở cụm mới từ ký tự này
  state = isRegionalIndicator(cp) ? REGIONAL : isEmojiModifierBase(cp) ? MOD_BASE
        : isText(cp) ? TEXT : isEmoji(cp) ? EMOJI : NONE;
  if (state !== NONE) { if (current && isValid(current.emoji)) out.push(current); current = { emoji: [ch], offset, length }; }
} else current.emoji.push(ch);
js

Vài chi tiết đáng học:

  • Rơi xuống case (fall-through) có chủ đích. MOD_BASE thử màu da trước, không phải màu da thì xử lý như emoji thường. Viết lại bằng ngôn ngữ khác phải giữ thứ tự này.
  • Lọc sau khi tách. Hàm u() loại cụm không phải emoji thật: ký tự "text" (#, 0-9, ©) đứng một mình, hoặc chỉ kèm dấu kết hợp không phải keycap; ký tự "symbol" đứng một mình; cụm kết thúc bằng TagSpec mà thiếu TagTerm (chuỗi tag bị cắt dở).
  • Dừng sớm. containsEmoji(s) gọi parse(s, 1): đủ một cụm là dừng, không quét hết chuỗi dài.
  • Khoá ảnh. FBEmojiUtils nối mã hex các code point bằng _ và bỏ _fe0f: ❤️ → 2764. getSupportedModifierSequences sinh 5 biến thể màu da (U+1F3FB…U+1F3FF) và chỉ giữ biến thể có trong bảng SupportedEmoji3.
  • render(text, fn) trả về mảng xen kẽ chuỗi và kết quả của fn(emoji), để React vẽ <img> ngay trong dòng chữ.
stateDiagram-v2
  [*] --> NONE
  NONE --> EMOJI: "emoji thường"
  NONE --> MOD_BASE: "emoji nhận màu da"
  NONE --> REGIONAL: "regional indicator"
  NONE --> TEXT: "# 0-9 ©"
  MOD_BASE --> MODIFIED: "màu da"
  REGIONAL --> FLAG_DONE: "RI thứ hai"
  EMOJI --> ZWJ_SEEN: "ZWJ"
  MODIFIED --> ZWJ_SEEN: "ZWJ"
  ZWJ_SEEN --> EMOJI: "emoji"
  ZWJ_SEEN --> MOD_BASE: "emoji nhận màu da"
  EMOJI --> TAG: "tag spec"
  TAG --> TAG: "tag spec / tag term"
  TEXT --> VS16: "VS16"
  VS16 --> COMBINING: "keycap"

#Bên trong Unicode: "grapheme cluster mở rộng"

Unicode định nghĩa cụm ký tự người dùng thấy là một đơn vị trong UAX #29 (extended grapheme cluster). Các quy tắc liên quan emoji:

  • Extended_Pictographic (Extend* ZWJ Extended_Pictographic)*: chuỗi emoji nối bằng ZWJ là một cụm.
  • Hai Regional_Indicator liên tiếp ghép thành một cụm (đếm theo cặp từ đầu).
  • Extend (gồm dấu kết hợp, màu da, VS15/VS16, tag) luôn dính vào ký tự trước.

Máy trạng thái của Facebook là một bản thu gọn các quy tắc đó, cộng thêm hiểu biết "cụm nào là emoji" mà UAX #29 không nói (UAX #29 chỉ tách cụm, không phân loại).

#Viết lại

Bản viết lại giữ cấu trúc máy trạng thái, nhưng đặt tên trạng thái bằng chữ và dùng Unicode property escape (\p{Emoji_Modifier_Base}, \p{Regional_Indicator}, \p{Extended_Pictographic}) thay bảng dữ liệu sinh sẵn, nên không cần cập nhật bảng khi Unicode thêm emoji.

Bảng chuyển trạng thái (startState mở cụm mới, afterEmoji xử lý ký tự sau một emoji):

src/lib/emojiSegment.ts
function step(state: State, cp: number, ch: string): State {
  switch (state) {
    case 'regional':
      return RE.regional.test(ch) ? 'flagDone' : 'none';
    case 'modifierBase':
      if (RE.modifier.test(ch)) return 'modified';
      return afterEmoji(cp, ch);
    case 'emoji':
      return afterEmoji(cp, ch);
    case 'combining':
    case 'vs16':
      if (RE.combining.test(ch)) return state;
      return cp === ZWJ ? 'zwj' : isTagSpec(cp) ? 'tag' : 'none';
    case 'flagDone':
    case 'modified':
      return cp === ZWJ ? 'zwj' : isTagSpec(cp) ? 'tag' : 'none';
    case 'tag':
      return isTagSpec(cp) || cp === TAG_TERM ? 'tag' : 'none';
    case 'zwj':
      if (RE.regional.test(ch)) return 'regional';
      if (RE.modifierBase.test(ch)) return 'modifierBase';
      return RE.pictographic.test(ch) ? 'emoji' : 'none';
    case 'text':
      if (RE.combining.test(ch)) return 'combining';
      return cp === VS16 ? 'vs16' : 'none';
    default:
      return 'none';
  }
}
ts

Vòng duyệt chính:

src/lib/emojiSegment.ts
/** Tách các cụm emoji. `limit`: dừng sớm khi đã đủ số cụm (containsEmoji dùng limit = 1). */
export function parseEmoji(text: string, limit = Infinity): EmojiMatch[] {
  const out: EmojiMatch[] = [];
  let current: EmojiMatch | null = null;
  let state: State = 'none';
  let i = 0;
  while (i < text.length) {
    const cp = text.codePointAt(i)!;
    const ch = String.fromCodePoint(cp);
    state = step(state, cp, ch);
    if (state === 'none') {
      // không nối được vào cụm cũ: đóng cụm cũ, xem ký tự này có mở cụm mới không
      if (current && isRealEmoji(current.parts)) out.push(current);
      current = null;
      if (out.length >= limit) return out;
      state = startState(ch);
      if (state !== 'none') current = { parts: [ch], offset: i, length: ch.length };
    } else if (current) {
      current.parts.push(ch);
      current.length += ch.length;
    }
    i += ch.length;
  }
  if (current && isRealEmoji(current.parts) && out.length < limit) out.push(current);
  return out;
}
ts

Cách hiện đại để đối chiếu: Intl.Segmenter tách grapheme, regex EMOJI_GRAPHEME (xem toàn bộ file) phân loại.

src/lib/emojiSegment.ts
export function parseEmojiIntl(text: string): EmojiMatch[] | null {
  if (typeof Intl === 'undefined' || !('Segmenter' in Intl)) return null;
  const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
  const out: EmojiMatch[] = [];
  for (const s of seg.segment(text)) {
    if (EMOJI_GRAPHEME.test(s.segment) && !s.segment.includes('\uFE0E')) {
      out.push({ parts: Array.from(s.segment), offset: s.index, length: s.segment.length });
    }
  }
  return out;
}
ts
Xem toàn bộ src/lib/emojiSegment.ts
src/lib/emojiSegment.ts
/**
 * Tách cụm emoji trong chuỗi (ý tưởng từ EmojiRenderer của Facebook).
 *
 * Một "emoji" người dùng thấy có thể là nhiều code point:
 *   👩🏽‍💻 = 👩 + 🏽 (màu da) + ZWJ + 💻
 *   🇻🇳   = hai regional indicator
 *   1️⃣   = '1' + VS16 + U+20E3 (keycap)
 *   🏴󠁧󠁢󠁳󠁣󠁴󠁿  = 🏴 + chuỗi tag + cancel tag
 * Facebook duyệt từng code point bằng một máy trạng thái. Bản này giữ cấu trúc đó nhưng
 * dùng Unicode property escape thay cho bảng dữ liệu sinh sẵn.
 */

export interface EmojiMatch {
  /** các code point (dạng chuỗi) của cụm */
  parts: string[];
  /** vị trí và độ dài theo đơn vị UTF-16, để cắt chuỗi gốc */
  offset: number;
  length: number;
}

const RE = {
  regional: /^\p{Regional_Indicator}$/u,
  modifierBase: /^\p{Emoji_Modifier_Base}$/u,
  modifier: /^\p{Emoji_Modifier}$/u,
  pictographic: /^\p{Extended_Pictographic}$/u,
  // ký tự "text" có thể thành emoji khi đi kèm VS16/keycap: # * 0-9 © ® ‼ …
  textEmoji: /^[#*0-9\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9\u21aa]$/u,
  combining: /^[\u0300-\u036f\u20d0-\u20ff]$/u, // gồm U+20E3 keycap
};
const ZWJ = 0x200d;
const VS16 = 0xfe0f;
const VS15 = 0xfe0e;
const isTagSpec = (cp: number) => cp >= 0xe0020 && cp <= 0xe007e;
const TAG_TERM = 0xe007f;

/** Trạng thái giống bản gốc (đặt tên thay cho số 0–11). */
type State =
  | 'emoji' // vừa gặp emoji thường
  | 'combining' // sau dấu kết hợp (keycap)
  | 'vs16' // sau VS16
  | 'flagDone' // đã đủ cặp regional indicator
  | 'modifierBase' // emoji nhận được màu da
  | 'modified' // đã gắn màu da
  | 'tag' // đang trong chuỗi tag (cờ Scotland…)
  | 'zwj' // vừa gặp ZWJ, chờ emoji nối tiếp
  | 'none' // ngoài emoji
  | 'regional' // mới có một regional indicator
  | 'text' // ký tự text có thể thành emoji (#, 0-9…)
  | 'vs15'; // VS15: ép hiển thị dạng chữ

function startState(ch: string): State {
  if (RE.regional.test(ch)) return 'regional';
  if (RE.modifierBase.test(ch)) return 'modifierBase';
  if (RE.textEmoji.test(ch)) return 'text';
  if (RE.pictographic.test(ch)) return 'emoji';
  return 'none';
}

function afterEmoji(cp: number, ch: string): State {
  if (cp === ZWJ) return 'zwj';
  if (cp === VS16) return 'vs16';
  if (cp === VS15) return 'vs15';
  if (RE.combining.test(ch)) return 'combining';
  if (isTagSpec(cp)) return 'tag';
  return 'none';
}

function step(state: State, cp: number, ch: string): State {
  switch (state) {
    case 'regional':
      return RE.regional.test(ch) ? 'flagDone' : 'none';
    case 'modifierBase':
      if (RE.modifier.test(ch)) return 'modified';
      return afterEmoji(cp, ch);
    case 'emoji':
      return afterEmoji(cp, ch);
    case 'combining':
    case 'vs16':
      if (RE.combining.test(ch)) return state;
      return cp === ZWJ ? 'zwj' : isTagSpec(cp) ? 'tag' : 'none';
    case 'flagDone':
    case 'modified':
      return cp === ZWJ ? 'zwj' : isTagSpec(cp) ? 'tag' : 'none';
    case 'tag':
      return isTagSpec(cp) || cp === TAG_TERM ? 'tag' : 'none';
    case 'zwj':
      if (RE.regional.test(ch)) return 'regional';
      if (RE.modifierBase.test(ch)) return 'modifierBase';
      return RE.pictographic.test(ch) ? 'emoji' : 'none';
    case 'text':
      if (RE.combining.test(ch)) return 'combining';
      return cp === VS16 ? 'vs16' : 'none';
    default:
      return 'none';
  }
}

/**
 * Cụm có thật sự là emoji không? '#' hay '1' đứng một mình là chữ; chỉ thành emoji
 * khi có VS16 hoặc keycap. Một regional indicator lẻ cũng không tính.
 */
function isRealEmoji(parts: string[]) {
  const first = parts[0];
  if (!first) return false;
  if (parts.some((p) => p.codePointAt(0) === VS15)) return false;
  if (RE.textEmoji.test(first)) return parts.length > 1;
  if (RE.regional.test(first)) return parts.length >= 2;
  return true;
}

/** Tách các cụm emoji. `limit`: dừng sớm khi đã đủ số cụm (containsEmoji dùng limit = 1). */
export function parseEmoji(text: string, limit = Infinity): EmojiMatch[] {
  const out: EmojiMatch[] = [];
  let current: EmojiMatch | null = null;
  let state: State = 'none';
  let i = 0;
  while (i < text.length) {
    const cp = text.codePointAt(i)!;
    const ch = String.fromCodePoint(cp);
    state = step(state, cp, ch);
    if (state === 'none') {
      // không nối được vào cụm cũ: đóng cụm cũ, xem ký tự này có mở cụm mới không
      if (current && isRealEmoji(current.parts)) out.push(current);
      current = null;
      if (out.length >= limit) return out;
      state = startState(ch);
      if (state !== 'none') current = { parts: [ch], offset: i, length: ch.length };
    } else if (current) {
      current.parts.push(ch);
      current.length += ch.length;
    }
    i += ch.length;
  }
  if (current && isRealEmoji(current.parts) && out.length < limit) out.push(current);
  return out;
}

export const containsEmoji = (text: string) => parseEmoji(text, 1).length === 1;
export const countEmoji = (text: string) => parseEmoji(text).length;

/** Thay mỗi cụm emoji bằng kết quả của `render` (vd thẻ <img>), giữ nguyên phần chữ. */
export function renderEmoji<T>(text: string, render: (m: EmojiMatch) => T): Array<string | T> {
  const out: Array<string | T> = [];
  let last = 0;
  for (const m of parseEmoji(text)) {
    if (m.offset > last) out.push(text.slice(last, m.offset));
    out.push(render(m));
    last = m.offset + m.length;
  }
  if (last < text.length) out.push(text.slice(last));
  return out;
}

/** Khoá ảnh kiểu Facebook: mã hex các code point nối bằng '_', bỏ VS16. */
export function emojiImageKey(m: EmojiMatch) {
  return m.parts
    .map((p) => p.codePointAt(0)!)
    .filter((cp) => cp !== VS16)
    .map((cp) => cp.toString(16))
    .join('_');
}

/** Năm biến thể màu da cho emoji nhận được màu da (U+1F3FB…U+1F3FF). */
export function skinToneVariants(base: string) {
  if (!RE.modifierBase.test(base)) return [];
  return [0x1f3fb, 0x1f3fc, 0x1f3fd, 0x1f3fe, 0x1f3ff].map((m) => base + String.fromCodePoint(m));
}

// ---- Cách hiện đại: Intl.Segmenter tách grapheme, rồi lọc grapheme nào là emoji ----

// Grapheme là emoji khi: mặc định hiển thị dạng emoji, hoặc có VS16 / màu da / ZWJ / cờ / keycap.
// Chỉ \p{Extended_Pictographic} là chưa đủ: '©' hay '❤' mặc định là CHỮ nếu không có VS16.
const EMOJI_GRAPHEME =
  /\p{Emoji_Presentation}|\p{Extended_Pictographic}(?:\uFE0F|\p{Emoji_Modifier}|\u200D)|\p{Regional_Indicator}{2}|[#*0-9]\uFE0F?\u20E3/u;

export function parseEmojiIntl(text: string): EmojiMatch[] | null {
  if (typeof Intl === 'undefined' || !('Segmenter' in Intl)) return null;
  const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
  const out: EmojiMatch[] = [];
  for (const s of seg.segment(text)) {
    if (EMOJI_GRAPHEME.test(s.segment) && !s.segment.includes('\uFE0E')) {
      out.push({ parts: Array.from(s.segment), offset: s.index, length: s.segment.length });
    }
  }
  return out;
}

/**
 * Cách ngây thơ hay gặp: đếm code point có thuộc tính \p{Emoji}. Sai hai kiểu: đếm lẻ từng mảnh
 * của một cụm (����‍�� thành 3), và đếm cả '#', chữ số vì chúng cũng có thuộc tính Emoji.
 */
export function countEmojiNaive(text: string) {
  return Array.from(text).filter((ch) => /\p{Emoji}/u.test(ch)).length;
}
ts

#Đi qua code

  • step(state, cp, ch) là bảng chuyển trạng thái. Trả về 'none' nghĩa là ký tự này không nối được vào cụm đang mở.
  • Khi 'none', vòng chính đóng cụm cũ (nếu là emoji thật) rồi gọi startState(ch) xem ký tự có mở cụm mới không. Đây đúng là đoạn if (f === q) của bản gốc.
  • isRealEmoji là hàm u(): '#' một mình không tính, '#' + VS16 + keycap thì tính; cụm có VS15 (ép hiển thị dạng chữ) không tính; một regional indicator lẻ không tính.
  • Offset/length tính theo đơn vị UTF-16 (ch.length là 1 hoặc 2), khớp với String.prototype.slice, nên renderEmoji cắt chuỗi gốc chính xác.
  • parseEmojiIntl: Intl.Segmenter tách grapheme đúng chuẩn UAX #29, việc còn lại chỉ là phân loại grapheme nào là emoji.

Khác bản gốc:

  • Dùng \p{...} thay bảng dữ liệu tự sinh. Bản gốc có bảng riêng để chạy được cả trên trình duyệt chưa hỗ trợ Unicode property escape (ES2018).
  • Bản gốc có trạng thái riêng cho dấu kết hợp không phải keycap (j) và cho ký tự "symbol". Bản viết lại gộp vào combining và lọc ở isRealEmoji; với mẫu thử trong demo, kết quả trùng với Intl.Segmenter.

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

Đếm bằng /\p{Emoji}/gu? Đây là lỗi hay gặp nhất. \p{Emoji} là thuộc tính của từng code point, và #, *, chữ số 0-9 đều có thuộc tính Emoji (vì có thể thành keycap). Demo: câu "Code xong rồi 👩🏽‍💻🎉 gửi team 🇻🇳" có 3 emoji, cách đếm này ra 6 (đếm lẻ 👩, 🏽, 💻 và hai nửa lá cờ). Câu "Mã đơn #1 và 1️⃣" ra 3 thay vì 1.

Chỉ dùng [...str] (tách theo code point)? Tách 👩🏽‍💻 thành 4 mảnh, lá cờ thành 2.

Regex khổng lồ kiểu emoji-regex? Chạy tốt, là cách nhiều thư viện dùng. Nhược điểm: regex hàng chục KB, cần cập nhật theo phiên bản Unicode. Facebook chọn máy trạng thái + bảng khoảng, chạy một lượt O(n) và cho luôn offset.

Intl.Segmenter một mình? Tách cụm đúng, nhưng không cho biết cụm nào là emoji. Lọc bằng \p{Extended_Pictographic} thì sai với © và ❤: hai ký tự này mặc định hiển thị dạng chữ, chỉ thành emoji khi có VS16. Bản trong bài lọc bằng \p{Emoji_Presentation} hoặc Extended_Pictographic đi kèm VS16/màu da/ZWJ. Trong demo, mẫu 5 ("© 2026") lúc đầu ra 1 emoji với cách lọc đơn giản, sau khi sửa ra 0, khớp máy trạng thái.

#Edge case và cạm bẫy

  • Emoji mới. Máy trạng thái chỉ quan tâm cấu trúc (ZWJ, màu da…), nên emoji ghép mới vẫn tách đúng nếu từng mảnh có trong dữ liệu. Với \p{...}, dữ liệu là của engine JavaScript, cập nhật theo trình duyệt.
  • Nhiều regional indicator liền nhau (🇻🇳🇯🇵): ghép theo cặp từ đầu. Số lẻ thì cái cuối đứng một mình và bị loại.
  • Cắt chuỗi giữa cụm (giới hạn ký tự của bình luận): cắt tại offset + length của cụm, đừng cắt theo .length thô.
  • Chuỗi tag bị cắt dở (cờ Scotland mất TAG_TERM): bản gốc loại cụm kết thúc bằng tag spec; bản viết lại vẫn giữ phần 🏴 nếu chuỗi tag dở. Trường hợp này hiếm.
  • Hiệu năng. Mỗi code point chạy vài regex property. Với văn bản rất dài (hàng trăm KB), dùng bảng khoảng như bản gốc hoặc Intl.Segmenter sẽ nhanh hơn.

#Dùng trong dự án

// Giới hạn "tối đa 3 emoji" cho tên nhóm
if (countEmoji(name) > 3) setError('Tối đa 3 emoji');

// Vẽ emoji bằng ảnh để mọi máy thấy giống nhau
const parts = renderEmoji(text, (m) => (
  <img key={m.offset} className='emoji' alt={text.slice(m.offset, m.offset + m.length)}
       src={`/emoji/${emojiImageKey(m)}.png`} />
));
return <p>{parts}</p>;

// Tin nhắn chỉ có emoji thì phóng to (kiểu Messenger, Telegram)
const big = parseEmoji(msg).reduce((n, m) => n + m.length, 0) === msg.trim().length;
tsx

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { countEmoji, containsEmoji, emojiImageKey, parseEmoji, parseEmojiIntl } from './emojiSegment';

describe('parseEmoji', () => {
  it('ghép đúng các cụm phức tạp', () => {
    const s = 'a👩🏽‍💻b🇻🇳c1️⃣d🏴󠁧󠁢󠁳󠁣󠁴󠁿';
    expect(parseEmoji(s).map((m) => s.slice(m.offset, m.offset + m.length))).toEqual(['👩🏽‍💻', '🇻🇳', '1️⃣', '🏴󠁧󠁢󠁳󠁣󠁴󠁿']);
  });
  it('# và chữ số đứng một mình không phải emoji, VS15 ép dạng chữ', () => {
    expect(countEmoji('#1 © ❤︎')).toBe(0);
    expect(countEmoji('❤️')).toBe(1);
  });
  it('khoá ảnh bỏ fe0f', () => {
    expect(emojiImageKey(parseEmoji('❤️')[0])).toBe('2764');
  });
  it('containsEmoji dừng sớm', () => {
    expect(containsEmoji('abc')).toBe(false);
    expect(containsEmoji('a😀' + 'x'.repeat(10_000))).toBe(true);
  });
  it('khớp Intl.Segmenter', () => {
    const s = 'Like 👍🏿 👍🏻, tim ❤️ và ❤︎';
    const a = parseEmoji(s).map((m) => [m.offset, m.length]);
    const b = parseEmojiIntl(s)!.map((m) => [m.offset, m.length]);
    expect(a).toEqual(b);
  });
});
ts

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

  • Intl.Segmenter có trong mọi trình duyệt chính (tính tới 10/2026, xem MDN). Đây là cách nên dùng để tách cụm; bước "cụm nào là emoji" vẫn phải tự làm.
  • Unicode property escape \p{...} (ES2018) có ở mọi trình duyệt hiện đại.
  • Thư viện emoji-regex (regex sinh từ dữ liệu Unicode) là lựa chọn phổ biến khi cần một regex duy nhất.

Bài liên quan: Học từ Facebook #25: tìm kiếm gõ không dấu (cũng là chuyện xử lý Unicode trong ô tìm kiếm).


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