Mở console của trình duyệt và gõ "🤦🏽♂️".length. Kết quả là 7, cho thứ trông như một ký tự. Đây không phải lỗi của JavaScript. Văn bản có ít nhất bốn kiểu "kích thước", và phần lớn lỗi về độ dài chuỗi đến từ việc dùng kiểu này khi cần kiểu khác. Bài này tháo rời emoji ôm mặt và vài từ tiếng Việt, giải thích từng kiểu kích thước, rồi chỉ cách đếm, cắt, so sánh và lưu văn bản mà không làm hỏng nó. Mọi con số dưới đây đều tái tạo được bằng công cụ tra cứu Unicode và emoji.
Bốn kích thước của một emoji
🤦🏽♂️ (người đàn ông ôm mặt, màu da trung bình) được ghép từ năm điểm mã Unicode:
| Điểm mã | Tên | Đơn vị UTF-16 | Byte UTF-8 |
|---|---|---|---|
| U+1F926 | FACE PALM | 2 | 4 |
| U+1F3FD | EMOJI MODIFIER FITZPATRICK TYPE-4 | 2 | 4 |
| U+200D | ZERO WIDTH JOINER | 1 | 3 |
| U+2642 | MALE SIGN | 1 | 3 |
| U+FE0F | VARIATION SELECTOR-16 | 1 | 3 |
Vậy nó là 1 cụm grapheme (thứ bạn nhìn thấy), 5 điểm mã, 7 đơn vị mã UTF-16 và 17 byte UTF-8. Khuôn mặt và bộ điều chỉnh màu da nằm trên U+FFFF, ngoài mặt phẳng đa ngôn ngữ cơ bản, nên UTF-16 lưu mỗi cái bằng một cặp đơn vị "surrogate"; chuỗi trong JavaScript, Java và C# là UTF-16, nên length báo 7. Ký tự nối rộng 0 (zero width joiner) dán khuôn mặt với ký hiệu nam thành một hình, còn bộ chọn biến thể yêu cầu kiểu emoji có màu.
Emoji gia đình như 👨👩👧👦 còn đi xa hơn: 1 grapheme, 7 điểm mã, 11 đơn vị UTF-16 và 25 byte UTF-8. Một lá cờ như 🇻🇳 là hai chữ cái chỉ báo vùng, V và N: 1 grapheme, 2 điểm mã, 4 đơn vị, 8 byte.
Dấu tiếng Việt cũng vậy
Tiếng Việt cho thấy chuyện này không chỉ riêng emoji. Từ "Việt" có 4 grapheme. Viết ở dạng dựng sẵn thông thường (NFC), "ệ" là một điểm mã, U+1EC7, và cả từ là 4 điểm mã, 4 đơn vị UTF-16, 6 byte UTF-8. Viết ở dạng tổ hợp (NFD), "ệ" thành ba điểm mã: e, U+0323 dấu chấm dưới tổ hợp và U+0302 dấu mũ tổ hợp. Từ đó trông y hệt nhưng giờ là 6 điểm mã, 6 đơn vị và 8 byte.
Đó cũng là lý do "é" === "é" cho false trong JavaScript dù hiển thị giống nhau: một bên là U+00E9, bên kia là "e" cộng dấu sắc tổ hợp. Văn bản chép từ tên tệp trên macOS, từ PDF hay gõ bằng một số bộ gõ thường đến ở dạng NFD.
Dùng kích thước nào
- Grapheme cho mọi thứ con người đếm: giới hạn ký tự trong biểu mẫu, "hiện 50 ký tự đầu", di chuyển con trỏ, đảo ngược chuỗi. Trong JavaScript,
[...new Intl.Segmenter().segment(text)].lengthđếm chúng; đa số ngôn ngữ có thư viện tương ứng. - Điểm mã cho thuộc tính Unicode và các định dạng trao đổi định nghĩa theo điểm mã.
[...text].length(hoặcArray.from) đếm điểm mã trong JavaScript. - Đơn vị UTF-16 chỉ cho chỉ số bạn truyền ngược lại cho cùng môi trường chạy, như
selectionStartcủa ô nhập liệu. - Byte UTF-8 cho lưu trữ và truyền tải: kích thước cột cơ sở dữ liệu, thân yêu cầu HTTP, kích thước tệp.
new TextEncoder().encode(text).lengthcho ra con số này.
Cắt và so sánh an toàn
Cắt theo đơn vị UTF-16 có thể chẻ đôi một điểm mã. "Hi 👍🏽".slice(0, 4) trả về "Hi " kèm một nửa surrogate lẻ loi, hiển thị thành � và có thể khiến bộ tuần tự hóa JSON hay cơ sở dữ liệu từ chối chuỗi. Cắt theo điểm mã tránh được điều đó nhưng vẫn có thể tách ngón cái khỏi màu da, hay chữ "e" khỏi dấu của nó. Hãy cắt theo ranh giới grapheme: tách cụm, lấy n cụm đầu, nối lại.
Để so sánh văn bản, hãy chuẩn hóa hai bên về cùng một dạng trước: a.normalize("NFC") === b.normalize("NFC"). NFC là mặc định đúng cho lưu trữ. Với định danh, tên người dùng và khóa tìm kiếm, NFKC còn gộp các ký tự tương thích (chữ ghép fi thành "fi", chữ toàn chiều rộng thành chữ thường), tránh hai cách viết trông khác nhau của cùng một tên.
Những lỗi hay gặp
- Kiểm tra độ dài bằng `.length`. Một tin nhắn 10 emoji có thể bị từ chối vì "hơn 40 ký tự", trong khi giới hạn tính bằng byte của cơ sở dữ liệu lại bị vượt bởi văn bản đã qua kiểm tra
.length. - Dùng bộ ký tự `utf8` của MySQL. Đó là mã hóa 3 byte (utf8mb3), không lưu được emoji vốn cần 4 byte trong UTF-8. Hãy dùng
utf8mb4. - Đảo ngược hay cắt chuỗi bằng `split("")`. Nó chẻ cặp surrogate và tách dấu tổ hợp; đảo "Việt" ở dạng NFD thì dấu rơi sang sai chữ.
- So sánh mà không chuẩn hóa. Hai tên tệp trông giống hệt, một NFC một NFD, là hai chuỗi khác nhau.
- Cho rằng một emoji là một điểm mã. Màu da, giới tính, kiểu tóc và cờ đều là chuỗi nhiều điểm mã.
Thử ngay
Dán bất kỳ văn bản nào vào thẻ phân tích của công cụ tra cứu Unicode và emoji để xem bốn cách đếm, các điểm mã của từng grapheme kèm tên và byte, cùng bốn dạng chuẩn hóa. Để đếm từ và ký tự của văn bản dài, bộ công cụ văn bản cũng đếm theo grapheme.