同形异码:Unicode 正规化、编码与文件系统的隐形分岔

Published
Updated
CategoryDev recipes

有些差异并不显眼,它们安静地藏在字节里。你眼前是同一个「ど」,但当它被递交给编码器与字符串函数时,却像走上了两条分岔的路。

先看现象:

encodeURIComponent('ど')
// '%E3%81%A9'
encodeURIComponent('ど')
// '%E3%81%A8%E3%82%99'

'ど'.split('')
// ['ど']
'ど'.split('')
// ['と', '゙']

根因在于 Unicode 允许“规范等价”的多种表示,并通过 NFC/NFD 等正规化形式定义了不同的“合成/分解”策略。12

NFC 与 NFD 的字形等价示意

这里的“起因”并不戏剧化:Unicode 需要容纳历史编码与多语言输入习惯,于是允许同形的多种表示;正规化只是提供一条可选的“统一路径”。差异不是偶然,而是规范为兼容性付出的代价。

NFC 与 NFD 的区别

Unicode 提供多种正规化形式,最常用的是 NFC 与 NFD。1

你可以把它们理解为两种“写法约定”:

  • NFC(Normalization Form C):偏向合成,把可合成的字符尽量合成
  • NFD(Normalization Form D):偏向分解,把字符尽量拆成基础字形 + 组合标记

它们在视觉上等价,但在 码点序列字节表示 上不同,因此会影响:

  • 比较与去重(看起来一样却不相等)
  • 排序与索引(不同序列导致顺序变化)
  • URL 编码与哈希(不同字节导致不同结果)

工程上最常见的误区,是把“用户输入”当作稳定的单一形式。实际上,输入法、剪贴板、跨平台同步都会改变正规化形式。越是跨端、多语言、长链路的数据流,越容易触发这种“看起来一样但对不上”的问题。

同样的字形,不同的码点组合

「ど」看起来是一个字,却可能来自两条路径:一种是 预组合的单一码点(U+3069),另一种是 基础字形 + 组合标记(U+3068 + U+3099)。它们在视觉上等价(canonical equivalent),但在字节层面完全不同。12

这也是为什么上面的 encodeURIComponent 会得到两段不同的结果:一个只走过一个码点,另一个需要把组合标记一起编码。只要你把字符串当作“身份”(索引键、缓存键、URL、哈希或文件名),这种差异就会显形,甚至造成“看起来相同却对不上”。

题外话:为什么 split('') 会拆开?
split('')UTF-16 码元 进行切分,不理解“组合字符”。3 在 NFD 里,「゙」是一个独立的组合标记(combining mark),因此会被拆成两段:

'ど'.split('')
// ['と', '゙']

这说明:看起来是一个字,但在字符串层面它是两个字符

如果你希望按“人眼看到的字符”拆分,可以用 Intl.Segmenter4

const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' })
const parts = Array.from(segmenter.segment('ど'), s => s.segment)
// ['ど']

不同操作系统为何选择不同正规化

历史上,不同系统对“文件名”等输入场景选择了不同的存储策略:

  • macOS(HFS+ 时代)倾向 NFD
    HFS+ 文件名要求“完全分解”的 Unicode 形式(接近 NFD),并要求组合标记按规范顺序存放。5
  • Windows / Linux 通常保留输入形式
    这两者一般不会强制正规化,更多依赖应用层统一。
  • 现代 APFS 更接近“保持输入”
    APFS 在磁盘上保留原始正规化形式,同时提供“正规化不敏感”的查找语义;这与 HFS+ 的“直接存储分解形式”不同。6

于是同一个文件名,在不同系统上可能“看起来一样但不相等”,尤其在 Git、打包、搜索与索引场景中更明显。

这背后的取舍并非简单的对错。文件系统需要在“用户体验”与“工程一致性”之间摇摆:强制正规化减少歧义,但也可能引入兼容性成本;不强制则把复杂度推回应用层。HFS+ 与 APFS 的差异,本质上是在不同历史阶段对“统一/兼容”的权衡。

一点思考

这类问题最棘手的地方,并不是“看见差异”,而是“差异发生在你看不见的地方”。当字符串既是展示,又是身份时,我们往往会把视觉上的一致误当作逻辑上的一致。Unicode 的正规化只是把这种差异提早暴露出来,而不是制造了它。

系统层面的选择也提醒我们:工程上的“统一”经常是权衡之后的结果。文件系统为了稳定或兼容做出的妥协,会把复杂度推回到应用层;而应用层如果没有意识到正规化的存在,就会在跨端、跨语言、跨链路时被反噬。也许真正值得被记住的,不是某种具体的格式,而是“字符串并不天然稳定”的事实。

如果未来要做点什么,更多是设计上的自觉:在边界处清楚地决定“我接受哪一种表示”,并在存储、索引、对比时坚持这个选择。与其说是技术技巧,不如说是对数据身份的一次约定。


参考与引文

Footnotes

  1. Unicode Standard Annex #15: Unicode Normalization Forms 2 3

  2. Unicode Technical Note #5: Canonical Equivalence in Applications 2

  3. MDN: String.prototype.split()

  4. MDN: Intl.Segmenter

  5. Apple Technical Note TN1150: HFS Plus Volume Format

  6. Apple File System Guide FAQ