📝 博客 · 2026-08-05 · ⏱ 18 分钟

Emoji 是怎么存储的?Unicode 码位、组合表情与兼容性全解

从 UTF-16 代理对讲清楚 JS 里 \"😀\".length 为什么等于 2,ZWJ 零宽连接符如何把 4 个 emoji 拼成一家四口,肤色修饰符与 VS16 变体选择符的作用,MySQL utf8mb4 踩坑与索引长度限制,附 18 个常用 emoji 的 Unicode 码位对照表。

Unicode编码前端开发

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+007F1A
U+0080 – U+07FF2é
U+0800 – U+FFFF3(U+2764)
U+10000 – U+10FFFF4😀🚀

记住这条:一个基本 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+1F60042😀
😂U+1F60242😂
🙏U+1F64F42🙏
👍U+1F44D42👍
👋U+1F44B42👋
🎉U+1F38942🎉
🔥U+1F52542🔥
🚀U+1F68042🚀
💡U+1F4A142💡
📌U+1F4CC42📌
🐶U+1F43642🐶
🍎U+1F34E42🍎
🌈U+1F30842🌈
U+270531
❤️U+2764 U+FE0F3+31+1❤️
⚠️U+26A0 U+FE0F3+31+1⚠️
👋🏽U+1F44B U+1F3FD4+42+2👋🏽
👨‍💻U+1F468 U+200D U+1F4BB4+3+42+1+2👨‍💻
🇨🇳U+1F1E8 U+1F1F34+42+2🇨🇳
👨‍👩‍👧‍👦1F468 200D 1F469 200D 1F467 200D 1F4662511

十六进制实体写法是 😀,CSS 里则写成 content: "\1F600"(注意没有 U+,且后面要跟空格分隔)。需要按分类快速找到某个表情并拿到它的码位,Emoji 选择器可以直接复制字符本体或码位,不用去翻 Unicode 官方表格。

平台渲染差异:为什么你发的和对方看到的不一样

Unicode 只规定码位和语义,不规定长相。 每个平台自己画一套字体:

平台字体特点
iOS / macOSApple Color Emoji立体拟真,细节最多
AndroidNoto Color Emoji扁平风,Android 12 后改版较大
WindowsSegoe UI Emoji线条风格,不渲染国旗
微信自绘表情集部分 emoji 被替换成微信自己的图
Twitter / XTwemoji开源,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.fromcodePointAt

复合表情靠三种附加字符拼装: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 编码转换拆一下码位,八成的疑惑当场就能解开。