开发者的表情符号指南
大多数表情相关的 bug 都源于同一个错位:代码数的东西和读者看到的不一样。一个家庭表情看起来是一张图,却有 7 个码点、11 个 JavaScript 字符和 25 个字节。我们用常见的正则、分段器和长度检查跑遍了 Unicode 官方列表中的全部 3,963 个表情,并在本页公布它们实际匹配到的结果,让你在 bug 报告告诉你选了哪种方法之前,先选对适合场景的那一种。
同一个表情,每种语言长度都不同
每个单元格是该表达式对列首表情的返回值。结果分为四类:UTF-16 单元、码点、UTF-8 字节,以及字素(人所数的单位)。字素一列由本服务器上的 Intl.Segmenter 计算,因此 Unicode 数据较旧的运行时,对最新表情可能返回更大的值。
| 表达式 | 计数单位 | 😀 | ❤️ | 👍🏽 | 🇯🇵 | 👩💻 | 👨👩👧👦 | 🧑🏻❤️💋🧑🏼 |
|---|---|---|---|---|---|---|---|---|
JavaScripts.length | UTF-16 单元 | 2 | 2 | 4 | 4 | 5 | 11 | 15 |
JavaScript[...s].length | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
JavaScriptIntl.Segmenter | 字素 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
Pythonlen(s) | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
Pythonlen(s.encode('utf-8')) | UTF-8 字节 | 4 | 6 | 8 | 8 | 11 | 25 | 35 |
Javas.length() | UTF-16 单元 | 2 | 2 | 4 | 4 | 5 | 11 | 15 |
Javas.codePointCount(0, s.length()) | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
C#s.Length | UTF-16 单元 | 2 | 2 | 4 | 4 | 5 | 11 | 15 |
C# (.NET 5+)new StringInfo(s).LengthInTextElements | 字素 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
Golen(s) | UTF-8 字节 | 4 | 6 | 8 | 8 | 11 | 25 | 35 |
Goutf8.RuneCountInString(s) | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
Rusts.len() | UTF-8 字节 | 4 | 6 | 8 | 8 | 11 | 25 | 35 |
Rusts.chars().count() | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
Swifts.count | 字素 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
Swifts.unicodeScalars.count | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
Rubys.length | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
Rubys.grapheme_clusters.length | 字素 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
PHPstrlen($s) | UTF-8 字节 | 4 | 6 | 8 | 8 | 11 | 25 | 35 |
PHPmb_strlen($s) | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
PHPgrapheme_strlen($s) | 字素 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
MySQLCHAR_LENGTH(s) | 码点 | 1 | 2 | 2 | 2 | 3 | 7 | 10 |
MySQLLENGTH(s) | UTF-8 字节 | 4 | 6 | 8 | 8 | 11 | 25 | 35 |
SQL ServerLEN(s) on nvarchar | UTF-16 单元 | 2 | 2 | 4 | 4 | 5 | 11 | 15 |
简单粗暴的截断会把表情变成什么
下面是对家庭表情 👨👩👧👦 按每个 n 执行 s.slice(0, n) 的结果。大多数截断并不会明显出错,只是返回一个不同的、更小的家庭。这类 bug 就是这样悄无声息地上线的。
| 在 n 处截断 | 剩下的内容 | 剩余码点 |
|---|---|---|
| s.slice(0, 1) | �半个代理对(显示为 �) | 0 |
| s.slice(0, 2) | 👨 | 1 |
| s.slice(0, 3) | 👨 | 2 |
| s.slice(0, 4) | 👨�半个代理对(显示为 �) | 2 |
| s.slice(0, 5) | 👨👩 | 3 |
| s.slice(0, 6) | 👨👩 | 4 |
| s.slice(0, 7) | 👨👩�半个代理对(显示为 �) | 4 |
| s.slice(0, 8) | 👨👩👧 | 5 |
| s.slice(0, 9) | 👨👩👧 | 6 |
| s.slice(0, 10) | 👨👩👧�半个代理对(显示为 �) | 6 |
| s.slice(0, 11) | 👨👩👧👦 | 7 |
会换国家的国旗
区域指示符字母从字符串开头起两两配对。从 🇺🇸🇯🇵 开头去掉一个码点,剩下的字母就会换一种方式配对:🇸🇯🇵。没有报错,只是变成了另一个国家。
🇺🇸🇯🇵 US + JP → 🇸🇯 SJ (Svalbard & Jan Mayen) + 🇵
哪种表情正则能匹配什么
于 2026-10-20 在 Node 25.2.1(内置 Unicode 17.0 数据)上,针对全部 3,963 个 fully-qualified 表情测试。只有当模式匹配整个表情时才算匹配成功。
| 模式 | 匹配数 | 漏掉的内容 |
|---|---|---|
| /^\p{RGI_Emoji}$/v | 3,944 / 3,963 | 所有比引擎 Unicode 数据更新的表情:Emoji 18.0 (19) |
| Intl.Segmenter | 3,963 / 3,963 | 在此运行时上无遗漏 |
| /\p{Extended_Pictographic}/u | 3,692 / 3,963 | 259 个国旗和 12 个键帽,它们完全不含象形码点 |
| /^\p{Extended_Pictographic}$/u | 1,193 / 3,963 | 所有由多个码点组成的表情:肤色、国旗、键帽、ZWJ 序列,以及带样式选择符的表情 |
还有一个要避开的:\p{Emoji} 也会匹配 # * 0 1 2 3 4 5 6 7 8 9 © ®。用它来删除表情,会把文本里的所有数字一并删掉。
删除表情:每种方法会留下什么
下面两行原本是同一段文本。残留字符在大多数字体中不可见,正因如此,它们会在之后的搜索、去重和长度限制中引发问题。
Great 👍🏽 job ❤️ 👩💻 🇯🇵 #️⃣1
| 方法 | 结果 | 残留码点 |
|---|---|---|
| 删除 \p{Extended_Pictographic} | Great 🏽 job ️ 🇯🇵 #️⃣1 | U+1F3FD U+FE0F U+200D U+1F1EF U+1F1F5 U+FE0F U+20E3 |
| 删除 \p{RGI_Emoji} | Great job 1 | 无 |
表情在数据库中占多少空间
全部 3,963 个表情每个所占的 UTF-8 字节数。30% 的表情不超过四个字节;最长的需要 35 字节。如果某列按字节而不是按字符限制长度,请根据这张表来设定大小,而不是看文本的外观。
| UTF-8 字节 | 可容纳的表情 | 占比 |
|---|---|---|
| ≤ 4 | 1,193 | 30% |
| ≤ 8 | 2,346 | 59% |
| ≤ 11 | 2,424 | 61% |
| ≤ 17 | 3,424 | 86% |
| ≤ 28 | 3,868 | 98% |
| ≤ 35 | 3,963 | 100% |
🧑🏻❤️💋🧑🏼 U+1F9D1 U+1F3FB U+200D U+2764 U+FE0F U+200D U+1F48B U+200D U+1F9D1 U+1F3FC
选择与你所执行限制相匹配的计数方式
不存在唯一正确的长度,只有与你所执行规则相符的那一种。按字节限制的数据库列需要 UTF-8 字节。向用户承诺 50 个字符的文本框需要字素,否则输入三面国旗的用户会被告知用掉了 12 个字符。SQL Server 是个例外:nvarchar(n) 按 UTF-16 单元计数,所以 😀 在 nvarchar 中占两个字符,而在按码点计数的 MySQL 或 PostgreSQL varchar 中只占一个。先确定你指的是四种中的哪一种,把它写进变量名,其余的就顺理成章了。
按字素边界截断,而不是按索引
任何按索引的截断都可能落在表情中间。对 UTF-16 来说,最坏的情况是半个代理对,大多数渲染器会把它显示成带问号的方框,有些 JSON 编码器则会直接拒绝。按码点截断时表面上什么都没坏,但这更糟:家庭少了一个孩子,肤色脱落成一个棕色方块,或者国旗变成两个字母。解决办法是先分段,再截取整数个字素。自 Firefox 125 于 2024 年 4 月支持以来,Intl.Segmenter 已在所有主流浏览器中可用,在 Node 中可用得更早,手写拆分器的老借口已经不成立了。
可直接复制的代码片段
简短,并在语言允许时不依赖任何第三方库。每段代码解决上面表格中的一种情况。
统计读者看到的数量(JavaScript)
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
const visibleLength = (s) => [...seg.segment(s)].length;
visibleLength('👨👩👧👦 ok'); // 4, while '👨👩👧👦 ok'.length is 14截断时不拆开表情(JavaScript)
function truncate(s, max, ellipsis = '…') {
const parts = [...seg.segment(s)].map((x) => x.segment);
return parts.length <= max ? s : parts.slice(0, max - 1).join('') + ellipsis;
}找出所有表情,包括国旗和键帽(JavaScript)
// ES2024 'v' flag: Chrome 112, Safari 17, Firefox 116, Node 20
const emoji = /\p{RGI_Emoji}/gv;
'Go 🇯🇵 #️⃣1 👍🏽'.match(emoji); // ['🇯🇵', '#️⃣', '👍🏽']Python 中的同样做法,使用 regex 模块
import regex # pip install regex; the built-in re has no \X
graphemes = regex.findall(r'\X', '👨👩👧👦 ok')
len(graphemes) # 4, while len('👨👩👧👦 ok') is 10能接受所有表情的列
-- MySQL: utf8mb4 on the column AND the connection
CREATE TABLE posts (body VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci);
SET NAMES utf8mb4;
-- PostgreSQL: any UTF8 database already stores every emoji
-- SQL Server: use nvarchar, and remember LEN() counts UTF-16 units为什么没有一劳永逸的正则
正则引擎中的每个表情属性都来自编译进该引擎的 Unicode 数据,所以模式的新旧程度取决于运行它的运行时。我们自己的测试就说明了这一点:\p{RGI_Emoji} 在 3,963 个中只匹配了 3,944 个,因为最新的 19 个序列还不在 Node 25.2.1 的 Unicode 17.0 表中。同样的代码在升级运行时后就能匹配它们,而使用旧浏览器的用户则会继续漏掉。如果某个校验必须在新表情发布当天就接受它们,请按结构(Extended_Pictographic 加上修饰符、连接符和选择符)或按字素匹配,只在漏判无害的地方使用该属性。
同一个表情可能有三种存储形式
Unicode 列出了 3,963 个 fully-qualified 表情,另有同一图案的 1,029 个 minimally-qualified 形式和 243 个 unqualified 形式,大多是不带样式选择符 U+FE0F 的版本。❤ 和 ❤️ 在很多应用中看起来一样,却是不同的字符串,因此唯一性检查、反应计数或搜索索引会把它们当成两样东西。Unicode 规范化也帮不上忙:NFC 和 NFKC 都不会处理变体选择符。如果要比较或去重表情,请确定一种形式(fully-qualified 最稳妥),并在输入时依据 qualified 列表进行转换,而不是删除所有 U+FE0F,那样还会破坏 1️⃣ 这样的键帽。
表情 Unicode 转换器全部 ZWJ 序列及其大小能正确统计表情的字数统计工具表情符号术语表
开发者关于表情的常见问题
为什么在 JavaScript 中一个表情的长度是 2?
JavaScript 字符串使用 UTF-16,任何高于 U+FFFF 的字符都要占用两个 16 位单元,即一个代理对。几乎所有表情都位于 U+FFFF 以上,所以即使是简单的 😀,.length 也是 2。要数码点请用 [...str].length,要数读者看到的数量请用 Intl.Segmenter。
匹配表情的最佳正则是什么?
在现代 JavaScript 中,/\p{RGI_Emoji}/v 可以匹配完整的表情序列,包括国旗和键帽,但仅限于引擎所知道的 Unicode 版本。\p{Extended_Pictographic} 会漏掉国旗和键帽,而 \p{Emoji} 会误匹配数字、# 和 *。
怎样把一个表情算作一个字符?
统计字素簇。在 JavaScript 中使用 granularity 为 grapheme 的 Intl.Segmenter;在 Swift 中,String.count 本身就是这样计数的;在 Python 中使用第三方 regex 模块的 \X;在 PHP 中使用 intl 扩展的 grapheme_strlen。
存储一个表情需要多少字节?
在 UTF-8 中需要 3 到 35 个字节。高于 U+FFFF 的单码点表情占 4 个字节。肤色、国旗和连接序列会累加:在我们的测试中,59% 的表情可以放进 8 个字节,而最长的那个,带两种肤色的亲吻表情,占 35 个字节。
为什么我的表情在数据库里变成了 ?????
列或连接使用的字符集无法存储四字节字符,通常是 MySQL 旧版的 utf8。请把表和连接都切换为 utf8mb4。我们的 Unicode 转换器页面会逐步讲解具体的 ALTER TABLE 语句。
