同形异码:Unicode 正规化、编码与文件系统的隐形分岔
有些差异并不显眼,它们安静地藏在字节里。你眼前是同一个「ど」,但当它被递交给编码器与字符串函数时,却像走上了两条分岔的路。
先看现象:
encodeURIComponent('ど')
// '%E3%81%A9'
encodeURIComponent('ど')
// '%E3%81%A8%E3%82%99'
'ど'.split('')
// ['ど']
'ど'.split('')
// ['と', '゙']根因在于 Unicode 允许“规范等价”的多种表示,并通过 NFC/NFD 等正规化形式定义了不同的“合成/分解”策略。12
这里的“起因”并不戏剧化: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.Segmenter:4
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 的正规化只是把这种差异提早暴露出来,而不是制造了它。
系统层面的选择也提醒我们:工程上的“统一”经常是权衡之后的结果。文件系统为了稳定或兼容做出的妥协,会把复杂度推回到应用层;而应用层如果没有意识到正规化的存在,就会在跨端、跨语言、跨链路时被反噬。也许真正值得被记住的,不是某种具体的格式,而是“字符串并不天然稳定”的事实。
如果未来要做点什么,更多是设计上的自觉:在边界处清楚地决定“我接受哪一种表示”,并在存储、索引、对比时坚持这个选择。与其说是技术技巧,不如说是对数据身份的一次约定。