- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 「一个字」的错觉
- 三层结构 — 字节、码点、字形簇
- 为什么 "👨👩👧".length 会撒谎
- UTF-8 vs UTF-16 vs UTF-32
- 代理对(Surrogate Pair)— UTF-16 的原罪
- 规范化 — 看起来相同、实则不同的 é
- macOS 与 Linux 之间的 é 之战 — 韩文尤其危险
- 表情符号与 ZWJ — 如何拼出「一个字」
- 字符串反转 — 最著名的陷阱
- 实务清单
- 结语
- 参考资料
引言 — 「一个字」的错觉
刚开始学编程时,我们都被告知字符串是「字符的序列」。而且大多数时候,这个模型都相安无事。在只处理 ASCII 的英语世界里,这曾经是事实:一个字符就是 1 字节,字符串长度就等于字符数,反转字符串等同于反转数组。
然而我们要处理的文本不只是英语。一旦韩文、日文、表情符号、组合重音符号,乃至从右向左书写的文字进入画面,「一个字」这个简单模型就会崩溃。而且这种崩溃是悄无声息的——没有编译错误,大多数测试照样通过。直到某个用户输入了带表情符号的名字,或者某位韩语用户在 Linux 上搜索一个在 macOS 上创建的文件名,一切才突然全部错位。
本文要为这片雷区画一张地图:字节、码点、字形簇为什么是三个不同的层,为什么 "👨👩👧".length 会撒谎,UTF-8 与 UTF-16 的差异为什么会变成实际的生产 bug,我们逐一梳理。对于处理韩文、日文的我们来说,这绝不是别人的事。
三层结构 — 字节、码点、字形簇
首先要抓住的事实是:文本不是一个层,而是三个层。几乎所有 Unicode bug,都始于把这三层混为一谈。
- 字节(byte):储存与传输的单位,也就是真正流经文件和网络的东西。编码(如 UTF-8)负责把码点转换成字节。
- 码点(code point):Unicode 给每个字符分配的编号,写作
U+AC00(가)、U+1F600(😀)这样的形式。Unicode 本质上就是这些编号构成的一部巨大字典。 - 字形簇(grapheme cluster):人眼认作「一个字」的单位。陷阱的核心就在于,它可能由多个码点组成。
举个例子,来看这个代表家庭的表情符号。人眼看到的是一个字。但它背后有多个码点,而这些码点又会被编码成多个字节。
人眼看到的: 👨👩👧 (一个字形簇 = "一个字")
│
码点: U+1F468 U+200D U+1F469 U+200D U+1F467 (5 个)
│
UTF-8 字节: 11 字节
三层的数量各不相同:字形簇 1 个,码点 5 个,字节 11 个。然而在大多数语言里,.length 这三者中哪一个都数不准。
为什么 "👨👩👧".length 会撒谎
在 JavaScript 里执行以下代码。
"👨👩👧".length // 8
[..."👨👩👧"].length // 5
.length 返回的是 8。字形簇明明只有 1 个,码点也只有 5 个,为什么会是 8?JavaScript 字符串的 .length 数的是 UTF-16 代码单元的数量,既不是字形簇,也不是码点。这个表情符号的 5 个码点里,有 3 个(代表人物的表情符号)在 UTF-16 中各自被表示为 2 个代码单元(代理对),另外 2 个 ZWJ 各占 1 个,所以 3×2 + 2×1 = 8。
也就是说,.length 数的既不是「人眼看到的字数」,也不是「Unicode 字符数」,而是底层编码的实现细节。展开运算符 [...str] 或 for...of 是按码点遍历的,所以会返回 5。而要得到人类期待的「1」,就需要一个字形簇分割器(比如 Intl.Segmenter)。
const seg = new Intl.Segmenter("zh", { granularity: "grapheme" });
[...seg.segment("👨👩👧")].length // 1
由此得到的教训很清楚:「字符串长度」这个问题不止一个答案。你得先决定,问的到底是储存所需的字节数、码点数,还是用户实际数的字数。推特字数限制、输入框最大长度、光标移动这类 UI 逻辑里,一旦漏掉这个区分,bug 必然出现。
UTF-8 vs UTF-16 vs UTF-32
Unicode 本身只是一本「编号簿」,如何把这些编号转换成实际的字节,才是编码要做的事。我们来比较三种代表性的编码。
| 编码 | 代码单元大小 | 每字符字节数 | 特点 |
|---|---|---|---|
| UTF-8 | 8 位 | 1~4 字节(可变) | 兼容 ASCII、Web 标准、空间效率高 |
| UTF-16 | 16 位 | 2 或 4 字节 | BMP 内 2 字节,之外用代理对 |
| UTF-32 | 32 位 | 始终 4 字节 | 定长,简单但浪费空间 |
UTF-8 是当今事实上的标准。ASCII 范围(0~127)会原样编码成 1 字节,所以英文文本和 ASCII 完全一致;往上到拉丁扩展字符、韩文、表情符号,逐步增长到 2、3、4 字节。韩文的一个字在 UTF-8 里是 3 字节,日文也大多是 3 字节,所以韩语/日语文本的文件体积比英语更大。
UTF-16 是 Java(JVM)、JavaScript、Windows、C# 的内部字符串表示形式。基本多文种平面(BMP,U+0000~U+FFFF)内的字符用 2 字节表示,而平面之外(大多数表情符号)则用两个 16 位单元来表示,也就是代理对。前面看到的 .length 撒谎的根源,正是这个。
UTF-32 把每一个码点都固定表示为 4 字节。它的优点是索引可以简化成 O(1),但对大多数文本来说会大幅浪费空间,所以几乎不用于储存或传输。
代理对(Surrogate Pair)— UTF-16 的原罪
代理对值得再深入看一看。Unicode 最初以为 16 位就足以容纳所有字符(65,536 个)。但很快就不够用了,码点空间被扩展到了 U+10FFFF。已经被锁定在 16 位单元里的 UTF-16,不得不想办法表示这些扩展出来的字符。
解决办法就是代理对。U+D800~U+DFFF 这段区间被预留为一块特殊区域——「单独存在时不算字符,只有成对出现才有意义」,BMP 之外的字符就用这段区间里两个单元的组合来表示。
😀 = U+1F600
│
UTF-16: 0xD83D 0xDE00 (高代理 + 低代理)
│
"这两个合在一起,才是一个 😀"
问题在于,这两个单元可能被不小心拆开。在 JavaScript 里,如果用 str.charAt(0) 或 str[0] 去截取表情符号的第一个「字符」,得到的会是半个代理,也就是一个破损字符(通常显示为 �)。
const s = "😀";
s[0]; // '\uD83D' — 半个代理,破损字符
s.substring(0, 1); // 同样会破损
s.codePointAt(0); // 128512 — 正确的码点
这就是为什么在截取或索引字符串时,应该尊重的是码点边界,而不是代码单元边界。把用户名朴素地截断成 20 个「字符」的代码,把表情符号切成两半的事故并不少见。
规范化 — 看起来相同、实则不同的 é
现在轮到踩一颗对韩语、日语使用者尤其重要的地雷了——规范化(normalization)。
问题的起点是这样的:Unicode 里,同一个字往往存在不止一种表示方法。来看法语的 é。
- 组合形式(NFC):
U+00E9— 就是 "é" 本身的单一码点。 - 分解形式(NFD):
U+0065 U+0301— "e"(U+0065)后面接一个组合重音符号(U+0301)。
两者在屏幕上看起来完全一样,都是 é。但在字节层面,它们是完全不同的数据。于是就会发生这样的事。
const a = "é"; // NFC: 1 个码点
const b = "é"; // NFD: 2 个码点
a === b; // false !
a.length; // 1
b.length; // 2
a.normalize() === b.normalize(); // true (两者都被规范化为 NFC)
肉眼看是同一个字,=== 比较却失败了。数据库里搜索用户名时「明明存在却搜不出来」的幽灵 bug,常见的真相就是这个:一边以 NFC 储存,另一边以 NFD 储存,字符串匹配就会失败。
Unicode 定义了四种规范化形式。
| 形式 | 名称 | 方式 |
|---|---|---|
| NFC | 规范组合 | 先分解,再尽量重新合成(最常见的储存形式) |
| NFD | 规范分解 | 尽量分解 |
| NFKC | 兼容组合 | 连兼容字符一并规范化后再组合 |
| NFKD | 兼容分解 | 连兼容字符一并规范化后再分解 |
实务原则很简单:接收到的字符串,在储存之前先规范化成单一形式(通常是 NFC)。 这样一来,比较、搜索、去重才会稳定。
macOS 与 Linux 之间的 é 之战 — 韩文尤其危险
规范化之所以不是别人的事,是因为不同操作系统偏好的形式并不一样。尤其是苹果的文件系统,历史上一直倾向于把文件名储存为接近 NFD 的形式,而 Linux 和 Windows 大多使用 NFC。
这个差异在韩文上表现得尤其戏剧化。韩文「각」可以用两种方式表示。
"각" (NFC): U+AC01 (1 个码点,完整型音节)
"각" (NFD): U+1100 U+1161 U+11A8 (ㄱ+ㅏ+ㄱ,分解为 3 个字母)
在 macOS 上创建一个类似「报告_最终.hwp」的文件,压缩或提交到 git 之后,再到 Linux 服务器上按文件名去找,很可能找不到——因为字节不一样。韩国开发者大概都有过这样的经历:「在 Mac 上打包的 zip 拿到服务器上解压,韩文文件名要么乱码,要么搜不到。」真凶往往不是编码,而是规范化形式的不一致。
日文也是同样的道理——带浊点、半浊点的假名(が、ぱ 等)也可能同时存在分解形式和组合形式,落入同一个陷阱。所以在处理文件名、用户输入、搜索键的时候,最好把「统一采用哪种规范化形式」定成团队规则。
表情符号与 ZWJ — 如何拼出「一个字」
前面提到,家庭表情符号由 5 个码点组成。拼装的秘密就是 ZWJ(Zero Width Joiner,U+200D)。ZWJ 是一个肉眼看不见的「胶水」字符,它是一个信号,告诉渲染器把前后的表情符号合并成一个来渲染。
👨 (男性) + ZWJ + 👩 (女性) + ZWJ + 👧 (女孩)
= 👨👩👧 (渲染后就是一个家庭)
如果字体或平台能理解这个 ZWJ 序列,就会画出合并后的一个表情符号;如果不能理解,就会并排显示三个人。所以同一段文本,在不同设备上可能长得不一样。
再加上肤色修饰符(Fitzpatrick modifier)、国旗(两个地区指示符字符的组合)、性别与职业组合,一个字形簇膨胀到五六个码点是很常见的事。对用户来说,这一切都只是「一个表情符号」。处理字符串时如果不尊重这个字形簇,就会出现表情符号被切错、长度数错、光标只移动一半这类 bug。
字符串反转 — 最著名的陷阱
「反转字符串」是编程面试的经典题。朴素的答案是这样的。
function reverse(s) {
return s.split("").reverse().join("");
}
reverse("hello"); // "olleh" — 没问题
在 ASCII 范围内完美无缺。但一旦 Unicode 登场,它就崩了。
reverse("😀"); // "\uDE00\uD83D" — 代理对被反转、破损 → �
reverse("é"); // 重音符号从前一个字母上脱落 → "́e"
split("") 是按 UTF-16 代码单元切分的,所以会把代理对切成两半。遇到组合字符时,重音符号会粘到错误的字母上。按码点处理能解决代理对的问题,但组合字符和 ZWJ 表情符号依然会破损。要真正正确地反转,必须按字形簇来切分。
function reverseGraphemes(s, locale = "zh") {
const seg = new Intl.Segmenter(locale, { granularity: "grapheme" });
return [...seg.segment(s)].map(x => x.segment).reverse().join("");
}
这个例子给出的教训,并不是反转字符串本身有多重要,而是它揭示了「按字符处理」这个朴素假设崩溃得有多频繁、多悄无声息。截取、计数、光标移动,乃至正则匹配,同样的陷阱到处都藏着。
实务清单
把前面这片雷区压缩成实务规则,大致如下。
- 编码统一用 UTF-8。 文件、数据库、HTTP 头,甚至源代码全都用 UTF-8,编码混乱就会消失一半。
- 先定义清楚「长度」是什么。 是字节、码点,还是字形簇?UI 的字数限制应该以字形簇为准,才符合用户的直觉。
- 输入在储存前先规范化。 通常规范化为 NFC,搜索键和比较对象也要统一成同一种形式。
- 不要按代码单元切分字符串。 为了不把表情符号和组合字符切成两半,要尊重码点、理想情况下是字形簇的边界。
- 要怀疑文件名的规范化形式。 尤其是在跨 macOS 与 Linux 的流水线上,以及韩文、日文文件名中。
- 测试里要放入表情符号和组合字符。 只用 ASCII 测试,这些 bug 永远抓不到。
结语
「文本是字符的序列」——我们最初的这个直觉,只在英语与 ASCII 这个狭窄的世界里成立。真实的文本有字节、码点、字形簇三个层,而表情符号、组合字符、规范化又会让这些层彼此错位。.length 撒谎、看起来相同的 é 其实不同、字符串反转会打碎表情符号,全都源于这种错位。
对处理韩文、日文的我们来说,这一点尤其切身——我们生活在这样一个世界里:完整型与组合型之间、macOS 与 Linux 之间,同一个字会变成不同的字节。所幸原理很简单:区分三个层,统一用 UTF-8,规范化输入,尊重字形簇而不是代码单元。仅凭这四个习惯,就能安全穿越文本雷区的大部分地方。
参考资料
- The Unicode Standard: https://www.unicode.org/versions/latest/
- UTF-8 and Unicode FAQ: https://www.unicode.org/faq/utf_bom.html
- Unicode Normalization Forms (UAX #15): https://www.unicode.org/reports/tr15/
- Unicode Text Segmentation (UAX #29): https://www.unicode.org/reports/tr29/
- The Absolute Minimum Every Software Developer Must Know About Unicode (Joel Spolsky): https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/
- MDN: String.prototype.normalize(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize