问题从何而来
带重音的字符有两种写法:一个预组合码位(例如 é,单个码位),或基础字母 + 组合符号(e 后面跟一个重音符号)。两者渲染完全一致,但字节不同、长度不同,用等号比较必然返回 false。这就是 Unicode 归一化要解决的问题。
四种归一化形式
| 形式 | 做法 | 适用 |
|---|---|---|
| NFC | 优先合成单一码位 | 存储与比较的默认选择 |
| NFD | 拆成基础字符 + 组合符 | 需要按音标处理时;macOS 文件名常见 |
| NFKC | 合成 + 兼容替换 | 搜索、去重(会改变语义) |
| NFKD | 分解 + 兼容替换 | 同上,分解形态 |
兼容形式要慎用:NFKC 会把全角字符、连字(fi)、带圈数字(①)等替换成兼容形式。搜索去重很有用,但用于存储会丢失原始信息。
代码示例
// JavaScript:比较前先归一化
const equal = 'a'.normalize('NFC') + 'é'.normalize('NFC') === 'é'.normalize('NFC')
// 注意长度差异(emoji、组合字符会拆成多个码位)
[...'é'].length // NFC 下为 1,NFD 下为 2
// Python
import unicodedata
unicodedata.normalize('NFC', s)
四条实操建议
- 入库前统一 NFC:让同一文本在库里只有一种形态;
- 比较前归一化:用户名、邮箱、标签等需要精确匹配的值尤其要处理;
- 哈希前固定形态:否则同一段文本会算出不同摘要,去重与校验都会失效;
- 大小写转换别想当然:土耳其语等语言的大小写规则与英语不同,涉及排序时显式指定 locale。
还有两个坑
- 截断与长度限制:emoji、组合字符、国旗由多个码位组成,按码位切片会出现半个字形,建议按字素簇处理;
- 安全过滤的顺序:应先归一化再做黑名单匹配,否则攻击者可用等价写法绕过关键字过滤。