最后更新:
Emoji 表情符号的字数计算 - 看似 1 个字符实为多个字符的原理
表情符号的历史与 Unicode 各版本的演进
世界上最早的表情符号诞生于 1999 年,由 NTT DoCoMo 的栗田穗崇为 i-mode 设计,共 176 种。它们最初是 12×12 像素的点阵图。当时日本各家运营商 (DoCoMo、au、软银) 各自独立实现表情符号,运营商之间的表情符号乱码问题时常发生。为解决这一兼容性问题,Google 与 Apple 向 Unicode 联盟提议将表情符号标准化,2010 年的 Unicode 6.0 正式收录了 722 种。
此后,表情符号随 Unicode 的每个主要版本持续增加。
| Unicode 版本 | 发布年份 | 新增表情符号数 | 累计表情符号数 (估计) |
|---|---|---|---|
| 6.0 | 2010 | 722 | 722 |
| 7.0 | 2014 | 250 | 约 1,000 |
| 8.0 | 2015 | 41 | 约 1,050 |
| 9.0 | 2016 | 72 | 约 1,100 |
| 11.0 | 2018 | 157 | 约 1,600 |
| 13.0 | 2020 | 117 | 约 3,300 |
| 15.0 | 2022 | 31 | 约 3,600 |
| 16.0 | 2024 | 8 | 约 3,790 |
近年来新增数量呈减少趋势。Unicode 联盟对表情符号提案会严格审查"能否用现有表情符号的组合来表达",凡是可以通过 ZWJ 序列合成实现的情况,一律不再分配新的码位。从提案到正式收录约需 2 年的流程,提案方还必须提交使用频率的预测数据,以及与其他表情符号相区别的依据。
表情符号的 Unicode 结构
表情符号在 Unicode 中的表示方式比想象的要复杂得多。最基本的表情符号 (如 😀 U+1F600) 是单个码点,但许多常见表情符号由多个码点组合而成。
| 表情符号 | 外观 | 码点数 | UTF-8 字节数 | UTF-16 代码单元 |
|---|---|---|---|---|
| 笑脸 | 😀 | 1 | 4 | 2 (代理对) |
| 带肤色的挥手 | 👋🏽 | 2 | 8 | 4 |
| 家庭 | 👨👩👧👦 | 7 | 25 | 11 |
| 国旗 (中国) | 🇨🇳 | 2 | 8 | 4 |
| 彩虹旗 | 🏳️🌈 | 4 | 14 | 7 |
各编码方式的字节大小实测数据
表情符号按 Unicode 详解 - 字符编码入门指南 中的规则进行编码,但不同编码方式下的字节大小差异很大。以下是代表性表情符号的实测数据。
| 表情符号 | 外观 | 码位数 | UTF-8 字节数 | UTF-16 字节数 | UTF-32 字节数 |
|---|---|---|---|---|---|
| 😀 (U+1F600) | 1 个字符 | 1 | 4 | 4 | 4 |
| 👍🏻 (U+1F44D U+1F3FB) | 1 个字符 | 2 | 8 | 8 | 8 |
| 👨👩👧👦 | 1 个字符 | 7 | 25 | 22 | 28 |
| 🇯🇵 (U+1F1EF U+1F1F5) | 1 个字符 | 2 | 8 | 8 | 8 |
| 1️⃣ (U+0031 U+FE0F U+20E3) | 1 个字符 | 3 | 7 | 6 | 12 |
| 🏳️🌈 | 1 个字符 | 4 | 14 | 12 | 16 |
在 UTF-8 中,基本多语言平面 (BMP) 之外的码位每个消耗 4 字节。UTF-16 用代理对 (2 个 16 位单元) 表示,所以同样是 4 字节。UTF-32 为定长编码,1 个码位固定 4 字节,计算简单但内存效率最差。家庭表情符号 👨👩👧👦 在 UTF-8 下要消耗 25 字节,这一点在设计数据库的列大小时尤其需要注意。
为什么 1 个表情符号会被计为多个字符
表情符号的字符计数取决于使用的计数方法。主要有三种计数方式:
- Unicode 码点计数:按 Unicode 码点数计算。家庭表情符号 👨👩👧👦 = 7 个码点 (4 个人物 + 3 个零宽连接符 ZWJ)。
- UTF-16 代码单元计数:JavaScript 的
.length属性使用此方式。基本多语言平面 (BMP) 外的字符需要代理对 (2 个代码单元)。😀 的.length为 2。 - 字素簇计数:按视觉上的"字符"计数。这是最符合人类直觉的方式。👨👩👧👦 = 1 个字素簇。
各社交媒体平台的表情符号计数方式
| 平台 | 计数方式 | 😀 的计数 | 👨👩👧👦 的计数 |
|---|---|---|---|
| X (Twitter) | 加权字符 | 1 | 1 |
| 字素簇 | 1 | 1 | |
| Discord | Unicode 码点 | 1 | 7 |
| UTF-16 代码单元 | 2 | 11 | |
| LINE | UTF-16 代码单元 | 2 | 11 |
X (Twitter) 和 Instagram 对表情符号最为友好,无论多复杂的组合表情符号都只计为 1 个字符。而 Discord 按码点计数,Facebook 和 LINE 按 UTF-16 代码单元计数,复杂表情符号会消耗大量字数。
在各平台的字数上限中,表情符号的实际处理如下。
- X (Twitter):表情符号在内部一律计为 2 个字符 (中文、日文等文本为 1 个字符)。用 ZWJ 连接的家庭表情符号 👨👩👧👦 同样按 2 个字符处理。在 280 字符的上限内大量使用表情符号时,若不了解这一规则,发帖就会在中途被截断。
- Instagram:在说明文字的 2,200 字符上限中,表情符号按 1 个字符计算。但话题标签内的表情符号不会成为检索对象,因此话题标签中不要包含表情符号更有效。
- LINE:文本消息中表情符号按 1 个字符计算。不过 LINE 自有的贴图表情与 Unicode 表情符号在内部受到不同的处理,通过 API 发送消息的开发者需要注意。
- Slack:消息正文中表情符号按 1 个字符计算,但自定义表情会被当作短代码 (例如
:thumbsup:) 处理,包含冒号的整个字符串都会计入字数。 - SMS:只要包含 1 个表情符号,编码就会从 GSM-7 切换为 UCS-2,单条短信的字数上限从 160 字符骤降到 70 字符。在营销短信中使用表情符号时,发送成本可能会变成约 2.3 倍。
零宽连接符 (ZWJ) 序列的原理
许多复杂表情符号使用零宽连接符 (ZWJ, U+200D) 将多个表情符号连接成一个视觉字符。家庭表情符号 👨👩👧👦 由"男性 (U+1F468) + ZWJ + 女性 (U+1F469) + ZWJ + 女孩 (U+1F467) + ZWJ + 男孩 (U+1F466)"共 7 个码位构成。
- 👨💻 (男性技术人员) = 👨 + ZWJ + 💻 = 3 个码点
- 👩🔬 (女性科学家) = 👩 + ZWJ + 🔬 = 3 个码点
- 🏳️🌈 (彩虹旗) = 🏳️ + VS16 + ZWJ + 🌈 = 4 个码点
虚线方格是不会显示在屏幕上的 ZWJ。4 个人物表情符号位于 BMP 之外,每个占用一个代理对 (2 个 UTF-16 代码单元) 和 4 个 UTF-8 字节;3 个 ZWJ 各占 1 个代码单元和 3 个字节。同一个图标,只要换一层来数,答案就分别是 1 (字素簇,Instagram 的计数方式)、7 (码点,Python 3 的 len()) 和 11 (代码单元,JavaScript 的 .length)。由于 UTF-8 下达到 25 个字节,MySQL 每字符最多 3 字节的 utf8 字符集根本无法保存它。
ZWJ 序列的支持取决于操作系统和应用程序。不支持的环境中,组合表情符号会显示为多个独立的表情符号。
采用这一设计的背景,是表情符号组合数量的爆炸式增长。若把 5 种肤色 × 性别 × 职业逐一登记,就需要数万种;而用 ZWJ 合成的方式,只靠基本部件的组合即可表达。Unicode 联盟借此在防止码位枯竭的同时实现了多样性。除 ZWJ 之外,还存在其他控制表情符号显示的不可见码位。
- 变体选择符 (Variation Selector):分为 U+FE0F (表情符号显示) 与 U+FE0E (文本显示) 两种。例如 ❤ (U+2764) 加上 U+FE0F 会显示为 ❤️ (彩色表情符号),加上 U+FE0E 则显示为 ❤︎ (文本符号)。由于这个不可见字符也会计入字节数,就会出现外观相同而数据大小不同的情况。
- 肤色修饰符 (Skin Tone Modifier):为 U+1F3FB 至 U+1F3FF 的 5 个等级,依据菲茨帕特里克量表 (皮肤科学的肤色分类) 制定。加上修饰符会追加 1 个码位 (4 字节)。
- 区域指示符号 (Regional Indicator Symbol):国旗表情符号由对应 A 至 Z 的 26 个区域指示符号 (U+1F1E6 至 U+1F1FF) 两两组合而成。🇯🇵 是 U+1F1EF (J) + U+1F1F5 (P)。由于它对应 ISO 3166-1 的国家代码,不存在的组合 (例如 U+1F1FF + U+1F1FF) 会显示为未定义状态。
JavaScript 的内部字符串表示采用 UTF-16,因此 BMP 之外的表情符号 (U+10000 以上) 会作为代理对用 2 个 16 位单元表示。这就是 "😀".length 返回 2 而不是 1 的根本原因。如果把代理对的高位 (U+D800 至 U+DBFF) 与低位 (U+DC00 至 U+DFFF) 拆开,就会生成非法字符串,所以在字符串截断处理中尤其需要注意。
编程中的表情符号处理
在编程中正确处理表情符号需要注意以下几点:
- JavaScript:
"😀".length返回 2 (UTF-16 代码单元)。使用[..."😀"].length或Intl.Segmenter获取正确的字素簇计数。 - Python 3:
len("😀")返回 1 (码点计数)。但len("👨👩👧👦")返回 7。 - 数据库:MySQL 的
utf8不支持 4 字节表情符号,必须使用utf8mb4。VARCHAR 长度设计时需考虑表情符号的字节消耗。 - 正则表达式:匹配表情符号需要使用 Unicode 属性转义
\p{Emoji},普通的.可能无法正确匹配。
各编程语言字符计数的差异
即使是同一个表情符号,不同编程语言的 length 返回值也不同。这源于各语言内部字符串表示方式的差异。
| 语言 / 方法 | "😀" | "👍🏻" | "👨👩👧👦" | 计数单位 |
|---|---|---|---|---|
JavaScript .length | 2 | 4 | 11 | UTF-16 代码单元 |
JavaScript [...str].length | 1 | 2 | 7 | Unicode 码位 |
Python 3 len() | 1 | 2 | 7 | Unicode 码位 |
Rust .len() | 4 | 8 | 25 | UTF-8 字节数 |
Rust .chars().count() | 1 | 2 | 7 | Unicode 码位 |
Swift .count | 1 | 1 | 1 | 字素簇 |
Go len() | 4 | 8 | 25 | UTF-8 字节数 |
Java .length() | 2 | 4 | 11 | UTF-16 代码单元 |
只有 Swift 按字素簇计数,所以无论哪种表情符号都如外观一样返回 1。JavaScript 与 Java 基于 UTF-16,BMP 之外的表情符号以代理对计为 2。Rust 与 Go 返回字节数,不适合用来统计表情符号的字符数。开发者必须准确掌握自己所用语言的 length 究竟返回什么。
表情符号版本与兼容性
即使是同一个表情符号,在 Apple、Google、Samsung、Microsoft 各平台上的设计也有很大差异,这一点同样需要注意。例如 🔫 (手枪) 在 Apple 改为玩具水枪的设计之后,其他公司也相继跟进,但各家改动的时间点并不一致。在营销或 UI 设计中若依赖表情符号的外观,建议事先确认它在主要平台上的显示效果。
此外,能否显示某个表情符号取决于操作系统与应用是否已支持相应的 Unicode 版本。在字体尚未更新的环境中,新增的表情符号会显示为豆腐块 (□) 等替代字形。
常见的失败模式与对策
- 用 JavaScript 的
String.length实现字数限制:"👨👩👧👦".length返回 11,但外观只是 1 个字符。在表单输入的字数校验中使用String.length,含表情符号的输入就会被不当地限制。改用Intl.Segmenter即可按字素簇为单位准确计数。 - 在字符串中途截断而破坏 ZWJ 序列:把家庭表情符号 👨👩👧👦 在中间的字节位置或代码单元位置截断,ZWJ 序列就会被切断,导致人物表情符号散开显示,或输出非法字符。字符串截断必须在字素簇边界上进行。
- 试图用 MySQL 的
utf8保存表情符号:MySQL 的utf8字符集最多只支持 3 字节,无法保存 4 字节的表情符号 (BMP 之外的码位)。要处理表情符号必须指定utf8mb4。此外,若把大量含家庭表情符号的文本存入VARCHAR(255)列,UTF-8 下 1 个字符最多消耗 25 字节,会远比外观字数更早触及列大小上限。PostgreSQL 从一开始就支持 UTF-8 的全部范围,因此不会发生这一问题。 - 用正则表达式把表情符号当作 1 个字符匹配:JavaScript 的
/./只会匹配到代理对的一半。要正确匹配表情符号需使用/./u(Unicode 标志),若要匹配整个 ZWJ 序列则需要/./v(Unicode Sets 标志,ES2024)。 - 在邮件主题行中大量使用表情符号:部分邮件客户端无法正确显示表情符号,会出现乱码或空白。尤其是商务邮件,考虑到收件人的环境,克制使用表情符号更为安全。
面向开发者:准确统计表情符号的方法
- 用
Intl.Segmenter进行字素簇切分:ES2022 引入的Intl.Segmenter可以按字素簇 (用户认知中的"1 个字符"单位) 切分字符串。用[...new Intl.Segmenter().segment(str)].length即可准确取得含表情符号字符串的"外观字数"。可用环境为 Node.js 16 以上、Chrome 87 以上、Safari 15.4 以上。 - 用正则表达式检测与移除表情符号:使用 Unicode 属性转义
/\p{Emoji_Presentation}/u即可检测并移除字符串中的表情符号。不过\p{Emoji}还会包含数字 (0-9) 和 # 等,若只想针对表情符号,需要区分使用\p{Emoji_Presentation}或\p{Extended_Pictographic}。 - 测试用的表情符号集合:开发时的测试至少覆盖以下 5 个类别,即可涵盖主要的边界情况。(1) 基本表情符号 (😀)、(2) 带肤色修饰符 (👍🏻)、(3) ZWJ 序列 (👨👩👧👦)、(4) 国旗 (🇯🇵)、(5) 键帽序列 (1️⃣)。
- 数据库设计的最佳实践:保存含表情符号文本的列,应按字节数而非外观字数来设计大小。MySQL 中指定
utf8mb4,VARCHAR的长度以"预期最大字数 × 4"为参考值设定。在大量使用表情符号的聊天类应用中,也可以考虑使用TEXT类型。
总结
表情符号的字符计数远比表面看起来复杂。同一个表情符号在不同平台和编程语言中的计数可能完全不同。理解 Unicode 码点、UTF-16 代码单元和字素簇这三种计数方式的区别,是准确处理表情符号的关键。使用字符计数器可以实时确认包含表情符号的文本的准确字符数。