为什么 "👨👩👧👦".length 是 11:码点、代理对与字素簇
在浏览器控制台敲 "👨👩👧👦".length,回车,得到 11。一个肉眼看上去明明是"一个"图标的东西,JavaScript 说它有 11。如果你的昵称长度校验、短信截断、数据库 VARCHAR 限制是按这个数来的,那它们全都在用错误的单位计数。这篇把"字符串长度到底是什么"这件事一层层拆开。
"一个字符串有多长"这个问题没有唯一答案,因为"长度"至少有四种,取决于你问的是哪一层。从底到顶:字节数、码元数、码点数、字素簇数。绝大多数 bug,都来自把其中一层的数字当成了另一层。
四层模型,用 "😀" 走一遍
| 层级 | "😀" 在这层是几 | 谁按这个算 |
|---|---|---|
| 字节(UTF-8) | 4 | 网络传输、数据库存储 |
| 码元(UTF-16 code unit) | 2 | JS .length、Java String.length() |
| 码点(code point) | 1 | [...str]、for...of、Python 3 len() |
| 字素簇(grapheme,肉眼字符) | 1 | 用户的"一个字"、光标移动 |
😀 比较温和,码点和字素簇都是 1。真正暴露问题的是那个家庭 emoji,下面专门拆它。
码元:JS 的 .length 数的是 UTF-16 单位
JavaScript 字符串内部是 UTF-16。BMP 里的字符(U+0000–U+FFFF)占 1 个码元,.length 加 1,符合直觉。但补充平面的字符(U+10000 以上,比如所有 emoji)在 UTF-16 里用一对码元表示——这叫代理对(surrogate pair)。于是:
"a".length // 1
"中".length // 1 —— 在 BMP 内
"😀".length // 2 —— 补充平面,一个代理对
"𝕏".length // 2 —— 数学粗体 X 也是补充平面
.length 数的是"UTF-16 码元个数",不是"字符个数"。这俩在纯 BMP 文本里恰好相等,于是这个误会能潜伏很久,直到有人在昵称里放了个 emoji。
拆解那个 11
"👨👩👧👦" 不是一个字符,它是一个 ZWJ 序列——四个独立 emoji 用"零宽连接符"(ZWJ,U+200D)粘起来,渲染引擎看到这个序列才把它画成一家四口:
👨 U+1F468 男人 → 补充平面,2 个码元
ZWJ U+200D 零宽连接 → BMP, 1 个码元
👩 U+1F469 女人 → 补充平面,2 个码元
ZWJ U+200D 1 个码元
👧 U+1F467 女孩 → 补充平面,2 个码元
ZWJ U+200D 1 个码元
👦 U+1F466 男孩 → 补充平面,2 个码元
────────────────────────────────────────
4×2 (emoji) + 3×1 (ZWJ) = 11 个码元
const family = "👨👩👧👦";
family.length // 11 —— UTF-16 码元
[...family].length // 7 —— 码点(4 emoji + 3 ZWJ)
// 字素簇(肉眼的"1 个")要靠 Intl.Segmenter,见下
真正的 bug 是截断。str.slice(0, 10) 这种按码元下标切,很可能正好切在一个代理对中间,把半个 emoji 留下——结果是一个孤立的代理码元,渲染成 �,存进库还可能因为不是合法 UTF-8 报错。按字节截断(很多短信/推送网关这么干)更惨,一刀下去一串乱码。
正确地数和切:按码点,或按字素簇
看你要的是哪种"一个"。要"程序意义上的字符"(码点),用展开运算符;要"用户眼里的一个字"(字素簇),用 Intl.Segmenter——现代浏览器和 Node 18+ 都支持了。
// 按码点:emoji 不会被劈开,但 ZWJ 序列会散成多个
[..."😀abc"].length // 4,安全
// 按字素簇:把 ZWJ 序列、变体选择符、肤色修饰都算作 1
const seg = new Intl.Segmenter("zh", { granularity: "grapheme" });
[...seg.segment("👨👩👧👦")].length // 1 —— 这才是用户认知里的"1 个"
[...seg.segment("👍🏽")].length // 1 —— 带肤色的点赞也是 1
// 安全截断前 N 个"肉眼字符"
function truncate(str, n) {
const segs = [...seg.segment(str)].map(s => s.segment);
return segs.slice(0, n).join("");
}
做昵称长度限制、输入框计数、卡片标题截断,应该按字素簇。做数据库存储评估,按字节。两者别混。想直观看一段文本在不同口径下各是多少,丢进 在线字数统计,字符数和字节数分开列出来。
还有一个隐藏维度:同一个字,两种码点序列
以为按码点数就稳了?再加一层。"é" 这个字符有两种合法写法:
const a = "é"; // U+00E9,单个预组合码点
const b = "é"; // U+0065 + U+0301(e + 组合用重音符)
a === b // false !两个字符串不相等
a.length // 1
b.length // 2
a.normalize() === b.normalize() // true —— 归一化后相等
它们长得一模一样,=== 却不相等。这会坑到去重、搜索、唯一约束:用户两次输入"看起来一样"的用户名,你的唯一索引拦不住。修法是存储和比较前统一做 Unicode 归一化(一般用 NFC)。
这一层还有安全含义:攻击者可以用同形字(不同码点、外观相同的字符,比如拉丁 а 和西里尔 а)注册一个看起来和管理员一样的用户名,或伪造域名做钓鱼。涉及身份的字段,除了 NFC 归一化,还要考虑限制允许的字符集、或做同形字检测。
各语言怎么对待这件事
- JavaScript / Java:
.length是 UTF-16 码元。要码点用[...str]/codePointAt/str.codePoints()。 - Python 3:
len()数的是码点,len("😀")就是 1,比 JS 友好一截。但字素簇仍要第三方库(如regex的\X)。 - Go:
len(s)是字节数,utf8.RuneCountInString才是码点数。range字符串按码点迭代。 - Rust:
s.len()是字节,s.chars().count()是码点,字素簇靠unicode-segmentationcrate。
没有哪门语言的默认 length 直接给你"肉眼字符数"——因为字素簇的切分规则复杂、还会随 Unicode 版本更新。它们各自选了一个"够用又便宜"的层次当默认,把麻烦留给了不知道这回事的人。
一句话收尾
下次写"字符串不能超过 20 个字符"这种需求,先反问一句:哪种字符?字节、码元、码点、字素簇是四件不同的事。想清楚用户、数据库、传输层各自关心哪一层,比记住"length 是 11"这个结论重要得多。