← 返回博客
语言: English 中文
Encoding 2026-06-07 10 分钟

码点不等于字符:Unicode 规范化与不可见故障

Unicode 1.0 在 1991 年发布时承诺:16 位编码空间、65,536 个字符、“足够覆盖当前所有在用的文字”。1996 年这个承诺被推翻,整个生态用了三十年才把后果消化掉。你写代码遇到过的几乎所有 Unicode 怪事,根源都是那一个被推翻的假设。

UnicodeUTF-8UTF-16Code PointNormalizationGrapheme Cluster

那个被推翻的承诺

最初的 Unicode 规范给每个字符一个固定的 16-bit 编码。Java 是按这个假设设计的、Windows NT 是、JavaScript 是。整个 90 年代的"Unicode-aware"语言和操作系统都把"char = 16 bit"烤进了基础假设。

1996 年的 Unicode 2.0 承认 65,536 不够用。修法是把编码空间扩展到大约 110 万 个 code point、组织成 17 个"plane"(平面),并在 16-bit 系统上拼接出一个向后兼容方案叫 UTF-16,让新增的大编号 code point 可以用一对 16-bit 单元表达。后面会讲。先记住一句:你遇到过的每个 UTF-16 怪事,都是这一个决定的代价。

几个被搞混的术语

下面这些词在大家平时说话里随便用,最后写代码就出 bug:

  • code point:Unicode 编码空间里的一个数字,写法 U+xxxxU+0041 是 "A",U+1F600 是 "😀"。é 这个字符的 code point 是 U+00E9。code point 是抽象的——它本身没有字节表达,必须挑一种"编码形式"才能落到字节上。
  • 编码形式:把 code point 变字节的规则。Unicode 定义了三种:UTF-8(每个 code point 1–4 字节)、UTF-16(2 或 4 字节)、UTF-32(永远 4 字节)。
  • plane(平面):65,536 个 code point 的一段。Plane 0 是 基本多文种平面(BMP)——U+0000U+FFFF,原始 16-bit 时代的全部疆域。Plane 1–16 是"补充平面",1996 年之后所有的 emoji、所有 CJK 扩展、所有古文字都在那里。
  • grapheme cluster(字素簇):人眼读起来的"一个字符"。常常是一个 code point,但不一定。é 写成 e + 组合急音符U+0065 U+0301)就是两个 code point 的一个字素簇

把"字符"说成"code point"或"字节",是这领域至少一半 bug 的源头。

为什么 UTF-8 在 web 上赢了

UTF-8(Ken Thompson 和 Rob Pike,1992 年)的几个性质像是从愿望清单里抠出来的:

  • ASCII 兼容:每个 ASCII 字节本身就是合法 UTF-8。一个纯 ASCII 文件天然也是合法的 UTF-8 文件,无需转换。这一条让 UTF-8 直接继承了三十年的 UNIX 工具链。
  • 自同步:从 UTF-8 流的中间任意位置开始读,向前最多走 3 字节就能找到下一个 code point 边界。UTF-16 理论上也能做到,UTF-32 不需要。
  • 没有字节序歧义:UTF-8 字节序列在大端、小端机器上读出来一样。UTF-16 和 UTF-32 不行,所以它们才需要 BOM。
  • 拉丁文紧凑:英文在 UTF-8 里是 1 字节/字符,UTF-16 里 2 字节,UTF-32 里 4 字节。CJK 重的文件刚好相反:UTF-8 里多数中日韩字符 3 字节,UTF-16 里 2 字节。

UTF-8 不是万能。CJK-heavy 文件比 UTF-16 大;按 code point index 随机访问是 O(n),因为 code point 边界宽度可变。但对于 web 的主导场景——拉丁文倾向、走网络的文本——UTF-8 决定性地赢了。Web、JSON、几乎所有现代协议默认都是 UTF-8。

Surrogate pair:1996 年的补丁

UTF-16 要用 16-bit 单元编码 110 万个 code point,怎么办?方案是把 U+D800U+DFFF 这 2,048 个码位预留作为"surrogate"——这些 code point 只能成对出现。一个高位 surrogate(U+D800U+DBFF)后面跟一个低位 surrogate(U+DC00U+DFFF),一对合起来编码一个补充平面里的 code point。

这能用,但留下了几道伤疤:

  • Surrogate code point 本身不是合法 Unicode 字符。"lone surrogate"(孤立 surrogate)——一对里只有一半——在 well-formed Unicode 里是非法的,但在 UTF-16 里技术上可表示。很多字符串库默默接受,多数网络协议拒绝。JSON 在 \uXXXX escape 里允许 lone surrogate,这就是跨系统 Unicode 往返 bug 的一半来源。
  • JavaScript 和 Java 字符串是 UTF-16 code unit 序列,不是 code point 序列。"😀".length 在 JS 里返回 2,因为这个 emoji 是一对 surrogate。"😀".charAt(0) 返回 emoji 的一半。
  • UTF-8 和 UTF-32 没有 surrogate 概念。它们只是 UTF-16 的"伤疤"。如果你把一个含有 lone surrogate 的 JS 字符串序列化成 UTF-8,标准说应当报错或者替换成 U+FFFD;实际很多工具会产出"非法 UTF-8"。

BOM 那点破事

Byte Order Mark 就是写在文件开头的 code point U+FEFF,用于声明字节序。UTF-16 需要、UTF-32 需要、UTF-8 不需要——单字节单元没有字节序可言。

Microsoft 当年还是给 UTF-8 文件开头放了一个 BOM,最初是为了让记事本能区分 UTF-8 和本地代码页。Unicode 标准容忍这种行为但不推荐。所有 Unix 工具、所有 web 标准、绝大多数现代编辑器都不预期 UTF-8 BOM(那 3 个字节 EF BB BF),会把它当成文件正文。这就是 Excel 导出的 CSV 第一列表头开头多一个不可见字符的原因。

写工具:不要发 UTF-8 BOM。读工具:要防御性地剥掉。

规范化(normalization)

同一个人眼字符常常有多种写法。

  • 一个 code point 写法:U+00E9(带急音符的小写 e)——"预组合"形式
  • 两个 code point 写法:U+0065 + U+0301(小写 e + 组合急音符)——"分解"形式

两者正则等价,意思是它们应当显示相同、在任何理智的字符串比较里也应当相等。但它们字节不相等。原始字符串比较会判它们不同——这就是为什么用户在 Mac 上输入的用户名(macOS 文件系统倾向分解形式)和 Windows 上输入的(Windows 倾向预组合)有时候对不上。

修法是 规范化,在比较之前把字符串改写到某种规范形式。四种形式:

  • NFC:规范组合(Canonical Composition)。能合并就合并。最常见的选择。
  • NFD:规范分解(Canonical Decomposition)。拆成基字符 + 组合字符。
  • NFKC:兼容组合。NFC 加上更激进的替换(全角数字变半角、连写字母拆开)。
  • NFKD:兼容分解。前面三个加在一起。

存储和比较默认 NFC,没特殊理由别动。NFKC 适合搜索("ffi" 应该匹配 "ffi"),但损失了不可恢复的信息。NFD 和 NFKD 通常是算法中间形式,不是你存到库里的东西。

字素簇与"length 在说谎"

"hello".length 在每种语言里都是 5。合理。

"é".length 如果是预组合形式,JS 里是 1;如果是分解形式 e + ◌́,JS 里就是 2。

"😀".length 在 JS 里是 2(UTF-16 surrogate pair),在 Python 里是 1(按 code point 计),在 Go 里是 4(按字节计)。三个都"对"——只是"length"在三种语言里的定义不同。

"👨‍👩‍👧‍👦".length 在 JS 里是 11、Python 里是 7。这个全家福 emoji 是 4 个人物 emoji 用 3 个零宽连接符(ZWJ,U+200D)粘起来。每个人在 UTF-16 里都是一对 surrogate。但用户看到的是"一家"。

用户语境下的"length"几乎总是指字素簇个数,而这是大多数语言默认不暴露的。JavaScript 要 Intl.Segmenter(ES2022)。Swift 原生有 String.count。Python 要装 regexgrapheme 库。如果你的文本截断逻辑按 code unit 或 code point 切,迟早会切到一个 emoji 的中间,渲染出豆腐块

ZWJ 和修饰符序列

现代 emoji 不是单 code point。它们是序列:

  • 肤色:👋🏼U+1F44B(挥手)+ U+1F3FC(中浅肤色修饰符)。
  • ZWJ 序列:👨‍🍳U+1F468(男人)+ U+200D(ZWJ)+ U+1F373(烹饪)。
  • 全家福:👨‍👩‍👦 是三个人物 emoji 用 ZWJ 粘起来。
  • 国旗:🇯🇵 是两个区域指示符 U+1F1EF + U+1F1F5(J + P)。

渲染器应该把这些显示成单一字形——前提是它有正确的字体。没有的话,就显示成组件——这就是为什么在老一些字体的系统上,国旗显示成两个带颜色框的字母。

实用规则

  • 存储和传输:UTF-8,不要 BOM。
  • 比较前:先 NFC 规范化。永远要做。哪怕数据看起来同源。
  • 不要相信 string.length。需要"用户视角的字符数",用 grapheme segmenter。
  • 用户面向的代码里不要按整数下标切字符串。迟早切到一对 surrogate 的中间、一个组合序列的中间、或者一个 ZWJ 序列的中间。
  • 把 lone surrogate 当作数据损坏处理,除非有明文档化的理由保留它。
  • 一个字符串在 UTF-8 系统上能往返、在 UTF-16 系统上挂掉,多半是规范化或 surrogate 处理问题。

主要参考资料

用于核对本文技术细节的标准与官方文档。

查看任意字符的细节

本站 Unicode 工具会展示任意字符串的 code point、UTF-8 / UTF-16 字节序列、以及 grapheme cluster 边界。在排查“为什么这个 emoji 长度是 7”那种瞬间特别有用。

打开 Unicode 工具

相关文章

继续阅读同一主题领域的实践指南。

查看全部文章

Cookie 同意

我们使用 Cookie 来增强您的体验并展示相关广告。您可以自定义您的偏好。