Đếm ký tự UTF-16 vs code point vs byte: Twitter, SMS, VARCHAR và tiếng Việt có dấu
“Độ dài chuỗi” không phải một số duy nhất. Ít nhất cần tách: code point, UTF-16 code unit (String.length trong JS), byte UTF-8, và đôi khi grapheme (chữ người dùng thấy). Tiếng Việt có dấu + emoji làm lệch Twitter/X, SMS và VARCHAR. Đo nhanh trên trình duyệt với công cụ đếm ký tự Kawa (số ký tự theo JS length + byte UTF-8, dòng/từ, không cần đăng ký).
Bạn cắt bio.slice(0, 160) cho form, trên máy nhìn ổn — user Việt dán câu có dấu và emoji 👨💻 thì API trả too long, hoặc SMS nhà mạng tính 3 segments. Root cause gần như luôn là đang đếm nhầm đơn vị.
Bài viết này giúp bạn
- Phân biệt code point / UTF-16 / byte / grapheme
- Hiểu cạm bẫy tiếng Việt có dấu và emoji
- Map sang Twitter/X, SMS, DB VARCHAR
- Có ví dụ số liệu và checklist trước khi validate độ dài
- Dùng char-count để soi JS length vs UTF-8 bytes
Bốn thước đo (đừng gộp một chữ “ký tự”)
| Thước | Nghĩa thực tế | Ví dụ trong JS / DB |
|---|---|---|
| Grapheme | Cụm người dùng coi là 1 chữ (có thể gồm nhiều code point) | Cần Intl.Segmenter hoặc lib |
| Code point | Điểm mã Unicode | [...str].length hoặc for…of |
| UTF-16 code unit | Đơn vị lưu trong string JS/Java/C# nhiều nơi | str.length |
| UTF-8 byte | Byte trên mạng / nhiều cột DB / file | TextEncoder().encode(str).length |
const s = "👨💻"; // man + ZWJ + computer
console.log(s.length); // 5 — UTF-16 code units
console.log([...s].length); // 3 — code points
// Grapheme thường = 1 “ký tự nhìn thấy”
const vi = "Xin chào";
console.log(vi.length); // 8 (UTF-16 units ≈ code points ở đây)
console.log(new TextEncoder().encode(vi).length); // 9 bytes UTF-8
Chữ à trong “chào” thường là 1 code point (NFC) nhưng 2 byte UTF-8 — đã lệch “8 chữ / 9 byte”. Nếu chuỗi ở dạng NFD (base + combining mark), code point còn tăng.
Mở đếm ký tự, dán Xin chào và một emoji: tool hiển thị số ký tự = length kiểu JS (UTF-16) và byte UTF-8 — đủ để bắt lệch trước khi đổ lỗi cho backend.
Tiếng Việt có dấu: NFC, NFD, byte
Hai chuỗi trông giống nhau có thể khác bytes:
NFC "Xin chào" → thường 8 code points, 9 UTF-8 bytes
NFD "Xin chào" → nhiều code points hơn nếu dấu tách riêng, byte cũng khác
Hệ quả:
- Hash / cache key / chữ ký lệch (cùng bài MD5)
VARCHARtheo byte đầy sớm- So sánh string trong test fail dù “nhìn giống”
Thói quen tốt: normalize NFC ở biên API trước khi đếm và lưu.
const normalized = input.normalize("NFC");
Twitter / X, SMS, meta description
| Kênh | Limit (gần đúng) | Đơn vị cần nhớ |
|---|---|---|
| X (Twitter) | 280 (weighted) | Không = JS length; emoji/URL có rule riêng |
| SMS GSM 7-bit | ~160 / segment | ASCII/GSM; ít dấu Việt |
| SMS UCS-2 | ~70 / segment | Khi có Unicode (nhiều tin Việt) |
| Meta description SEO | ~150–160 hiển thị | Pixel/hiển thị ≈ ký tự; vẫn kiểm byte nếu CMS limit |
| MySQL VARCHAR(n) utf8mb4 | n character (thường) | Xác nhận version/config — đừng nhầm với byte của utf8 cũ |
SMS marketing tiếng Việt
Soạn 120 “chữ” có dấu → tưởng còn dưới 160. Nhà mạng đếm UCS-2 → hết 70/segment → tin bị xé 2–3 đoạn, giá tin nhắn tăng. Trước khi gửi chiến dịch: dán copy vào char-count xem byte + length, và hỏi nhà cung cấp SMS đang UCS-2 hay Unicode segmentation thế nào.
Cắt chuỗi trên frontend
// ❌ Nguy hiểm với emoji / surrogate
bio.slice(0, 50);
// ✅ Cắt theo code point (vẫn chưa = grapheme)
[...bio].slice(0, 50).join("");
// ✅✅ UI “một chữ nhìn thấy” — Intl.Segmenter khi target browser đủ
slice theo UTF-16 có thể cắt giữa surrogate pair → ký tự lạ “.
Case study 1: Form bio VARCHAR(100) + tiếng Việt
API: reject khi Buffer.byteLength(s, 'utf8') > 100.
FE: validate value.length <= 100.
User nhập ~90 chữ Việt có dấu → FE OK, BE fail.
Fix: FE dùng cùng hàm đếm byte (hoặc gọi cùng normalize + đếm) — đối chiếu bằng tool trước khi viết test.
Case study 2: Đếm emoji trong comment
"ok 👨💻" — PM đếm 4 “ký tự”; length = 2 + 5 = 7; code point = 2 + 3 = 5; grapheme ≈ 3.
Limit “max 5 characters” trong spec mơ hồ → QA và dev cãi nhau một buổi.
Fix spec: ghi rõ UTF-8 bytes hoặc grapheme clusters hoặc JS length.
Checklist trước khi ship validate độ dài
- Spec ghi đơn vị (byte / code point / grapheme / rule SNS)
- FE và BE dùng cùng hàm đếm
normalize('NFC')ở input boundary nếu nhận tiếng Việt- Test fixture: ASCII, tiếng Việt có dấu, emoji đơn, emoji ZWJ, CRLF
- Không dùng
sliceUTF-16 cho user-facing truncate mà không kiểm tra - Đo mẫu thật trên char-count — nhìn cả length và bytes
Cách đọc số trên công cụ Kawa
Trên /vi/tools/char-count/:
- Ký tự ≈
String.length(UTF-16 code units) — gần với nhiều validate JS - Byte = UTF-8 qua
TextEncoder— gần với nhiều API Node/byteLength - Dòng / từ — hữu ích bản thảo; SNS/DB thường không dùng “từ” tiếng Việt như English word count
Tool không thay composer X và không mô phỏng segment SMS nhà mạng — nhưng đủ để phát hiện “trông ngắn mà byte dài” trước khi khách complaint.
Tóm tắt nhanh
| Nhu cầu | Nên đếm |
|---|---|
Debug str.length / nhiều validate JS | UTF-16 units |
| Payload HTTP, file, nhiều DB byte limit | UTF-8 bytes |
| “User thấy bao nhiêu chữ” (pure UI) | Grapheme |
| Twitter/X | Rule weighted của X |
| SMS Việt có dấu | Giả định UCS-2 ~70/segment trừ khi provider khẳng định khác |
Bookmark lý do: bảng bốn thước đo + case VARCHAR/SMS tiếng Việt — hết cãi “sao 80 chữ mà vẫn over limit” trong team VN.