代理对 (Surrogate Pair)
UTF-16 中使用两个 16 位编码单元表示 BMP 之外字符的机制。
代理对 (Surrogate Pair) 是 UTF-16 编码中用于表示基本多语言平面 (BMP: U+0000 至 U+FFFF) 范围之外字符的机制。它将高位代理 (U+D800 至 U+DBFF) 和低位代理 (U+DC00 至 U+DFFF) 两个 16 位代码单元组合起来表示一个字符。Unicode 中 U+10000 至 U+10FFFF 范围内的约 100 万个字符都通过这种方式表示。
代理对之所以成为必要,背后是 Unicode 扩展的历史。最初 Unicode 计划用 16 位 (65,536 个字符) 收录全世界的文字,但人们发现要收进人名、地名中使用的汉字以及历史文字并不够用,于是在 1996 年 7 月的 Unicode 2.0 中把编码空间扩展到了 U+10FFFF。当时也出现过把所有字符都用固定 4 字节表示的方案,但内存与磁盘的消耗过大,而且等于抛弃以 16 位为前提构建的既有实现,因此作为折中方案,把 BMP 中的未使用区域 (U+D800 至 U+DFFF) 预留给代理,采用了用两个代码单元表示一个字符的机制。表情符号之所以用代理对表示,是因为 2010 年 Unicode 6.0 以后追加的部分正好落在这个事先准备好的机制之上,并不是表情符号导致了这次扩展。
大多数表情符号位于 BMP 之外,因此用代理对表示。例如 "😀" (U+1F600) 由高位代理 U+D83D 和低位代理 U+DE00 组成。JavaScript 的 String.length 返回 UTF-16 代码单元数,因此 "😀" 的 length 是 2 而不是 1。同样,charAt() 和 charCodeAt() 也以代码单元为单位操作,无法正确处理代理对字符。
要获取以码点为单位的字符数,可以使用 [...str].length 或 Array.from(str).length。它们利用迭代器协议按码点分解字符串,将代理对视为单个字符。ES2015 以后,codePointAt() 方法和 for...of 循环也按码点单位操作。但要准确计数书写素簇 (由组合字符或 ZWJ 序列构成的视觉上的单个字符),则需要 Intl.Segmenter API。
在实务中最容易出问题的,是以代码单元为单位截断后残留的单侧代理。把 "😀" 用 slice(0, 1) 截取会得到长度为 1 的无效字符串,把它传给 encodeURIComponent() 会以 URIError 抛出异常。转换为 UTF-8 时会变成 1 个替换字符 (U+FFFD),用 JSON.stringify() 则会输出 "\ud83d" 这样的转义形式,无法还原为原来的字符。在把输入值按固定长度截断后再发送的处理中,这种损坏的字符串有时直到保存或对外联动的前一刻都不会被发现。虽然可以用 isWellFormed() 判定是否存在单侧代理,用 toWellFormed() 把它归拢为替换字符,但根本的对策是把截断的单位从 UTF-16 代码单元改为码点或书写素簇。
代理对是 UTF-16 特有的概念,在 UTF-8 和 UTF-32 中不存在。UTF-8 使用可变长度 (1 至 4 字节) 直接编码码点,UTF-32 使用固定 4 字节表示所有码点。数据库的字符类型列 (如 MySQL 的 utf8 与 utf8mb4 的区别) 也可能遇到代理对字符的存储问题。
从字符计数的角度来看,代理对提出了 "什么是一个字符" 这一根本问题。根据计数单位是 UTF-16 代码单元数、码点数还是书写素簇数,结果会有所不同。设计字符计数工具时,明确计数单位并确保与用户期望的结果一致非常重要。