一个表情是几个字符:字节、码点、UTF-16 单元与字形
昵称限制「最多 10 个字符」。前端用 JavaScript 的
length判断,后端用 Java 的length(),数据库字段是VARCHAR(10)。一个用户的昵称里有两个家庭表情,前端说超长,另一个只有一个国旗的昵称被截断后,页面上显示成了一个问号加一个奇怪的字母。
「一个字符串有多长」至少有四种答案:占多少字节(存储和传输关心的)、有多少个 Unicode 码点(字符集关心的)、有多少个 UTF-16 单元(Java 和 JavaScript 的 length 返回的)、用户看到几个字形(界面和「最多 N 个字」这类规则关心的)。对 ASCII 来说四个数字相同,对中文前三个就开始不同,对表情、国旗、带组合符号的字母,四个数字可能各不相同。
本文用七个字符串,在 JDK 25、Python 3.14、Node 24 上统计这四个数字,并看截断、反转和比较时会出什么问题。实验见文末。
一、四个数字
字符串 Java length() / JS length Python len() UTF-8 字节 字形
中 1 1 3 1
é(预组合) 1 1 2 1
e + 组合重音 2 2 3 1
笑脸 2 1 4 1
一家三口 8 5 18 1
中国国旗 4 2 8 1
带肤色的点赞 4 2 8 1- UTF-16 单元:Java 的
String和 JavaScript 的字符串内部是 UTF-16,length返回的是单元数。基本多文种平面以外的字符(大部分表情)要用两个单元(代理对)表示,所以一个笑脸的长度是 2。 - 码点:Python 3 的
len()返回码点数,一个笑脸是 1。Java 的codePointCount、JavaScript 的[...s].length也是码点数。 - 字节:取决于编码,UTF-8 下中文 3 字节、表情 4 字节。数据库的字段长度、网络协议的长度上限通常按字节或按码点计算,要看具体的定义。
- 字形:用户看到的一个「字」。一家三口由三个人的表情和两个零宽连接符组成,5 个码点、18 个字节,显示出来是一个图形;国旗由两个区域指示符组成;带肤色的点赞是一个表情加一个肤色修饰符。Java 用
BreakIterator.getCharacterInstance()、JavaScript 用Intl.Segmenter可以按字形分割,Python 标准库没有对应的功能,需要第三方库。
「最多 10 个字」这类面向用户的规则,应该按字形计算;存储和协议的限制按它们自己定义的单位计算。两者分别校验,不能用一个 length 代替。
二、截断与反转
Java substring(0, 1) / JS slice(0, 1) 截笑脸:得到孤立代理 U+D83D
Java 编码成 UTF-8 后变成了 "?";JS 的 TextEncoder 编码成 U+FFFD
Python s[:1] 截一家三口:只剩第一个人 U+1F468
三种语言按码点反转国旗:区域指示符的顺序变成 N、C按 UTF-16 单元截断会把代理对切成两半,留下一个孤立代理。它不是一个合法的字符,写进数据库、编码成 UTF-8 时会变成 ? 或替换字符 U+FFFD,开头那个「问号」就是这么来的。按码点截断(Python 的切片)不会产生非法字符,但会把一个字形拆开:一家三口变成了一个人。
反转字符串更糟:中国国旗是区域指示符 C、N 的组合,按码点反转后变成 N、C,显示出来是新喀里多尼亚的旗帜。
需要截断显示的文本(摘要、昵称),应该按字形截断;不能按字形处理时,至少按码点截断,避免产生非法字符。
三、看起来一样,比较却不相等
é(预组合)与 e + 组合重音比较:三种语言都不相等;NFC 规范化后相等
"straße" 转大写:STRASSE,长度 6 → 7同一个 é 可以写成一个预组合字符(U+00E9),也可以写成 e 加一个组合重音符(U+0301),显示完全一样,比较却不相等。从 macOS 的文件名、某些输入法、复制粘贴的文本里,都可能得到分解形式。用户名、搜索关键词、去重的键,应该在存储前统一规范化(通常是 NFC)。
大小写转换也会改变长度:德语的 ß 转大写变成 SS。按「转大写后的长度」做校验、按转换前的位置做截取,都可能出错。
四、数据库与协议
- MySQL 的
utf8(即utf8mb3)每个字符最多 3 字节,存不下表情,要用utf8mb4。VARCHAR(10)在 MySQL 里是 10 个字符(码点),在一些其他数据库或按字节定义的字段里是 10 个字节。 - JSON 本身按码点处理,但
😀这种转义形式是 UTF-16 代理对;生成 JSON 的库如果按 UTF-16 截断,可能产生无法解析的转义。 - HTTP 头、日志系统常按字节限制长度,截断时同样要避开多字节字符的中间。
五、检查清单
- 先确认每个长度限制的单位:字形、码点、UTF-16 单元还是字节。
- 面向用户的长度规则按字形计算;Java 用
BreakIterator,JavaScript 用Intl.Segmenter。 - 截断按字形,至少按码点;不要按 UTF-16 单元截断用户文本。
- 不要反转用户文本。
- 比较、去重、搜索前做 Unicode 规范化。
- 数据库使用
utf8mb4或同等编码,并确认字段长度的单位。
配套实验
- codesphere-labs/engineering/unicode-length:JDK 25.0.4.1、Python 3.14.8、Node 24.21.0:七个字符串的 UTF-16 单元、码点、UTF-8 字节与字形,截断、反转、规范化与大小写转换(验证记录)
参考资料