第 4 讲 / 共 10 讲

看起来一样,为什么匹配不上?读懂 Unicode 规范化

在通讯录里搜索一个刚刚复制的名字,结果却是零;两行看起来完全相同的文字,去重之后仍然留下两行。这些问题不一定出在数据库,也不一定是空格作怪。文字的外观与它内部的字符序列并不是同一回事。Unicode 规范化处理其中一类表示差异,但决定哪些差异应该消失,仍然是产品和数据的责任。

Copy2Paste约 11 分钟阅读

先把“相同”说清楚

假设我们整理一家咖啡馆的菜单,第一份文件写着 café,第二份文件也写着 café。前者可以用一个预组合的 é,后者可以用字母 e 加组合重音符号。屏幕上的读者通常看不出区别,逐个比较码点的程序却会发现两份字符串不同。这个例子没有错别字,没有解码失败,也没有人偷偷加入普通空格。

在编辑器里重新打一遍名字,有时能让问题暂时消失,却没有说明故障发生在哪一层。更可靠的做法是保留原文,列出每个码点,再比较规范化结果。检查时同时记录字节、码点和显示字符数量,避免把某一个长度当成所有长度。这是一次表示方式的诊断,不是对用户输入是否规范的评判。

“相同”还可能指同一个客户、同一个商品或同一种搜索意图。即使两个名字规范化以后相等,也不能据此把两个人的账户合并。字符比较只提供一个技术条件,不能替代业务身份。先说清自己要比较什么,后面的选项才不会变成一排凭感觉勾选的开关。

四种形式是在选择两件事

NFD 做规范分解;NFC 在规范分解后按规则重新组合。NFKD 和 NFKC 采用兼容分解,后者还进行规范组合。可以把选择拆成两个问题:是否消除兼容性差异,以及结果采用分解还是组合形式。字母里的 K 来自 compatibility,不能理解为“更高级”或者“更安全”。

在本篇的小实验中,é 的 NFD 是 e 加组合重音,NFC 则回到预组合字母;全角 A 在 NFC 下保持全角,在 NFKC 下成为 A。NFC 不等于把每个显示字符压成一个码点,有些组合本来就没有可用的预组合字符。理解形式名称之后,仍然要观察真实输入,而不是根据结果长度猜测处理是否成功。

我们还可以加入圈号、上标、假名等测试材料,比较哪些保持原样、哪些发生变化。这比记忆一长串名称更有用:四种形式不是四个视觉滤镜,而是四套确定的字符串转换约定。它们不会根据文章主题、读者意图或者字段名称自动作出选择。

四种形式是在选择两件事
输入NFCNFKC
é 或 e + U+0301éé
AAA
①①1
x²x²x2

兼容性差异可能承载信息

在商品搜索框里,把全角输入与半角输入当作同一种查询,通常能减少漏搜。但是把整份商品说明永久改写成兼容形式,就需要另外考虑。作者写下的圈号可能对应插图标注,上标可能表达数学关系。搜索方便与原稿保真是两个要求,不能用一个“统一格式”的按钮把它们混在一起。

例如把 x² 变成 x2 后,读者仍然能认出字母和数字,却失去了上标表达的关系;把序号 ① 变成 1,可能使正文中的交叉引用变得含糊。这里的风险不在于转换算法做错了事,而在于应用把适用于检索的转换用于保存正文。算法能保证规则一致,不能保证改写符合每一种写作目的。

因此,批量操作应该展示真实的前后对照,并说明输出会覆盖哪里。用户若只想去掉多余空格,就不应该顺便得到一次兼容规范化。对工具来说,透明地展示少数有意义的变化,比把所有操作包装成“清洗干净”更容易建立信任。

原文与比较键各有职责

一个实用的数据设计是保留用户原文,并另建用于查询的比较键。例如显示名保存作者输入,检索键采用明确约定的 NFC。页面展示和导出使用原文,需要规范等价比较的索引使用检索键。这样既能解释为什么两份输入匹配,也能在规则调整后重新生成索引,而不必猜测已经丢掉的写法。

并非每个系统都必须保存两个字段。团队也可以明确规定正文统一存成 NFC,只要所有入口、导出和调用方都理解这项约定。关键是不要一部分请求在浏览器处理,另一部分请求由后台静默改写,离线导入又绕开所有规则。重复记录常常来自入口不一致,而不是缺少某个神奇函数。

如果准备给现有数据增加唯一约束,应先在副本上计算新键,找出会发生碰撞的记录。逐条决定是合并、保留别名还是要求修改,之后再启用约束。直接上线新的比较方式,可能把历史上允许并存的数据突然判定为重复,用户看到的就会是一串无法解释的保存失败。

规范化不会自动完成其他文本任务

大小写处理、去重音、繁简转换和消除视觉混淆,都不是调用 NFC 后自然获得的能力。拉丁字母 a 与西里尔字母 а 看起来相近,本篇实验中的四种规范化形式都不会把后者变成前者。同样,对一个词做规范分解,重音符号仍然存在;真正删掉重音的是之后另加的删除操作。

这些额外操作有时很有价值,但需要单独命名。中文工具可以提供“全角转半角”的明确选项,而不是让用户从 NFKC 的缩写猜出所有副作用。英文搜索可以讨论是否忽略某些重音,但不能把某个语言的检索习惯推广为所有语言的拼写规则。名字尤其值得谨慎处理,因为使用者常常在意具体写法。

标识符还涉及允许哪些字符、是否区分大小写、如何处理冲突等约束。采用标准中的标识符建议时,应把整个规则作为一个版本化的方案,而不是摘取一个函数当作安全保证。规范化能让选定的等价关系更容易比较,却不能替你判断两个视觉相似的名字是否出自同一主体。

用一个小实验验证自己的假设

下面的代码可以在支持 normalize 的 JavaScript 环境运行。第一组比较说明相同外观不保证原始字符串相等,第二组显示兼容转换对全角、圈号和上标的影响。建议先预测输出,再运行代码。只要把样本替换成自己遇到的问题文本,就能把抽象名称变成可复现的观察。

示例故意把匹配键和原文分开。如果要去重,集合里可以存比较键,但最终结果仍保留第一次出现的原始行。这样能回答两个不同问题:哪些行按照当前规则属于同一组,以及每一组最终展示哪一份原文。对用户而言,“保留第一行”通常比一个没有解释的改写结果更容易理解。

不要只测一个带重音的英文词。加入空字符串、中文、分解字母、全角数字、组合符号、表情以及重复应用转换的情况。样本不需要很多,但每个样本应该对应一条决定。测试的价值在于确认产品约定,而不是证明标准库确实存在。

javascript
const a = "café";
const b = "café";
console.log(a === b); // false
console.log(a.normalize("NFC") === b.normalize("NFC")); // true
console.log("A①x²".normalize("NFC")); // A①x²
console.log("A①x²".normalize("NFKC")); // A1x2
const rows = [a, b];
const seen = new Set();
const kept = rows.filter(line => {
  const key = line.normalize("NFC");
  if (seen.has(key)) return false;
  seen.add(key);
  return true;
});
console.log(kept); // ["café"]

拼接、缓存和迁移里的边界

即使两段文字各自已经规范化,拼接结果仍可能需要重新处理。以 NFC 为例,本篇可以用字母 e 与单独的组合重音复现:两段分别保持原样,拼起来后却可以组合成 é。因此模板、流式导入和分块处理需要关注连接位置,不能因为每个片段都通过检查,就假定整个结果满足同一个约定。

对于一般应用,先得到完整字段,再交给成熟库处理,往往更容易维护。确实需要流式性能时,应使用库提供的边界能力,避免自己按固定字节数切块。缓存键也应记录比较策略的版本;否则新的检索规则上线以后,旧缓存可能继续给出依据旧规则计算的结果。

迁移可以分成观察、回填和切换三个阶段:先统计会改变的记录,再计算新键并检查冲突,最后切换读写入口。保留可重建的原始材料和迁移说明,能让下一次升级成为一次数据工程操作,而不是针对乱码的临时抢修。

把一次漏搜变成可以重现的案例

假设门店员工报告:从旧表格复制的商品名搜不到,手工输入却能找到。第一步不是批量改写整张表,而是保存那两个最小样本。分别查看码点后,如果只有组合形式不同,就测试规范等价比较;如果差异来自空格、标点或另一个字母,则继续沿着真正的差异检查。

第二步把观察写成一条具体的预期:这两种表示在商品名搜索里应返回同一条记录,但原始标题继续按编辑确认的写法显示。随后检查网页搜索、后台搜索和导出再导入是否一致。这条预期比“修好 Unicode”更容易开发,也更容易让编辑验收。

第三步确认误匹配是否增加。拿几组本来必须区分的标题作为对照,观察新规则有没有把它们意外合并。完成这一轮之后,再决定是否回填历史检索键。一个小案例可以同时说明故障原因、用户期待与转换边界,避免团队以后又为同一件事争论。

把决定写成读者能理解的规则

如果你是在整理文章,先检查是否真的存在规范等价差异,再选择是否使用 NFC。如果你是在构建搜索,明确哪些兼容差异可以忽略,并保留展示原文。如果你是在处理账号或外部协议,遵循该系统完整的标识符与比较约定,不能替对方额外“优化”输入。

一个好的操作说明应能写成这样:本次比较忽略规范等价的编码差异,保留每组第一次出现的原文;大小写、空格和全角形式仍按原样比较。说明虽短,却能让使用者预测结果。之后再增加选项,也应该逐条解释其作用,而不是不断扩大“相同”的隐藏定义。

理解 Unicode 规范化之后,最有用的能力并不是记住四个缩写,而是看到一段文字时知道该问什么:我正在保存原稿、寻找内容,还是判断身份?只要这个问题有清楚的答案,转换规则、数据结构和界面提示就更容易达成一致。

参考资料

  1. Unicode Standard Annex #15: Normalization Forms ↗
  2. Unicode Normalization FAQ ↗
  3. ICU: Normalization ↗
  4. ECMAScript: String.prototype.normalize ↗
  5. W3C: Character Model — String Matching ↗
  6. Unicode Standard Annex #31: Identifiers and Syntax ↗