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 在多数字体下无法区分。这类只能靠白名单或混淆检测,规范化无能为力。

字节层面的相等,永远以规范化之后为准。入口处做一次,后面全省。

← 返回文章列表

评论

…