组合字符
与前面的基础字符组合显示的 Unicode 字符。变音符号、日语浊音符号等都属于这一类。
组合字符 (Combining Character) 指的是自身不单独显示、要与前面的基础字符结合成一个字符来显示的 Unicode 字符。组合用变音符号 (U+0300 到 U+036F) 是其中的代表,重音符号、分音符、软音符等都包含在内。
例如在拉丁字母"a" (U+0061) 后面接组合用锐音符 (U+0301),就显示为"á"。日语中对应的是组合用浊音符号 (U+3099) 和组合用半浊音符号 (U+309A),"か"+ U+3099 可以表示"が"。泰语、阿拉伯语等大量使用组合字符的语言也有很多。组合用变音符号这一区块是 U+0300 到 U+036F 的 112 个字符,而在整个 Unicode 中被归类为组合记号的字符超过 2,000 个。
由于组合字符的存在,看起来相同的字符也可能对应不同的码位序列。"á"既可以写成预组合字符 U+00E1 (NFC 形式),也可以写成基础字符 U+0061 + 组合字符 U+0301 (NFD 形式)。需要用 Unicode 规范化 (NFC 组合、NFD 分解) 来统一处理,如果比较字符串之前不做规范化,外观相同的字符串就会被判定为"不相等"。不过 NFC 并不保证总能合成为 1 个字符。"か"后接 U+3099 的字符串经 NFC 会合成为 U+304C (が),但半角片假名"カ"后接半角浊音符号 U+FF9E 的字符串在 NFC 下仍是 2 个字符,只有使用同时转换兼容字符的 NFKC 时才会变成全角的"ガ" (U+30AC)。选哪种形式,取决于你是否希望把半角和全角当作同一个字符。实务中最容易踩到的是文件名。macOS 的旧文件系统 HFS+ 会把文件名中的组合字符统一为分解形,因此与 Windows 或 Linux 上生成的合成形文件名看起来一样,码位序列却不一致 (现行的 APFS 会原样保存给定的形式,并把两种形式视为同一个文件)。Git 有一个吸收这种差异的设置 core.precomposeUnicode,在会混入分解形文件名的环境中不启用它,同一个文件有时会以两个名字重复出现。
组合字符也可以叠加多个。在 1 个基础字符上附加多个组合字符,就能表达上方加重音、下方加软音符这类复合修饰。极端的例子是在 1 个基础字符上叠加数十个组合字符、让笔画上下溢出的"Zalgo 文本",它被用来破坏投稿栏或聊天的行距,属于骚扰手段。Unicode 一侧也有相应的约束思路,规范化的规范把连续组合字符不超过 30 个的形态定义为可安全处理的形式。接受用户输入的一侧也照此为连续组合字符的个数设上限,超出的部分在保存时丢弃,这是现实可行的应对办法。
从安全角度看,滥用组合字符的视觉欺骗 (外观相同而码位序列不同的字符串) 是个问题。验证用户名和域名时,规范化之后再比较是前提条件。但规范化能吸收的只是同一个字符的写法差异。把拉丁字母"a"换成西里尔字母"а"这种用别的字符冒充的手法,规范化之后依然存在。这一类要靠另一项检查来处理,也就是看 1 个词当中是否混入了多种文字体系。
从字符计数的角度看,组合字符按独立码位计数,所以 String.length 的值与看到的字符数并不一致。"á"若是 NFD 形式,码位数为 2,但看上去只有 1 个字符。按书写素簇为单位计数,返回的结果最接近用户的预期。JavaScript 中把 Intl.Segmenter 配合 granularity: 'grapheme' 使用,就能按这个单位计数。在输入框的上限判定上,以分解形送来的"á"会被算成 2 个字符,从用户看来就是毫无缘由的字数超限。只要在接收时先规范化为 NFC 再计数,这类偏差基本就消失了。