Unicode 规范化:看起来一样的两个字符串为什么不相等
é 可以是一个码点,也可以是 e 加组合重音。比较、存储、索引之前先统一成 NFC,否则同形异码会让查重和登录全部失效。
两个视觉上完全相同的字符串比较起来可能是 false。不是浏览器的问题:同一个字符在 Unicode 里有多种合法编码。
- U+00E9(单个码点)
- U+0065 U+0301(基字符 e + 组合尖音符)
两者渲染出来一模一样,但字节不同,length 也不同(1 对 2)。
四种规范化形式
| 形式 | 做什么 | 典型用途 |
|---|---|---|
| NFC | 组合成最少码点 | 存储、显示、比较的默认选择 |
| NFD | 拆成基字符 + 组合符 | 文本处理、去重音 |
| NFKC | 兼容分解再组合 | 标识符、搜索 |
| NFKD | 兼容分解 | 同上,保留分解态 |
选择规则很简单:比较和存储用 NFC,要剥掉重音用 NFD。
'é'.normalize('NFC').length // 1
'é'.normalize('NFD').length // 2
不规范化会怎样
| 场景 | 症状 |
|---|---|
| 用户名查重 | 两个「相同」的用户名都能注册 |
| 登录校验 | 同一密码换个输入法就登不上 |
| 唯一索引 | 数据库认为两行不同 |
| 字符串排序 | 结果与肉眼顺序不符 |
| 搜索匹配 | 明明存在却搜不到 |
登录那条最阴:手机上打出的可能是 NFC,桌面输入法给的是 NFD。密码哈希对字节敏感,于是「密码错误」。
该在哪里统一
入口统一,之后一路信任。 在接收用户输入的最外层做一次 normalize('NFC'),写进数据库、写进缓存、参与比较的全是规范形式。
const normalizeKey = (s) => s.normalize('NFC').toLowerCase();
如果历史数据已经混了两种形式,不能直接加唯一索引 —— 先全量规范化再建,否则约束会在加索引那一刻失败。
兼容分解是另一回事
NFKC 更激进:把 ① 变成 1、㎏ 变成 kg。用于标识符和搜索合适,用于展示会改变用户原意。
'①'.normalize('NFKC') // '1'
'①'.normalize('NFC') // '①'
绝不要对密码做 NFKC。 它会把视觉不同的密码合并成一个,白缩小密钥空间。
同形字是另一个问题
规范化解决「同一字符的不同编码」,不解决「不同字符长得一样」:西里尔字母 U+0430 与拉丁 U+0061 在多数字体下无法区分。这类只能靠白名单或混淆检测,规范化无能为力。
字节层面的相等,永远以规范化之后为准。入口处做一次,后面全省。

评论
…