Unicode:一个 Emoji 为什么 length 不是 1
TL;DR
用户眼中的一个字符,可能由多个 Unicode 码点和多个 UTF-16 代码单元组成。本文用 Emoji、组合音标和 ZWJ 序列拆解字符串长度、截断、校验和 Intl.Segmenter 的正确边界。
用户眼中的一个字符,可能由多个 Unicode 码点和多个 UTF-16 代码单元组成。本文用 Emoji、组合音标和 ZWJ 序列拆解字符串长度、截断、校验和 Intl.Segmenter 的正确边界。

在 JavaScript 里,下面这段代码经常让人第一次看到时感到困惑:
"👨👩👧👦".length
它不会返回人眼理解的“一个”。因为字符串至少有四个层次:存储字节、编码单元、Unicode 码点,以及用户感知到的字符。Unicode 标准把最后一种称为 grapheme cluster,也就是用户通常认为的一个可见字符。UAX #29 定义了如何判断这些文本边界,并明确把一个 Emoji 序列视为一个 grapheme cluster。Unicode UAX #29
四个“字符”概念不要混着用
字节:编码后的存储单位
UTF-8 会把一个 Unicode 码点编码成 1 到 4 个字节。中文、Emoji 和拉丁字母占用的字节数可能不同。网络传输和文件大小通常关心字节,而不是用户看见了几个字符。
UTF-16 编码单元:JavaScript 字符串的历史包袱
JavaScript 字符串使用 UTF-16 编码单元表达文本。基本多文种平面里的字符通常占一个 16 位编码单元,超出范围的码点会使用一对 surrogate pair。于是:
"A".length // 1
"中".length // 1
"😀".length // 2
length 返回的是编码单元数量,不是 Unicode 码点数量,更不是用户感知的字符数量。
Unicode 码点:抽象字符编号
使用展开运算符或 for...of,JavaScript 可以按码点遍历:
[..."😀"].length // 1
这比直接用 length 更接近 Unicode 层面的“一个字符”,但仍然没有到达用户感知层。带肤色修饰符、组合音标、旗帜和 ZWJ Emoji 都可能由多个码点组成。
Grapheme Cluster:用户按退格键想删掉的单位
用户看到的一个 Emoji 可能由基础 Emoji、零宽连接符和另一个 Emoji 拼成。用户按一次退格,通常期待整个图形一起消失,而不是只删掉其中一个不可见组成部分。UI 光标移动、截断、计数和删除应该尽量以 grapheme cluster 为单位。
为什么 Emoji 会变成一串码点
Unicode 不只为“一个图形一个编号”设计。为了表达组合关系,它允许多个码点组成一个显示序列:
- 基础字符加组合音标。
- 基础 Emoji 加肤色修饰符。
- 区域指示符组合成旗帜。
- 多个 Emoji 通过零宽连接符组成职业、家庭等序列。
这让文本可以持续扩展,也避免为所有视觉组合分配独立编码。但程序不能只看单个码点就判断用户看见了几个字符。
slice 为什么可能把 Emoji 切坏
const value = "你好😀世界"
value.slice(0, 4)
如果切分位置落在 surrogate pair 中间,结果可能包含孤立的代理单元;即便没有切在代理对中间,也可能把 ZWJ 序列或组合音标拆开,得到一个显示异常的字符串。
用户输入长度限制也要先定义“长度”是什么:数据库列限制可能按字节,协议字段可能按字节数,UI 文案可能按 grapheme cluster,模型输入则可能按 token。把这些限制都叫“字符数”会让边界问题迟早出现。
正规化:看起来一样,内部可能不同
带重音符号的文字可能有两种表示:一个预组合码点,或一个普通字母加组合音标。它们视觉上相同,底层序列却可能不同。Unicode Normalization 提供 NFC、NFD 等形式,帮助系统在比较、搜索和存储前选择一致的表示。
正规化也不是越早越好。文件名、密码、签名和协议字段对原始字节可能有特殊要求,不能不加判断地把所有字符串统一转换。应该根据字段语义决定:展示文本、搜索索引和用户标识通常需要明确的规范化策略;密码验证则必须遵循认证系统的约定。
JavaScript 里怎么做得更稳
现代运行时可以使用 Intl.Segmenter 按 grapheme cluster 分词:
const segmenter = new Intl.Segmenter("zh", {
granularity: "grapheme",
})
const clusters = [...segmenter.segment("👨👩👧👦á")]
console.log(clusters.length)
它比手写“遇到 Emoji 就算一个”可靠得多,因为 Unicode 的分段规则会持续演进。服务端和客户端还要统一截断策略,否则一个端显示完整、另一个端保存半个组合序列,问题会在同步时暴露。
一份实用清单
- 展示和删除:按 grapheme cluster 处理。
- 统计数据库大小:明确按字节、码点还是编码单元。
- 截断昵称:不要直接对 UTF-16 下标做
slice。 - 搜索和比较:考虑 Unicode 正规化与大小写折叠。
- 接口校验:对长度、编码和非法代理单元做明确约定。
- 测试:加入组合音标、肤色 Emoji、旗帜和 ZWJ 序列。
一句话带走
字符串不是一串“字母格子”,而是一层层编码和组合规则。length 只能回答“有多少个 UTF-16 编码单元”,不能回答用户看见了多少个字符;真正贴近用户体验的单位,是 Unicode 定义的 grapheme cluster。



