码点不等于字符:Unicode 规范化与不可见故障
Unicode 1.0 在 1991 年发布时承诺:16 位编码空间、65,536 个字符、“足够覆盖当前所有在用的文字”。1996 年这个承诺被推翻,整个生态用了三十年才把后果消化掉。你写代码遇到过的几乎所有 Unicode 怪事,根源都是那一个被推翻的假设。
那个被推翻的承诺
最初的 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+xxxx。U+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+0000到U+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+D800–U+DFFF 这 2,048 个码位预留作为"surrogate"——这些 code point 只能成对出现。一个高位 surrogate(U+D800–U+DBFF)后面跟一个低位 surrogate(U+DC00–U+DFFF),一对合起来编码一个补充平面里的 code point。
这能用,但留下了几道伤疤:
- Surrogate code point 本身不是合法 Unicode 字符。"lone surrogate"(孤立 surrogate)——一对里只有一半——在 well-formed Unicode 里是非法的,但在 UTF-16 里技术上可表示。很多字符串库默默接受,多数网络协议拒绝。JSON 在
\uXXXXescape 里允许 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 要装 regex 或 grapheme 库。如果你的文本截断逻辑按 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 工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。