一个 emoji 为什么 length 是 2 甚至 11:码元、码点、字素簇三层长度全解

· 约 4 分钟 😀 Emoji 与符号

三段事实,先摆出来:

"😀".length          // 2
"👨‍👩‍👧‍👦".length      // 11
[..."👨‍👩‍👧‍👦"].length  // 7

而 Python 里 len("👨‍👩‍👧‍👦") 是 7,Go 里 len("😀") 是 4。这些数字全都是对的——因为「字符串长度」在 Unicode 世界里有三层定义,每层数的东西不同。搞混哪两层,就对应一类线上事故。

三层长度:码元、码点、字素簇

**码点(code point)**是 Unicode 给每个字符的编号,如 😀 = U+1F600。这是逻辑层。

码元(code unit)是某种编码下的存储单位。UTF-16 用 16 位码元:U+FFFF 以内的字符占 1 个码元,之外的(绝大多数 emoji)拆成代理对——高代理 + 低代理共 2 个码元,"😀""\uD83D\uDE00"JS、Java、C# 的 length 数的都是 UTF-16 码元,所以 😀 是 2。Go 和多数数据库按 UTF-8 字节数,😀 是 4 字节。Python 3 的 len 按码点,😀 是 1。

**字素簇(grapheme cluster)**是人眼看到的「一个字符」。家庭 emoji 👨‍👩‍👧‍👦 由 7 个码点组成——4 个人物(各 2 码元)+ 3 个零宽连接符 ZWJ(各 1 码元):

👨‍👩‍👧‍👦 的「长度」谁在用
字素簇1人眼、光标移动
码点7Python len、JS Array.from
UTF-16 码元11JS/Java/C# 的 .length
UTF-8 字节25Go len、数据库、网络传输

同理:🇨🇳 是两个区域指示符(JS length 4)、👍🏽 是基础 + 肤色修饰符(length 4)、❤️ 是心 + 变异选择符(length 2)。为什么 emoji 会是这种「多码点组合」的构造,机制在emoji 跨平台显示差异里展开。

事故一:截断切出 �

s.slice(0, 140) 这行代码埋着两级雷:

  1. 切开代理对。截断点落在某个 emoji 的高低代理中间,留下孤立代理——渲染成 �,而且孤立代理不是合法的 Unicode 标量值:JSON 序列化、UTF-8 编码、数据库写入都可能在这里报错或静默替换。故障离截断代码很远,排查时想不到源头。
  2. 切开字素簇。没劈开码点,但把 👍🏽 的肤色切掉、把一家四口切剩两口——不报错,内容变形。

防御分三档:最低限度用 Array.from(s).slice(0, n).join('') 按码点截(不再产生孤立代理);完整方案用 Intl.Segmentergranularity: 'grapheme')按字素簇截;展示层的省略号交给 CSS text-overflow,别用 JS 截字符串——从根上绕过整个问题。

事故二:MySQL 报 Incorrect string value

经典到有专名的坑:MySQL 的 utf8 是历史遗留的阉割版(utf8mb3),每字符最多 3 字节,而 BMP 之外的 emoji 需要 4 字节 UTF-8。含 emoji 的写入直接报 Incorrect string value,或经过转换层后变成 ????

修法是全链路 utf8mb4:表和列 CONVERT TO CHARACTER SET utf8mb4连接字符集(JDBC URL、DSN 里的 charset 参数)同步改——三层里任何一层还是 utf8mb3 都白改。MySQL 8.0 起默认已是 utf8mb4;老库迁移注意索引长度:utf8mb4 下每字符按 4 字节预算,旧版 767 字节的索引前缀限制可能让 VARCHAR(255) 建不了索引。

相邻的雷还有短信:含 emoji 的短信按 UCS-2 编码,单条从 160/70 字掉到 70 字——营销短信里一个表情,成本翻倍。

事故三:前后端的「140 字」不是同一个 140

字数限制类 bug 的标准剧本:前端按 s.length(码元)放行,后端按字节数校验拒绝——用户明明看着没超限,提交就是失败。

规则其实简单:

  • 面向用户的限制按字素簇——用户打一个 👨‍👩‍👧‍👦 被扣 11 个字的额度是投诉级体验。JS 用 Intl.Segmenter 数。
  • 面向存储的限制按字节——new TextEncoder().encode(s).length
  • 码元数(.length)对谁都不准,它只是历史默认值。

以及一条流程要求:接口文档写明「长度」指哪一层,前后端用同一算法。四个都叫长度的数字(1 / 7 / 11 / 25)没约定清楚,联调必吵架。

排查工具

字符串长度对不上、截断行为诡异时,别瞪着黑盒猜——把它拆开看:Unicode 编解码工具能把任意字符串拆成逐个码点,ZWJ、变异选择符、肤色修饰符这些不可见成分一眼现形;配合字数统计可以对照几种口径的计数差异。不可见字符在普通文本里搞出的同类破坏(BOM、零宽空格),见文本里看不见的字符;利用「长得一样、码点不同」做钓鱼的进阶话题,见Unicode 同形字攻击

❓ 常见问题

为什么 JS 里 "😀".length 是 2?

因为 JS 字符串按 UTF-16 码元计数,而 😀(U+1F600)超出了 16 位能直接表示的范围,要用两个码元(代理对)表示机制:UTF-16 用一个 16 位码元表示 U+0000–U+FFFF(基本平面 BMP);更大的码点(U+10000 以上,绝大多数 emoji 都在这里)拆成「高代理 + 低代理」两个码元——😀 在 JS 里就是 \uD83D\uDE00。length 数的是码元,所以是 2。连锁反应:(1) charAt/charCodeAt 拿到的是半个字符(孤立代理);(2) 用下标遍历会把 emoji 劈成两半;(3) codePointAt 和 for...of / Array.from 按码点走,"😀" 用 Array.from 得到长度 1。其他语言对照:Java/C# 同样按 UTF-16 码元(length 也是 2);Python3 的 len 按码点(😀 是 1);Go 的 len 按 UTF-8 字节(😀 是 4),range 遍历才按码点。同一个字符串在四种语言里 len 出四个数,全都「对」。

那 "👨‍👩‍👧‍👦".length 为什么是 11?

因为家庭 emoji 是 7 个码点的组合,其中 4 个人物各占 2 个码元,3 个零宽连接符各占 1 个:4×2 + 3×1 = 11分解:👨(U+1F468) + ZWJ(U+200D) + 👩(U+1F469) + ZWJ + 👧(U+1F467) + ZWJ + 👦(U+1F466)。三层长度对照这一个例子记牢:码元(JS length)= 11;码点(Python len、JS Array.from 长度)= 7;字素簇(人眼看到的「一个字符」)= 1同类还有:🇨🇳 是两个区域指示符码点 = JS length 4;👍🏽 是基础 + 肤色修饰符 = length 4;❤️ 是心 + 变异选择符 = length 2(心在 BMP 内)。结论:「一个 emoji 占几个字符」没有统一答案,取决于哪一层在数——涉及用户感知的一切(字数限制、截断、光标移动)都应该按字素簇数,而三大主流层里只有它没有现成的 length 属性。

按长度截断字符串,为什么截出了 � 乱码?

因为截断点落在了代理对中间或组合序列中间,留下半个字符两级破坏:(1) 切开代理对——s.slice(0, 5) 恰好把某个 emoji 的高低代理劈开,留下孤立代理,渲染成 �,序列化 JSON、写数据库时还可能直接报错(孤立代理不是合法的 Unicode 标量值,UTF-8 编码器会拒绝或替换它);(2) 切开字素簇——没劈开码点,但把 👍🏽 的肤色修饰符切掉了、把家庭切成半个家庭,显示不报错但内容变形。正确姿势分档:(1) 最低要求:用 Array.from(s).slice(0, n).join("") 按码点截,保证不产生孤立代理;(2) 完整方案:用 Intl.Segmenter(granularity: "grapheme")按字素簇截,emoji、组合音标、带修饰的字符都不会被切坏;(3) 展示层的「…」省略交给 CSS text-overflow 而不是 JS 截字符串,从根上绕过这个问题。

MySQL 报 Incorrect string value 存不进 emoji,怎么回事?

因为 MySQL 的 utf8 是历史遗留的「阉割版」(utf8mb3),每字符最多 3 字节,而 BMP 之外的 emoji 需要 4 字节的 UTF-8 编码修法:表和连接都换成 utf8mb4——ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci(或按版本选 0900 系 collation),连接字符集(jdbc url / DSN 里的 charset 参数)同步改,任何一层还是 utf8mb3 都照样报错或变 ????。MySQL 8.0 起默认已是 utf8mb4,老库迁移要注意索引长度:utf8mb4 下 VARCHAR(255) 的索引字节数变大,旧版本 767 字节限制的 InnoDB 表可能建不了索引,要缩短列或开启大前缀。其他存储的对应雷:(1) 短信网关按 UCS-2 计费,含 emoji 的短信每条只剩 70 字(纯 GSM 字符 160 字),一个表情让营销短信成本翻倍;(2) 某些老系统/接口按字节限长,emoji 4 字节,中文 3 字节,「200 字符」的字段实际装不下 200 个字。

做「字数限制」功能,到底该按哪种长度数?

面向用户的限制按字素簇数,面向存储的限制按字节数,两个都要,别用码元数——它对谁都不准用户侧:输入框「还能输入 20 字」应该按人眼看到的字符数(字素簇)——用户输入一个 👨‍👩‍👧‍👦 被扣掉 11 个字的额度,是投诉级体验;JS 里用 Intl.Segmenter 数,[...segmenter.segment(s)].length。存储侧:数据库列、接口字段的物理上限是字节,校验按 new TextEncoder().encode(s).length(UTF-8 字节数)。前后端一致性是这类 bug 的重灾区:前端按 s.length(码元)放行、后端按字节拒绝,用户看着没超限却提交失败——双方必须写明用哪一层长度并用同一算法对照速查(👨‍👩‍👧‍👦):字素簇 1、码点 7、UTF-16 码元 11、UTF-8 字节 25。四个数字都叫「长度」,合同里没写清是哪个,就等着联调吵架。

😀 打开 Emoji 与符号 1900+ Emoji + 200+ 符号·中文搜笑脸/箭头/数学/希腊/颜文字·点击复制·双击收藏·本地运行

📖 同一工具的其他教程

🔗 相关阅读

全部教程 →