Đếm ký tự JS sai vì surrogate pair | .length vs emoji vs UTF-8【2026】
.length trong JavaScript không phải “số ký tự mắt nhìn thấy”. Nó đếm UTF-16 code unit. Emoji như 🍎 là surrogate pair → .length === 2. Cần code point: [...str].length hoặc Array.from(str). Cần “ô chữ” người dùng: Intl.Segmenter. Giới hạn DB/API còn có thể là byte UTF-8. Dán thử trên Đếm ký tự Kawa — ô ký tự ≈ .length, ô byte ≈ TextEncoder — rồi mới tin vào validation.
Fresher Việt paste "Xin chào 🍎" vào form, assert value.length <= 20, QA bảo “chỉ 10 chữ mà vẫn fail”. Console log ra 12 hoặc hơn vì emoji chiếm hai unit. Bug này tái hiện liên tục ở team outsourcing khi client Nhật/Hàn dùng emoji trong title, caption Zalo, hoặc meta description.
Bài này tập trung một góc: hiểu surrogate pair và chọn đúng đơn vị đếm — không phải hướng dẫn chung “cách dùng counter”.
Bài viết này giúp bạn
- Phân biệt code unit / code point / grapheme / UTF-8 byte
- Biết vì sao
.length“phình” với emoji và một số ký tự - Có snippet JS copy được và bảng quyết định theo use case
- Biết tool Kawa đang đo gì (tránh ảo tưởng “đếm grapheme”)
- Checklist trước khi merge validation độ dài
Bốn đơn vị đếm — đừng trộn
| Đơn vị | Ví dụ API | "🍎".? | Khi nào dùng |
|---|---|---|---|
| UTF-16 code unit | str.length | 2 | Repro bug JS; một số API cũ đếm UTF-16 |
| Code point | [...str].length | 1 | Hầu hết “ký tự Unicode” đơn |
| Grapheme | Intl.Segmenter | 1 (kể cả ZWJ phức tạp hơn) | UI, đếm “ô” người dùng nhìn |
| UTF-8 byte | TextEncoder | 4 | Payload JSON, cột BYTE, giới hạn HTTP |
Tiếng Việt "ế" là một code point (hoặc đôi khi tổ hợp base+dấu tùy nguồn nhập). Emoji phần lớn nằm ngoài BMP → hai code unit UTF-16. Đó là surrogate pair.
Surrogate pair là gì (ngắn, đủ dùng)
UTF-16 dùng 16 bit mỗi unit. Ký tự ngoài Basic Multilingual Plane được mã hóa thành cặp:
- High surrogate:
0xD800–0xDBFF - Low surrogate:
0xDC00–0xDFFF
Một emoji = hai phần tử trong representation nội bộ của String. .length đếm từng phần → 2. Spread [...str] iterate theo code point (khi engine xử lý đúng) → 1.
const s = "🍎abc";
console.log(s.length); // 5 (🍎 = 2 unit)
console.log([...s].length); // 4 (4 code point)
console.log([...s]); // ["🍎", "a", "b", "c"]
// ZWJ family — phức tạp hơn
const family = "👨👩👧";
console.log(family.length); // thường > 1 (nhiều unit)
console.log([...family].length); // nhiều code point, không phải 1 “ô”
Muốn đếm grapheme (ô nhìn thấy):
function graphemeLength(str) {
const seg = new Intl.Segmenter("vi", { granularity: "grapheme" });
return [...seg.segment(str)].length;
}
Intl.Segmenter có trên trình duyệt hiện đại; Node cũ có thể cần polyfill — kiểm tra target runtime trước khi dùng production.
Case study: validation form “tối đa 50 ký tự”
Tình huống: Freelancer nhận brief: bio user tối đa 50 ký tự. Implement if (bio.length > 50) reject.
Fixture thật:
| Chuỗi | .length | [...].length | Ghi chú |
|---|---|---|---|
Hello | 5 | 5 | ASCII ổn |
Xin chào | 8 | 8 | khoảng trắng tính |
Táo 🍎 | 6 | 5 | emoji +2 unit |
🎉🎉🎉 | 6 | 3 | ba emoji = sáu unit |
User Việt viết caption ngắn có 3 emoji → frontend báo “quá dài” dù mắt thấy ngắn. Client phàn nàn “app Nhật không bị”. Nguyên nhân: họ đếm grapheme hoặc code point phía server khác frontend.
Cách xử lý:
- Thống nhất với PM/BE: đơn vị là gì?
- Mirror cùng hàm đếm ở client và server (hoặc chỉ validate server).
- Test fixture có emoji + tiếng Việt có dấu trong unit test.
CRLF và khoảng trắng — trap thứ hai
Copy từ Excel/Windows Notepad:
dòng1\r\n
dòng2\r\n
vs paste đã chuẩn hóa:
dòng1\n
dòng2\n
Mỗi dòng Windows thêm một code unit \r. Hai dòng = +2 vào .length. Khi so “số ký tự DB” vs “số trên textarea”, lệch vài đơn vị thường đến từ đây — không phải surrogate.
Quy tắc: trước khi so sánh độ dài với API, text.replace(/\r\n/g, "\n") (hoặc ngược lại nếu server bắt CRLF).
Công cụ đếm ký tự Kawa đo gì?
Đếm ký tự trên trình duyệt chạy local — không upload. Theo implementation hiện tại:
- Số ký tự ≈
String.prototype.length(UTF-16), có tùy chọn bỏ khoảng trắng/xuống dòng trước khi đếm - Số dòng ≈
split(/\r?\n/) - Số từ ≈ tách theo khoảng trắng (hữu ích tiếng Anh hơn tiếng Việt không dấu cách từ)
- Bytes ≈
new TextEncoder().encode(text).length(UTF-8)
Nghĩa là: dán 🍎 → ký tự 2, bytes 4. Đó là feature để debug, không phải bug của tool. Nếu bạn cần grapheme count cho UI, hãy dùng Intl.Segmenter trong code — đừng giả định mọi counter online đều đếm “ô mắt thấy”.
🔢 Đếm ký tự ngay tại đây
Số ký tự
0
Số dòng
0
Số từ
0
Dung lượng (Bytes UTF-8)
0
Gợi ý dùng nhanh: dán cùng một caption Zalo/Facebook, ghi lại cặp (ký tự, byte), rồi đối chiếu preview thật của nền tảng. Nhiều mạng xã hội đếm theo quy tắc riêng với emoji — số trên tool chỉ là baseline JS/UTF-8.
Bảng quyết định: dùng gì khi nào
| Ngữ cảnh | Nên dùng | Tránh |
|---|---|---|
| Debug vì sao JS length lệch | str.length + giải thích surrogate | Đổi sang grapheme rồi quên document |
| Giới hạn 'số ký tự Unicode' API | [...str].length / Array.from | Tin mù str.length |
| Đếm ô trên UI (emoji ghép) | Intl.Segmenter grapheme | Chỉ spread |
| Cột/payload theo byte | TextEncoder UTF-8 | Nhầm byte với .length |
| Meta description SEO sơ bộ | Đo + preview Search Console | Hard-code 160 bằng .length có emoji |
Checklist trước khi merge
- Comment hoặc schema ghi rõ đơn vị: code unit / code point / grapheme / byte
- Fixture: ASCII + tiếng Việt có dấu + ít nhất một emoji BMP-ngoài
- Nếu UI hiển thị “còn X ký tự”, hàm đếm trùng với backend
- Chuẩn hóa
\r\nkhi so với file Windows - Đã thử trên char-count để thấy
.lengthvs UTF-8 byte cạnh nhau - Không copy
bio.length > Ntừ Stack Overflow mà không đọc N nghĩa là gì
Lỗi thường gặp
1. “Emoji làm vỡ substring / slice”
str.slice(0, 1) trên chuỗi bắt đầu bằng emoji có thể cắt giữa surrogate → ký tự lỗi (�). Dùng spread hoặc thư viện truncate theo code point/grapheme.
2. “JSON.stringify rồi đếm length”
Dấu ngoặc kép và escape làm lệch so với độ dài hiển thị user.
3. “MySQL utf8 vs utf8mb4”
Charset cũ không lưu đủ emoji; lưu được rồi vẫn có thể lệch giới hạn index. Kiểm tra migration, đừng chỉ nhìn frontend.
4. “Word count cho tiếng Việt”
Tách theo space không phản ánh “số từ” ngôn ngữ Việt. Đừng dùng word count tool cho brief viết tiếng Việt có dấu cách bất thường.
Tóm tắt mang đi
- Surrogate pair = lý do emoji làm
.length= 2 - Ba tầng: code unit → code point → grapheme; thêm UTF-8 byte khi nói payload
- Thống nhất đơn vị với backend trước khi viết
if (length > N) - Tool local giúp nhìn cùng lúc
.lengthvà byte — mở Đếm ký tự khi review PR có validation chuỗi
Bookmark bài này khi lần sau QA bảo “chữ ít mà vẫn over limit”: mở console, gõ s.length và [...s].length, đối chiếu — thường ra đáp án trong 30 giây.