UTF-16

一种使用 16 位编码单元的 Unicode 编码方式,被 JavaScript、Java 和 Windows 内部使用。

UTF-16 是以 16 位 (2 字节) 为单位表示 Unicode 的编码方式。基本多语言平面 (BMP, U+0000-U+FFFF) 的字符用 2 字节表示,BMP 之外的字符通过代理对用 4 字节表示。它被 JavaScript、Java、C#、Windows API 等众多编程环境采用为内部字符串表示。

UTF-16 最大的特点是代理对机制。无法容纳在 BMP 中的字符 (U+10000 及以上) 通过高位代理 (U+D800-U+DBFF) 和低位代理 (U+DC00-U+DFFF) 两个 16 位编码单元的组合来表示。表情符号、部分汉字 (CJK 统一汉字扩展 B 及之后)、古代文字等都使用代理对表示。

由于 JavaScript 字符串内部使用 UTF-16 表示,String.length 返回的是 UTF-16 编码单元数。例如表情符号"😀"(U+1F600) 以代理对表示,因此 length 为 2。charAt() 和 charCodeAt() 也按编码单元操作,可能只获取代理对的一半。ES2015 之后,codePointAt() 和 for...of 循环可以按码点级别处理。

UTF-16 存在字节序 (端序) 问题。有 UTF-16BE (大端序) 和 UTF-16LE (小端序) 两种,通过文件开头的 BOM (字节顺序标记,U+FEFF) 来识别字节顺序。Windows 系的工具往往备有把 UTF-16LE 连 BOM 一起保存的选项,如果接收一侧没有预想到 BOM,这份数据就会被当作开头混入了 1 个看不见的字符来处理。虽然 Web 标准化为 UTF-8 后遇到 UTF-16 字节序问题的机会减少了,但在与 Windows 环境或遗留系统交互时仍需注意。

与 UTF-8 相比,ASCII 字符在 UTF-8 中占 1 字节,在 UTF-16 中占 2 字节,因此英文文本 UTF-8 更节省空间。相反,CJK 字符在 UTF-8 中占 3 字节,在 UTF-16 中占 2 字节,因此日语和中文文本 UTF-16 更紧凑。不过这是正文几乎被 CJK 字符占满时的情形。像 HTML 或源代码那样混入大量标签、属性名、符号等 ASCII 字符的文档,即使包含 CJK 字符,UTF-8 也会更小 (<p class="x">日本語</p> 在 UTF-8 中是 26 字节,在 UTF-16 中是 40 字节)。在估算实际文件大小时,请连字符种类的比例一起看再作判断。此外,由于 Web 标准已统一为 UTF-8,通常的做法是文件存储和通信使用 UTF-8,程序内部字符串处理使用 UTF-16。

在字符计数方面,在基于 UTF-16 的语言 (JavaScript、Java) 中处理代理对是最大的挑战。直接使用 String.length 会导致表情符号和部分汉字的字符数被多计。请按想要计数的单位,使用 [...str].length (展开语法) 或 Array.from(str).length 获取码点数,或使用 Intl.Segmenter 获取书写素簇数。这两者不会得到相同的值。例如家庭表情符号"👨‍👩‍👧‍👦"按码点数是 7,按书写素簇数是 1 (以 Node.js 26 实测)。若想对齐人眼看到的 1 个字符,就需要 Intl.Segmenter。

另一个陷阱是按编码单元切分字符串时产生的孤立代理。JavaScript 的字符串被当作 UTF-16 编码单元的序列处理,只剩配对一半的代理也能原样保留。'😀'.slice(0, 1) 会变成只有高位代理 U+D83D 的 1 个编码单元,在这种状态下转换为 UTF-8 会变成替换字符 U+FFFD,而 encodeURIComponent() 会抛出 URIError (两者均以 Node.js 26 实测)。而且如果只是写出为 JSON 则不会报错,会以转义写法的形式留存,于是破损的字符串会在无人察觉中流向保存或发送的后段。在把输入截断为"开头 N 个字符"的处理中,不要直接使用 slice() 或 substring(),先用 Intl.Segmenter 或 Array.from() 对齐单位再切分才安全。

分享这篇文章