Unicode:一个 Emoji 为什么 length 不是 1

TL;DR

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

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

Unicode:一个 Emoji 为什么 length 不是 1

在 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。

延伸阅读

KEEP READING