最后更新:

Emoji 表情符号的字数计算 - 看似 1 个字符实为多个字符的原理

11 分钟阅读

表情符号的历史与 Unicode 各版本的演进

世界上最早的表情符号诞生于 1999 年,由 NTT DoCoMo 的栗田穗崇为 i-mode 设计,共 176 种。它们最初是 12×12 像素的点阵图。当时日本各家运营商 (DoCoMo、au、软银) 各自独立实现表情符号,运营商之间的表情符号乱码问题时常发生。为解决这一兼容性问题,Google 与 Apple 向 Unicode 联盟提议将表情符号标准化,2010 年的 Unicode 6.0 正式收录了 722 种。

此后,表情符号随 Unicode 的每个主要版本持续增加。

Unicode 版本发布年份新增表情符号数累计表情符号数 (估计)
6.02010722722
7.02014250约 1,000
8.0201541约 1,050
9.0201672约 1,100
11.02018157约 1,600
13.02020117约 3,300
15.0202231约 3,600
16.020248约 3,790

近年来新增数量呈减少趋势。Unicode 联盟对表情符号提案会严格审查"能否用现有表情符号的组合来表达",凡是可以通过 ZWJ 序列合成实现的情况,一律不再分配新的码位。从提案到正式收录约需 2 年的流程,提案方还必须提交使用频率的预测数据,以及与其他表情符号相区别的依据。

表情符号的 Unicode 结构

表情符号在 Unicode 中的表示方式比想象的要复杂得多。最基本的表情符号 (如 😀 U+1F600) 是单个码点,但许多常见表情符号由多个码点组合而成。

表情符号外观码点数UTF-8 字节数UTF-16 代码单元
笑脸😀142 (代理对)
带肤色的挥手👋🏽284
家庭👨‍👩‍👧‍👦72511
国旗 (中国)🇨🇳284
彩虹旗🏳️‍🌈4147

各编码方式的字节大小实测数据

表情符号按 Unicode 详解 - 字符编码入门指南 中的规则进行编码,但不同编码方式下的字节大小差异很大。以下是代表性表情符号的实测数据。

表情符号外观码位数UTF-8 字节数UTF-16 字节数UTF-32 字节数
😀 (U+1F600)1 个字符1444
👍🏻 (U+1F44D U+1F3FB)1 个字符2888
👨‍👩‍👧‍👦1 个字符7252228
🇯🇵 (U+1F1EF U+1F1F5)1 个字符2888
1️⃣ (U+0031 U+FE0F U+20E3)1 个字符37612
🏳️‍🌈1 个字符4141216

在 UTF-8 中,基本多语言平面 (BMP) 之外的码位每个消耗 4 字节。UTF-16 用代理对 (2 个 16 位单元) 表示,所以同样是 4 字节。UTF-32 为定长编码,1 个码位固定 4 字节,计算简单但内存效率最差。家庭表情符号 👨‍👩‍👧‍👦 在 UTF-8 下要消耗 25 字节,这一点在设计数据库的列大小时尤其需要注意。

为什么 1 个表情符号会被计为多个字符

表情符号的字符计数取决于使用的计数方法。主要有三种计数方式:

各社交媒体平台的表情符号计数方式

平台计数方式😀 的计数👨‍👩‍👧‍👦 的计数
X (Twitter)加权字符11
Instagram字素簇11
DiscordUnicode 码点17
FacebookUTF-16 代码单元211
LINEUTF-16 代码单元211

X (Twitter) 和 Instagram 对表情符号最为友好,无论多复杂的组合表情符号都只计为 1 个字符。而 Discord 按码点计数,Facebook 和 LINE 按 UTF-16 代码单元计数,复杂表情符号会消耗大量字数。

在各平台的字数上限中,表情符号的实际处理如下。

零宽连接符 (ZWJ) 序列的原理

许多复杂表情符号使用零宽连接符 (ZWJ, U+200D) 将多个表情符号连接成一个视觉字符。家庭表情符号 👨‍👩‍👧‍👦 由"男性 (U+1F468) + ZWJ + 女性 (U+1F469) + ZWJ + 女孩 (U+1F467) + ZWJ + 男孩 (U+1F466)"共 7 个码位构成。

家庭表情符号 👨‍👩‍👧‍👦 的内部结构 - 看似 1 个字符如何变成 7 个码点 25 个字节
屏幕上的外观 👨‍👩‍👧‍👦 1
码点 👨 U+1F468 ZWJ U+200D 👩 U+1F469 ZWJ U+200D 👧 U+1F467 ZWJ U+200D 👦 U+1F466 7
UTF-16 代码单元数 👨 2 ZWJ 1 👩 2 ZWJ 1 👧 2 ZWJ 1 👦 2 11
UTF-8 字节数 👨 4 ZWJ 3 👩 4 ZWJ 3 👧 4 ZWJ 3 👦 4 25

虚线方格是不会显示在屏幕上的 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 之外,还存在其他控制表情符号显示的不可见码位。

JavaScript 的内部字符串表示采用 UTF-16,因此 BMP 之外的表情符号 (U+10000 以上) 会作为代理对用 2 个 16 位单元表示。这就是 "😀".length 返回 2 而不是 1 的根本原因。如果把代理对的高位 (U+D800 至 U+DBFF) 与低位 (U+DC00 至 U+DFFF) 拆开,就会生成非法字符串,所以在字符串截断处理中尤其需要注意。

编程中的表情符号处理

在编程中正确处理表情符号需要注意以下几点:

各编程语言字符计数的差异

即使是同一个表情符号,不同编程语言的 length 返回值也不同。这源于各语言内部字符串表示方式的差异。

语言 / 方法"😀""👍🏻""👨‍👩‍👧‍👦"计数单位
JavaScript .length2411UTF-16 代码单元
JavaScript [...str].length127Unicode 码位
Python 3 len()127Unicode 码位
Rust .len()4825UTF-8 字节数
Rust .chars().count()127Unicode 码位
Swift .count111字素簇
Go len()4825UTF-8 字节数
Java .length()2411UTF-16 代码单元

只有 Swift 按字素簇计数,所以无论哪种表情符号都如外观一样返回 1。JavaScript 与 Java 基于 UTF-16,BMP 之外的表情符号以代理对计为 2。Rust 与 Go 返回字节数,不适合用来统计表情符号的字符数。开发者必须准确掌握自己所用语言的 length 究竟返回什么。

表情符号版本与兼容性

即使是同一个表情符号,在 Apple、Google、Samsung、Microsoft 各平台上的设计也有很大差异,这一点同样需要注意。例如 🔫 (手枪) 在 Apple 改为玩具水枪的设计之后,其他公司也相继跟进,但各家改动的时间点并不一致。在营销或 UI 设计中若依赖表情符号的外观,建议事先确认它在主要平台上的显示效果。

此外,能否显示某个表情符号取决于操作系统与应用是否已支持相应的 Unicode 版本。在字体尚未更新的环境中,新增的表情符号会显示为豆腐块 (□) 等替代字形。

常见的失败模式与对策

面向开发者:准确统计表情符号的方法

  1. Intl.Segmenter 进行字素簇切分:ES2022 引入的 Intl.Segmenter 可以按字素簇 (用户认知中的"1 个字符"单位) 切分字符串。用 [...new Intl.Segmenter().segment(str)].length 即可准确取得含表情符号字符串的"外观字数"。可用环境为 Node.js 16 以上、Chrome 87 以上、Safari 15.4 以上。
  2. 用正则表达式检测与移除表情符号:使用 Unicode 属性转义 /\p{Emoji_Presentation}/u 即可检测并移除字符串中的表情符号。不过 \p{Emoji} 还会包含数字 (0-9) 和 # 等,若只想针对表情符号,需要区分使用 \p{Emoji_Presentation}\p{Extended_Pictographic}
  3. 测试用的表情符号集合:开发时的测试至少覆盖以下 5 个类别,即可涵盖主要的边界情况。(1) 基本表情符号 (😀)、(2) 带肤色修饰符 (👍🏻)、(3) ZWJ 序列 (👨‍👩‍👧‍👦)、(4) 国旗 (🇯🇵)、(5) 键帽序列 (1️⃣)。
  4. 数据库设计的最佳实践:保存含表情符号文本的列,应按字节数而非外观字数来设计大小。MySQL 中指定 utf8mb4VARCHAR 的长度以"预期最大字数 × 4"为参考值设定。在大量使用表情符号的聊天类应用中,也可以考虑使用 TEXT 类型。

总结

表情符号的字符计数远比表面看起来复杂。同一个表情符号在不同平台和编程语言中的计数可能完全不同。理解 Unicode 码点、UTF-16 代码单元和字素簇这三种计数方式的区别,是准确处理表情符号的关键。使用字符计数器可以实时确认包含表情符号的文本的准确字符数。

分享这篇文章