← 返回文章列表

两个「看起来一样」的字符串为什么不相等:Unicode 归一化

字符编码避坑

问题从何而来

带重音的字符有两种写法:一个预组合码位(例如 é,单个码位),或基础字母 + 组合符号(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)

四条实操建议

  1. 入库前统一 NFC:让同一文本在库里只有一种形态;
  2. 比较前归一化:用户名、邮箱、标签等需要精确匹配的值尤其要处理;
  3. 哈希前固定形态:否则同一段文本会算出不同摘要,去重与校验都会失效;
  4. 大小写转换别想当然:土耳其语等语言的大小写规则与英语不同,涉及排序时显式指定 locale。

还有两个坑

  • 截断与长度限制:emoji、组合字符、国旗由多个码位组成,按码位切片会出现半个字形,建议按字素簇处理;
  • 安全过滤的顺序:应先归一化再做黑名单匹配,否则攻击者可用等价写法绕过关键字过滤。

动手试试

归一化后做校验:哈希计算、JSON 格式化。