在Stack Overflow上,一个反复出现的问题困扰着无数开发者:为什么JavaScript中"🤦🏼♂️".length的结果是7,而不是1?
这不是一个孤立的案例。在浏览器地址栏中,“аррӏе.com"看起来像是"apple.com”,但前者使用的西里尔字母让用户在毫不知情中访问了钓鱼网站。在GitHub代码审查中,看似无害的注释里隐藏着控制字符,让编译器执行完全不同的逻辑。在终端模拟器中,粘贴一个表情符号后光标移动的距离,取决于你使用的是哪个终端——有的移动2格,有的移动4格,有的甚至移动6格。
这些问题的根源都指向同一个地方:Unicode——这个统一了全球文字编码的标准。三十五年来,Unicode从最初的设想发展为涵盖154,998个编码字符的庞大体系,但"统一"的背后,是一个比任何人想象中都更加复杂的世界。
一个名字的诞生:从1988到1991
1987年秋天,施乐公司的Joe Becker和苹果公司的Mark Davis开始讨论一个困扰软件行业多年的问题:如何让计算机同时处理多种语言。
那个年代的文本处理是一场噩梦。ASCII只能表示128个字符,足以应付英语,但对其他语言束手无策。各国开发了自己的字符编码标准——中国大陆的GB2312、日本的Shift-JIS、韩国的KS C 5601——但这些编码互不兼容。一个文本文件在不同系统间传递,很可能变成一堆乱码。更糟糕的是,像Shift-JIS这样的编码是"混合宽度"的:某些字符占一个字节,某些占两个字节,而且某些字节值既可能代表一个完整字符,也可能是多字节字符的一部分。
1988年2月,Becker完成了题为《Unicode 88》的论文,正式提出"Unicode"这个名字——意为"unique, universal, and uniform character encoding"(唯一的、通用的、统一的字符编码)。他的核心设想极其大胆:为世界上所有字符分配一个唯一的16位数字。
16位意味着65,536个码点。Becker和他的合作者们相信,这个空间足够容纳所有现代语言中常用的字符。他们的调研显示,汉字虽然总数超过六万,但现代常用汉字的数量远小于这个数字。通过"汉字统一"(Han Unification)——将中日韩三国使用的相同汉字映射到同一个码点——Unicode团队相信16位空间绰绰有余。
1991年10月,Unicode 1.0正式发布。这本厚重的标准书收录了7,161个字符,标志着人类历史上第一个真正通用的字符编码标准的诞生。
然而,就在标准发布的那一刻,一些根本性的问题已经埋下了种子。
汉字统一:第一个争议
Unicode最雄心勃勃的决定是"汉字统一":将中日韩三国使用的相同汉字映射到同一个码点。例如,中国的"字"、日本的"字"、韩国的"字",虽然在笔画细节上可能存在细微差异,但在Unicode中共享同一个码点U+5B57。
这个决定有着充分的理由。如果不统一,中日韩三国的汉字各自编码,将占用数万个码点,彻底挤爆16位空间。而且,这三个国家使用的汉字大多源自同一历史源头,在语义上高度一致。
但这个决定也引发了持续至今的争议。对许多东亚用户来说,“相同"并不等于"相同”。日本汉字和简体中文的笔画形态存在系统性的差异——横画的角度、点的形状、部件的比例。当字体渲染引擎选择以某种风格显示时,另一种文字的用户可能感到违和甚至难以辨认。
更微妙的是文化身份问题。某些汉字在中日韩三国有不同的"国字"变体——比如日本的"峠"(tōge,山口)和"凪"(nagi,风平浪静),这些字在中国和韩国根本不存在。Unicode后来不得不为这些特殊字单独分配码点,但最初的"统一"理念已经让一些用户感到不满。
尽管如此,Unicode的整体架构是成功的。到1996年Unicode 2.0发布时,标准已经收录了38,885个字符,涵盖了世界上几乎所有现代书写系统。
但就在同一年,一个看似技术性的决定埋下了更大的隐患:UTF-16与代理对(Surrogate Pairs)的引入。
码点爆炸:从65,536到1,114,112
1996年,Unicode联盟做出了一个改变一切的决定:将码点空间从16位扩展到21位。
原因很简单:16位不够用了。古文字、罕见汉字、各种符号系统的需求源源不断地涌入。Unicode标准需要一种方法来表示超出65,536范围的字符。
解决方案是UTF-16中的"代理对"机制。在基本多语言平面(Basic Multilingual Plane,BMP,即Plane 0)中,U+D800到U+DFFF这2,048个码点被保留下来,不分配任何字符。当一个字符的码点超过U+FFFF时,就使用一个"高代理"和一个"低代理"的组合来表示。
这个机制听起来优雅,但它制造了一个让无数开发者踩坑的陷阱。
JavaScript的UTF-16遗留问题
JavaScript在1995年设计时,Unicode还只有16位。因此,JavaScript的字符串内部使用UTF-16编码,每个"字符"被定义为一个16位的编码单元(code unit)。
当代码点超过U+FFFF时,问题出现了。例如,字符"𝌆"(U+1D306,用于古代符号)在UTF-16中被编码为两个16位单元:0xD834 0xDF06。在JavaScript中,"𝌆".length返回2,而不是1。
表情符号更是灾难性的。"👨🌾"(农民表情)由三个码点组成:U+1F468(男人)、U+200D(零宽连接符)、U+1F33E(稻穗)。在UTF-16中,这需要7个编码单元。因此,"👨🌾".length返回7。
这不是JavaScript的bug,而是UTF-16设计的一个必然结果。但它揭示了一个更深层的问题:“字符"这个概念本身,在Unicode中变得模糊不清。
字素群:一个字符到底是什么?
Unicode标准定义了四个层级的概念:
- 码点(Code Point):Unicode编码空间中的一个数值,范围从U+0000到U+10FFFF。
- 编码单元(Code Unit):特定编码格式中用于表示码点的基本单位。UTF-8中是8位,UTF-16中是16位,UTF-32中是32位。
- 码点序列(Code Point Sequence):一个或多个码点组成的序列。
- 字素群(Grapheme Cluster):用户感知为单个"字符"的文本单元。
问题在于,这四个层级之间没有简单的对应关系。
一个字素群可能对应一个码点(如"a”,U+0061),也可能对应多个码点。“é"可以表示为单个码点U+00E9,也可以表示为两个码点:U+0065(e)加上U+0301(组合重音符)。这两种表示在视觉上完全相同,但在数据层面却不同。
更复杂的是表情符号。"👨🌾“是一个字素群,但由7个编码单元、4个码点组成。更极端的例子是家庭表情”👨👩👧👦":7个码点、11个UTF-16编码单元,但用户只看到一个"字符”。
这就是为什么"🤦🏼♂️".length返回7。JavaScript计算的是UTF-16编码单元的数量,而不是字素群的数量。大多数编程语言都有同样的问题:Python 3中len("🤦🏼♂️")返回4,Go中utf8.RuneCountInString("🤦🏼♂️")也返回4——它们计算的是码点数量,而不是字素群数量。
归一化:四选一的困境
为了解决"同一字符多种表示"的问题,Unicode定义了四种归一化形式:
| 形式 | 名称 | 描述 |
|---|---|---|
| NFC | 规范化合成 | 先分解再合成,优先使用预组合字符 |
| NFD | 规范化分解 | 将所有可能的字符分解 |
| NFKC | 兼容性合成 | 类似NFC,但会替换兼容性字符 |
| NFKD | 兼容性分解 | 类似NFD,但会替换兼容性字符 |
NFC是最常用的形式。在NFC下,“é"的两种表示会被统一为单码点形式U+00E9。大多数网页内容、文件系统都默认使用NFC。
但NFC并不能解决所有问题。某些语言(如越南语)中的字符在NFC下仍然可能存在多种表示。更危险的是NFKC和NFKD:它们会进行"兼容性替换”,可能改变文本的语义。
例如,字符"①"(带圈数字1)在NFKC/NFKD下会被替换为普通数字"1"。这在某些场景下是有用的(如搜索),但在安全场景下却可能被利用。攻击者可以注册一个包含"①"的用户名,当系统进行NFKC归一化后,就变成了另一个已存在的用户名。
2017年,安全研究人员演示了一类攻击:通过在用户名中插入零宽字符,攻击者可以创建在数据库中不同但在视觉上完全相同的账户。归一化本是解决方案,却在某些情况下成为攻击的帮凶。
终端中的字素群困境
2023年,终端模拟器开发者Mitchell Hashimoto发表了一篇引起广泛关注的文章,揭示了终端世界中的一个混乱现实。
当你在一个终端模拟器中粘贴表情符号"🧑🌾"(农民)时,光标会移动多少格?答案取决于你使用的是哪个终端:
| 终端 | 移动格数 |
|---|---|
| Ghostty | 2 |
| Alacritty | 4 |
| iTerm | 2 |
| Kitty | 4 |
| Terminal.app | 6 |
| Windows Terminal | 5 |
为什么会有如此大的差异?
传统终端以"字符网格"为基础。每个字符占据一个固定宽度的格子。ASCII字符占1格,东亚字符(如汉字)占2格——这是wcwidth函数的经典行为。
但字素群打破了这一模型。"🧑🌾“由三个码点组成:U+1F9D1(人)、U+200D(零宽连接符)、U+1F33E(稻穗)。如果逐个码点应用wcwidth,结果是2+0+2=4格。但如果正确识别为一个字素群,应该只占2格。
更荒谬的是Terminal.app的6格和Windows Terminal的5格——这些数值在技术上毫无道理,是特定实现细节的产物。
为了解决这个问题,Contour终端的开发者提出了"模式2027"提案,让终端和应用程序可以协商是否使用字素群处理。但这仍然是一个提案,大多数终端尚未支持。
Trojan Source:看不见的攻击
2021年11月,剑桥大学的Nicholas Boucher和Ross Anderson公开了一种被称为"Trojan Source"的攻击方式,影响几乎所有编程语言的编译器。
攻击的原理是利用Unicode的双向文本(Bidi)算法。某些语言(如阿拉伯语、希伯来语)是从右向左书写的。当左向右和右向左的文本混合时,Bidi算法会自动调整显示顺序。
攻击者可以利用特殊的控制字符(如U+202E,右向左覆盖)来操控代码的显示方式。考虑以下C代码:
#include <stdio.h>
int main() {
bool isAdmin = false;
/* } if (isAdmin) begin admins only */
printf("You are an admin.\n");
/* end admins only { */
return 0;
}
在代码编辑器中,这段代码看起来很正常。但编译器看到的是完全不同的逻辑:if (isAdmin)实际上是注释的一部分,而"admin only"的代码块是无条件执行的。
这类攻击被分配了两个CVE编号:CVE-2021-42574(Bidi攻击)和CVE-2021-42694(同形字攻击)。研究团队发现,他们测试的所有主流编译器和代码审查工具都受到影响。
防御措施包括:在编译前检测并拒绝包含Bidi控制字符的源代码,或在显示时标记这些字符。但这需要对现有工具链进行修改,而许多组织尚未采取行动。
同形字攻击:一个域名引发的钓鱼
2005年,国际化域名(IDN)被引入,允许域名中包含非ASCII字符。这本是好事——为什么中文用户要被迫用英文拼写自己的网站地址?
但IDN也带来了新的安全风险。西里尔字母、希腊字母中有许多与拉丁字母视觉上几乎相同的字符。例如:
- 西里尔字母"а”(U+0430)与拉丁字母"a"(U+0061)
- 希腊字母"ο"(U+03BF)与拉丁字母"o"(U+006F)
攻击者可以注册"аррӏе.com"(使用西里尔字母),用户在地址栏看到的是"apple.com",但实际访问的是完全不同的服务器。
这不是理论上的风险。2005年,安全研究人员就演示了如何利用这种方式钓鱼。此后,研究者们多次展示了注册大型科技公司域名的西里尔字母版本进行概念验证,引发了行业对IDN安全的广泛关注。
浏览器厂商的应对措施各不相同。Chrome会显示域名的Punycode形式(如"xn–e1awd7f.com"),除非用户明确将该语言添加到偏好设置中。Firefox默认显示Punycode。Safari则采取了更激进的策略:当检测到混合脚本时,显示Punycode。
但这些措施都依赖用户的警觉性。一个普通的互联网用户可能根本不理解为什么"xn–“开头的域名值得怀疑。
UTF-8的诞生:餐桌上的奇迹
在Unicode的复杂性之外,有一个相对简单的故事:UTF-8的发明。
1992年9月的一个晚上,在新泽西的一家餐厅里,Unix的创造者Ken Thompson在餐桌纸上勾勒出了UTF-8的编码方案。
当时,ISO 10646定义了一种称为UTF-1的编码方式,但Plan 9操作系统的团队(包括Thompson和Rob Pike)对它不满意。UTF-1有一个致命缺陷:当从一个字节流的中间位置开始读取时,无法在不消耗至少一个字符的情况下确定同步位置。
Thompson设计的新方案完全解决了这个问题。UTF-8使用了一种巧妙的前缀编码:首字节的高位指示了这个字符占用了多少字节,后续字节都以"10"开头。这意味着,从任意位置开始,最多读取一个字节就能确定字符边界。
1992年9月8日凌晨3点22分,Thompson发送了包含UTF-8规范的邮件。当天,团队就将Plan 9系统完全迁移到了UTF-8编码。
UTF-8的成功超出了所有人的预期。今天,它已经成为互联网上最主要的字符编码方式,根据W3Techs的统计,98.9%的网站使用UTF-8编码。它的优雅设计让"字符"这个概念至少在编码层面变得简单:一个码点对应1到4个字节,ASCII字符保持原有的单字节形式。
但UTF-8解决的是编码层面的问题。它并不能解决字素群、归一化、双向文本等更高层面的复杂性。
三十五年后的今天
2024年9月10日,Unicode 16.0发布。这个版本新增了5,185个字符,使总数达到292,531个已分配码点。新字符包括7个表情符号(铲子、指纹、无叶树、萝卜、竖琴等),以及对多种历史文字的支持。
Unicode联盟的工作仍在继续。每一版新增的字符和规则,都在扩展这个标准的复杂性。2025年将发布Unicode 17.0,预计又将带来数千个新字符。
对于开发者来说,Unicode的复杂性是一个必须面对的现实。处理文本时,需要明确你操作的是什么层级:字节、编码单元、码点,还是字素群。用户输入需要归一化。域名和标识符需要防范同形字攻击。源代码需要检测Bidi控制字符。
Unicode统一了全球的文字编码,让不同语言的用户可以在同一个平台上交流。这是人类历史上前所未有的成就。但"统一"并不意味着"简单”——一个统一的标准,承载着人类书写系统的全部复杂性,注定会在细节处不断制造新的挑战。
也许,这正是Unicode的本质:它不是一个人为设计出来然后强行实施的系统,而是一个不断吸收人类书写多样性的容器。三十五年来,它从65,536个位置扩展到1,114,112个位置,从简单的16位编码发展为涉及归一化、排序、分段、双向文本等多重规范的庞大体系。
每当你以为理解了Unicode,总有一个新的细节在等着你。这既是它的美丽,也是它的困境。
参考资料
- Unicode Technical Report #36: Unicode Security Considerations
- The Unicode Standard, Version 16.0 (2024)
- Rob Pike, “UTF-8 History” (2003)
- Mitchell Hashimoto, “Grapheme Clusters and Terminal Emulators” (2023)
- Nicholas Boucher & Ross Anderson, “Trojan Source: Invisible Vulnerabilities” (USENIX Security 2023)
- UAX #29: Unicode Text Segmentation
- X Developer Platform: Counting Characters
- Wikipedia: Plane (Unicode), CJK Unified Ideographs, IDN Spoofing