前端 · 编码

为什么 "👨‍👩‍👧‍👦".length 是 11:码点、代理对与字素簇

在浏览器控制台敲 "👨‍👩‍👧‍👦".length,回车,得到 11。一个肉眼看上去明明是"一个"图标的东西,JavaScript 说它有 11。如果你的昵称长度校验、短信截断、数据库 VARCHAR 限制是按这个数来的,那它们全都在用错误的单位计数。这篇把"字符串长度到底是什么"这件事一层层拆开。

"一个字符串有多长"这个问题没有唯一答案,因为"长度"至少有四种,取决于你问的是哪一层。从底到顶:字节数、码元数、码点数、字素簇数。绝大多数 bug,都来自把其中一层的数字当成了另一层。

四层模型,用 "😀" 走一遍

层级"😀" 在这层是几谁按这个算
字节(UTF-8)4网络传输、数据库存储
码元(UTF-16 code unit)2JS .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 归一化,还要考虑限制允许的字符集、或做同形字检测。

各语言怎么对待这件事

没有哪门语言的默认 length 直接给你"肉眼字符数"——因为字素簇的切分规则复杂、还会随 Unicode 版本更新。它们各自选了一个"够用又便宜"的层次当默认,把麻烦留给了不知道这回事的人。

一句话收尾

下次写"字符串不能超过 20 个字符"这种需求,先反问一句:哪种字符?字节、码元、码点、字素簇是四件不同的事。想清楚用户、数据库、传输层各自关心哪一层,比记住"length 是 11"这个结论重要得多。

在线字数统计

中英文混排、字符数与字节数分开计,看清文本在各口径下到底"算几个"

打开字数统计