三段事实,先摆出来:
"😀".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 | 人眼、光标移动 |
| 码点 | 7 | Python len、JS Array.from |
| UTF-16 码元 | 11 | JS/Java/C# 的 .length |
| UTF-8 字节 | 25 | Go len、数据库、网络传输 |
同理:🇨🇳 是两个区域指示符(JS length 4)、👍🏽 是基础 + 肤色修饰符(length 4)、❤️ 是心 + 变异选择符(length 2)。为什么 emoji 会是这种「多码点组合」的构造,机制在emoji 跨平台显示差异里展开。
事故一:截断切出 �
s.slice(0, 140) 这行代码埋着两级雷:
- 切开代理对。截断点落在某个 emoji 的高低代理中间,留下孤立代理——渲染成 �,而且孤立代理不是合法的 Unicode 标量值:JSON 序列化、UTF-8 编码、数据库写入都可能在这里报错或静默替换。故障离截断代码很远,排查时想不到源头。
- 切开字素簇。没劈开码点,但把 👍🏽 的肤色切掉、把一家四口切剩两口——不报错,内容变形。
防御分三档:最低限度用 Array.from(s).slice(0, n).join('') 按码点截(不再产生孤立代理);完整方案用 Intl.Segmenter(granularity: '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 同形字攻击。