Emoji 是怎么存储的?Unicode 码位、组合表情与兼容性全解
在 JS 控制台敲 "😀".length,返回的是 2 不是 1;把用户昵称里的 emoji 存进 MySQL,报 Incorrect string value: '\xF0\x9F\x98\x80';截取字符串前 10 个字符,结果末尾出现一个「�」。这些问题的根都在同一个地方——emoji 不是普通字符。这篇文章从 Unicode 码位讲到 ZWJ 组合、肤色修饰符、数据库存储和平台渲染差异,把所有会踩的坑一次讲透。
码位、代理对,和那个恼人的 length
Unicode 给每个字符分配一个编号,叫码位(code point),写成 U+ 加十六进制。ASCII 的 A 是 U+0041,汉字「中」是 U+4E2D。
Unicode 把整个编码空间切成 17 个平面,每个平面 65536 个码位。第 0 平面叫基本多文种平面(BMP),范围 U+0000 到 U+FFFF,装的是绝大多数常用文字。而绝大部分 emoji 住在第 1 平面(辅助多文种平面,SMP),主要集中在 U+1F300 – U+1FAFF 这一段,远远超出了 U+FFFF。
这就带来了麻烦。JavaScript、Java、C# 内部都用 UTF-16 存字符串,一个「字符单元」是 16 位,最多表示 U+FFFF。超出的码位怎么办?用代理对(surrogate pair)——拿两个 16 位单元拼一个码位:
码位 ≥ U+10000 时:
高位代理 = 0xD800 + ((cp - 0x10000) >> 10) 范围 D800–DBFF
低位代理 = 0xDC00 + ((cp - 0x10000) & 0x3FF) 范围 DC00–DFFF
以 😀 (U+1F600) 为例:
0x1F600 - 0x10000 = 0xF600
高位 = 0xD800 + (0xF600 >> 10) = 0xD800 + 0x3D = 0xD83D
低位 = 0xDC00 + (0xF600 & 0x3FF) = 0xDC00 + 0x200 = 0xDE00
所以 "😀" 在 UTF-16 里是 D83D DE00 两个单元
这就是 "😀".length === 2 的全部原因:length 返回的是 UTF-16 单元数,不是字符数。正确的数法是用码位遍历:
"😀".length // 2 ← UTF-16 单元数
[..."😀"].length // 1 ← 展开运算符按码位迭代
Array.from("😀").length // 1
"😀".codePointAt(0) // 128512 (0x1F600)
Java 里同理:"😀".length() 是 2,"😀".codePointCount(0, 2) 才是 1。Python 3 是个例外,它内部按码位存储,len("😀") 直接就是 1。
UTF-8 的情况不同,它是变长的,按码位大小决定占几个字节:
| 码位范围 | UTF-8 字节数 | 举例 |
| U+0000 – U+007F | 1 | A |
| U+0080 – U+07FF | 2 | é |
| U+0800 – U+FFFF | 3 | 中、❤(U+2764) |
| U+10000 – U+10FFFF | 4 | 😀、🚀 |
记住这条:一个基本 emoji 在 UTF-8 下占 4 字节,是汉字的 1.33 倍。 这直接影响数据库字段长度和接口传输大小。做内容长度限制时,字符数、UTF-16 单元数、UTF-8 字节数是三个完全不同的口径,字数统计工具可以同时给出这几个数值,写表单校验前先对齐一下口径能省掉很多返工。
ZWJ 组合表情:一家四口是拼出来的
👨👩👧👦 看起来是一个表情,实际上它是四个独立 emoji 用三个连接符粘在一起的。
这个连接符叫零宽连接符(Zero Width Joiner),码位 U+200D,本身不显示任何东西,作用是告诉渲染引擎「把我两边的字符合成一个整体来画」。
👨👩👧👦 的完整序列:
U+1F468 (👨) U+200D U+1F469 (👩) U+200D U+1F467 (👧) U+200D U+1F466 (👦)
码位数: 7 个
UTF-16 单元数:4×2 + 3×1 = 11
UTF-8 字节数: 4×4 + 3×3 = 25 字节
一个看起来占两格宽的表情,实际占 25 个字节。 如果你的数据库昵称字段是 VARCHAR(20),用户放三个这种家庭表情就爆了。
同样的机制还造出了很多现代 emoji:
👨💻 程序员 = 👨 (U+1F468) + ZWJ + 💻 (U+1F4BB)
👩🚀 女宇航员 = 👩 (U+1F469) + ZWJ + 🚀 (U+1F680)
❤️🔥 燃烧的心 = ❤️ (U+2764 FE0F) + ZWJ + 🔥 (U+1F525)
🏳️🌈 彩虹旗 = 🏳️ (U+1F3F3 FE0F) + ZWJ + 🌈 (U+1F308)
ZWJ 序列的兼容性是靠「优雅降级」保证的。 如果某个平台不认识这个组合,它会把序列拆开,各画各的——你会看到 👨💻 两个并排的表情,而不是乱码。这是 Unicode 设计上很聪明的一点。
处理字符串时,ZWJ 序列是最容易出错的地方。 按码位切割也不够,因为一刀砍在 ZWJ 中间会把一家四口切成两半。正确做法是用 Intl.Segmenter 按字素簇(grapheme cluster)分割:
const seg = new Intl.Segmenter('zh', { granularity: 'grapheme' });
[...seg.segment("👨👩👧👦你好")].length // 3 ← 才是人眼看到的字符数
肤色修饰符和变体选择符
除了 ZWJ,还有两类「附加字符」经常跟在 emoji 后面。
肤色修饰符(Fitzpatrick 修饰符)占用 U+1F3FB – U+1F3FF 五个码位,对应 Fitzpatrick 皮肤分型的 1-2、3、4、5、6 型。用法是直接跟在支持肤色的 emoji 后面:
👋 U+1F44B 默认黄色
👋🏻 U+1F44B U+1F3FB 浅肤色
👋🏽 U+1F44B U+1F3FD 中等肤色
👋🏿 U+1F44B U+1F3FF 深肤色
加了修饰符后码位数变成 2,UTF-8 字节数从 4 变成 8。只有人物、手势类 emoji 支持肤色修饰符,给 🚀 加修饰符会渲染成两个独立字符。
变体选择符(Variation Selector)解决的是另一个历史遗留问题。有些符号在 emoji 出现之前就已经在 Unicode 里了,比如 ❤ (U+2764)、⚠ (U+26A0)、✂ (U+2702),它们默认按黑白文字渲染。要让它们显示成彩色 emoji,需要在后面加 U+FE0F(称为 VS16,emoji 呈现选择符):
❤ U+2764 黑白文字心形
❤️ U+2764 U+FE0F 彩色 emoji 心形
反过来,U+FE0E(VS15)强制按文字样式渲染。
这是「同一个表情在有的地方是彩色、有的地方是黑白」的直接原因。 复制粘贴时 FE0F 很容易丢失——尤其是经过某些编辑器、日志系统或数据库的 trim 处理后。想确认某个 emoji 到底带不带 FE0F、由哪些码位组成,用Unicode 编码转换把它拆成码位序列看一眼最直接。
国旗又是另一套机制。 国旗不是单独的 emoji,而是由两个区域指示符(Regional Indicator,U+1F1E6 – U+1F1FF,分别对应字母 A–Z)组成的国家代码:
🇨🇳 = U+1F1E8 (C) + U+1F1F3 (N) → ISO 国家码 CN
🇯🇵 = U+1F1EF (J) + U+1F1F5 (P) → JP
所以国旗 emoji 都是 8 字节,而且 Windows 系统默认不渲染国旗,只显示 CN JP 这样的字母对——这不是 bug,是微软的策略选择。
常用 emoji 与 Unicode 码位对照表
| Emoji | 码位 | UTF-8 字节 | UTF-16 单元 | HTML 实体(十进制) |
| 😀 | U+1F600 | 4 | 2 | 😀 |
| 😂 | U+1F602 | 4 | 2 | 😂 |
| 🙏 | U+1F64F | 4 | 2 | 🙏 |
| 👍 | U+1F44D | 4 | 2 | 👍 |
| 👋 | U+1F44B | 4 | 2 | 👋 |
| 🎉 | U+1F389 | 4 | 2 | 🎉 |
| 🔥 | U+1F525 | 4 | 2 | 🔥 |
| 🚀 | U+1F680 | 4 | 2 | 🚀 |
| 💡 | U+1F4A1 | 4 | 2 | 💡 |
| 📌 | U+1F4CC | 4 | 2 | 📌 |
| 🐶 | U+1F436 | 4 | 2 | 🐶 |
| 🍎 | U+1F34E | 4 | 2 | 🍎 |
| 🌈 | U+1F308 | 4 | 2 | 🌈 |
| ✅ | U+2705 | 3 | 1 | ✅ |
| ❤️ | U+2764 U+FE0F | 3+3 | 1+1 | ❤️ |
| ⚠️ | U+26A0 U+FE0F | 3+3 | 1+1 | ⚠️ |
| 👋🏽 | U+1F44B U+1F3FD | 4+4 | 2+2 | 👋🏽 |
| 👨💻 | U+1F468 U+200D U+1F4BB | 4+3+4 | 2+1+2 | 👨💻 |
| 🇨🇳 | U+1F1E8 U+1F1F3 | 4+4 | 2+2 | 🇨🇳 |
| 👨👩👧👦 | 1F468 200D 1F469 200D 1F467 200D 1F466 | 25 | 11 | 略 |
十六进制实体写法是 😀,CSS 里则写成 content: "\1F600"(注意没有 U+,且后面要跟空格分隔)。需要按分类快速找到某个表情并拿到它的码位,Emoji 选择器可以直接复制字符本体或码位,不用去翻 Unicode 官方表格。
平台渲染差异:为什么你发的和对方看到的不一样
Unicode 只规定码位和语义,不规定长相。 每个平台自己画一套字体:
| 平台 | 字体 | 特点 |
| iOS / macOS | Apple Color Emoji | 立体拟真,细节最多 |
| Android | Noto Color Emoji | 扁平风,Android 12 后改版较大 |
| Windows | Segoe UI Emoji | 线条风格,不渲染国旗 |
| 微信 | 自绘表情集 | 部分 emoji 被替换成微信自己的图 |
| Twitter / X | Twemoji | 开源,Web 项目常直接引用 |
差异有时候会造成真正的沟通问题。最经典的是 🙂 和 😅——同一个码位在不同字体下的「情绪浓度」差别很大。另一个是 🔫,苹果早在 2016 年就把它从真枪改成了水枪,而某些平台改得晚,导致同一条消息在两端的含义完全不同。
微信的替换机制尤其要注意。 微信会把一部分标准 emoji 换成自家表情,还有一套 [微笑] [破涕为笑] 这样的私有文本标记。这些标记不是 Unicode,复制到微信外部就是纯文本方括号。做跨平台内容同步时,这类私有表情需要单独做映射。
Web 项目想保证一致性,主流做法有两条:一是引入 Twemoji 这类 SVG 表情库,运行时把文本里的 emoji 替换成 ,代价是 DOM 变重;二是在 CSS 里指定 font-family 优先级,把 emoji 字体显式排在前面,成本低但只能保证同系统内一致。
存储与传输的坑
MySQL 的 utf8 是个假的 utf8。 MySQL 里的 utf8 字符集(正式名 utf8mb3)每个字符最多 3 字节,装不下需要 4 字节的 emoji。往这样的字段插 emoji 会直接报错:
Incorrect string value: '\xF0\x9F\x98\x80' for column 'nickname'
\xF0\x9F\x98\x80 正是 😀 的 UTF-8 编码。解决办法是全链路改成 utf8mb4:
ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
光改表还不够,连接层也要设,JDBC 的 URL 加 characterEncoding=utf8,MySQL 服务端配置里 character_set_server=utf8mb4。三处只要漏一处,插入的 emoji 就会变成 ????。
改完还有一个索引长度陷阱。 InnoDB 早期版本单列索引前缀最长 767 字节,utf8mb4 下每字符按 4 字节算,VARCHAR(255) 需要 1020 字节,直接超限报 Specified key was too long。MySQL 5.7 以后默认 DYNAMIC 行格式把上限提到 3072 字节,问题基本消失;如果还在用老版本,把索引列改成 VARCHAR(191)(191×4 = 764 < 767)是标准解法。
另一个高频坑是字符串截断。 按字节截断会把 4 字节的 emoji 从中间劈开,产生非法字节序列,前端显示成「�」,严格的 JSON 解析器还会直接抛异常。截断必须按字素簇做,退一步至少按码位做,绝不能按字节。
URL 里传 emoji 要先 percent-encoding。 😀 编码后是 %F0%9F%98%80,正好对应它的四个 UTF-8 字节。手动拼接查询参数时忘记编码,会导致部分服务端框架解析失败或者截断。有拿不准的参数,丢进URL 编码解码对照一下编码前后的结果就清楚了。
总结
emoji 的所有怪异行为都能归到几条规则上。大部分 emoji 的码位在 U+FFFF 之上,所以 UTF-8 下占 4 字节,UTF-16 下要用代理对表示成两个单元——这就是 "😀".length === 2 的原因,要数真实字符得用 Array.from 或 codePointAt。
复合表情靠三种附加字符拼装:ZWJ(U+200D)把多个 emoji 粘成一个,👨👩👧👦 一共 7 个码位 25 字节;肤色修饰符(U+1F3FB–1F3FF)跟在人物手势后面;VS16(U+FE0F)让 ❤ 这类老符号显示成彩色。要精确分割字符串,只能用 Intl.Segmenter 按字素簇处理。
存储上记死一条:MySQL 必须用 utf8mb4,且数据库、表、连接三层都要改,老版本注意 191 字符的索引长度上限。传输时 URL 要 percent-encoding,截断永远不要按字节做。
渲染差异是没法根治的,Unicode 只定义语义不定义长相,Windows 不画国旗、微信替换部分表情都属于正常行为。需要严格一致就上 Twemoji。日常开发中遇到分不清的 emoji,用Unicode 编码转换拆一下码位,八成的疑惑当场就能解开。