Skip to content

一个表情是几个字符:字节、码点、UTF-16 单元与字形 ​

昵称限制「最多 10 个字符」。前端用 JavaScript 的 length 判断,后端用 Java 的 length(),数据库字段是 VARCHAR(10)。一个用户的昵称里有两个家庭表情,前端说超长,另一个只有一个国旗的昵称被截断后,页面上显示成了一个问号加一个奇怪的字母。

「一个字符串有多长」至少有四种答案:占多少字节(存储和传输关心的)、有多少个 Unicode 码点(字符集关心的)、有多少个 UTF-16 单元(Java 和 JavaScript 的 length 返回的)、用户看到几个字形(界面和「最多 N 个字」这类规则关心的)。对 ASCII 来说四个数字相同,对中文前三个就开始不同,对表情、国旗、带组合符号的字母,四个数字可能各不相同。

本文用七个字符串,在 JDK 25、Python 3.14、Node 24 上统计这四个数字,并看截断、反转和比较时会出什么问题。实验见文末。

字符串UTF-16码点UTF-8 字节字形Java/JS lengthPython len用户看到的中1131é(预组合)1121e + 组合重音2231笑脸2141一家三口(ZWJ 序列)85181中国国旗(两个区域指示符)4281带肤色的点赞4281截断:按 UTF-16 留下孤立代理(写库变成 ?);按码点把一家三口切成一个人比较:预组合与分解的 é 不相等,NFC 后相等;面向用户的长度规则按字形计算
图 1 · 实测(JDK 25.0.4.1、Python 3.14.8、Node 24.21.0):Java 与 JS 的 length 是 UTF-16 单元,Python 的 len 是码点;一家三口的表情是 8 个单元、5 个码点、18 个字节,用户看到的只有 1 个字形。按 UTF-16 截断会留下孤立代理,按码点反转会把中国国旗变成新喀里多尼亚。

一、四个数字 ​

text
字符串             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 代替。

二、截断与反转 ​

text
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,显示出来是新喀里多尼亚的旗帜。

需要截断显示的文本(摘要、昵称),应该按字形截断;不能按字形处理时,至少按码点截断,避免产生非法字符。

三、看起来一样,比较却不相等 ​

text
é(预组合)与 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 或同等编码,并确认字段长度的单位。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权