Unicode 规范化实用指南:NFC、NFKC 与保留原文的搜索键
在一份笔记里,标题保存的是 cafe\u0301,搜索框里输入的是 café。肉眼看到的重音字母可能相同,精确字符串比较却失败。如果直接把它归结为“编码坏了”,很容易错过真正的问题:两边都可以是合法文本,只是采用了不同的码点序列。
Unicode 规范化可以处理一部分这样的差别。真正接入搜索和存储时,还要决定哪些差别可以折叠、怎样保留原文,以及不同记录被折叠到同一个键后该怎么办。下面用一个自拟的私人笔记标题检索场景把这些决定落实为代码;它并不是对谷米现有搜索实现的修改。
所有比较表格、索引查询和边界断言已于 2026-09-28 在 Node.js v22.23.1、ICU 78.2、Unicode 17.0 环境运行。完整的实验脚本可下载,保存后执行 node normalization.mjs 即可重新检查。
1. 先看码点,再判断两段文字为何不同
排查时,不要只把字符串打印在终端里。字体渲染可能隐藏差别,打印码点才能说明到底保存了什么:
function showCodePoints(text) {
return [...text].map(char => `U+${char.codePointAt(0).toString(16)
.toUpperCase().padStart(4, '0')}`).join(' ');
}
showCodePoints('\u00E9'); // U+00E9
showCodePoints('e\u0301'); // U+0065 U+0301
'\u00E9' === 'e\u0301'; // false
前者是一个预组合的重音字母,后者是字母 e 加组合重音。JavaScript 的普通字符串相等比较不会自行调用规范化;normalize 才是明确的转换操作,见 ECMAScript 字符串规范化方法。
这一步也会帮助区分另一种情况:两个字符串可能只是形状相近,根本没有规范化等价关系。例如拉丁字母 a 与西里尔字母 а。如果一开始就写一个“把看起来一样的字符全部替换掉”的函数,就已经把显示判断、检索政策和身份判断混在一起,后续很难解释哪些文本会被合并。
2. 四种形式,实际需要作出两项选择
Unicode 区分规范等价和兼容等价。前一种处理重音字母等不同编码形式;后一种还可以折叠全角、圈号等兼容表示。四种标准形式由“采用哪种等价关系”和“分解后是否进行组合”构成,定义见 UAX #15 的规范化形式。
| 形式 | 分解方式 | 随后组合 |
|---|---|---|
| NFD | 规范分解 | 否 |
| NFC | 规范分解 | 是 |
| NFKD | 兼容分解 | 否 |
| NFKC | 兼容分解 | 是 |
NFC 的“组合”并不意味着一个可见字符都能压成一个码点。例如,本次脚本验证了 q\u0301 经 NFC 后仍保留两个码点。选形式时应从允许忽略的差别出发,而不是选一个听起来最彻底的名称。
下面把左、右两段字符串分别转换后再比较,得到本次运行结果:
| 左侧 / 右侧 | 原始 === | 都用 NFC 后 | 都用 NFKC 后 |
|---|---|---|---|
é / e\u0301 | false | true | true |
ガ / カ\u3099 | false | true | true |
A / A | false | false | true |
① / 1 | false | false | true |
fi / fi | false | false | true |
A / a | false | false | false |
拉丁 a / 西里尔 а | false | false | false |
这里有三条直接影响实现的结果。第一,日文浊音也可以存在预组合和组合序列之别,问题不只发生在拉丁重音。第二,NFKC 会让 ① 与 1 相等;如果圈号承担列表格式或原文表达,覆盖原文会丢掉这项信息。第三,大小写与跨文字体系的相似形状并没有随 NFKC 一起消失。
因此,在本文的检索场景中,我选择 NFC 来解释重音和浊音问题,选择 NFKC 来建立允许全角和圈号差别的搜索键。这个选择服务于具体场景,并不是“所有文本都应该先做 NFKC”的通用建议。
3. 搜索键要成为派生数据,原文仍然是原文
这个标题索引采用以下明确政策:允许兼容等价的标题匹配;保留大小写和首尾空格;返回所有匹配记录;使用独立 id 标识记录;保存用户提供的标题。它做的是整个标题的等价检索,没有实现子串搜索、排序评分或完整的全文搜索。
把转换政策命名,也给以后重建索引留下可辨认的版本:
const KEY_POLICY = 'note-key-v1-nfkc';
function requireText(text) {
if (typeof text !== 'string') throw new TypeError('Expected a string');
if (!text.isWellFormed()) throw new TypeError('Unpaired UTF-16 surrogate');
}
function toSearchKey(text) {
requireText(text);
return text.normalize('NFKC');
}
function buildTitleIndex(records) {
const index = new Map();
for (const record of records) {
const key = toSearchKey(record.title);
const bucket = index.get(key);
if (bucket) bucket.push(record);
else index.set(key, [record]);
}
return index;
}
function findByTitle(index, query) {
return [...(index.get(toSearchKey(query)) ?? [])];
}
isWellFormed 的检查与规范化本身承担不同职责:先确定本例接受的是完整 Unicode 文本,再应用检索政策。示例脚本会先确认这个 API 存在;缺少它的运行环境需要显式安排兼容实现,而不是跳过检查还声称规则相同。
现在放入四条记录:
const records = [
{ id: 'n1', title: '① 設定' },
{ id: 'n2', title: '1 設定' },
{ id: 'n3', title: 'ABC' },
{ id: 'n4', title: 'cafe\u0301' },
];
const index = buildTitleIndex(records);
findByTitle(index, '1 設定').map(r => r.id); // ['n1', 'n2']
findByTitle(index, 'ABC')[0].title; // 'ABC'
findByTitle(index, 'caf\u00E9')[0].id; // 'n4'
findByTitle(index, 'abc'); // []
findByTitle(index, ' ABC'); // []
检索 ABC 得到的展示标题仍是 ABC。这使搜索更宽容,又不需要假装用户最初写的是半角字母。末尾两项返回空数组也很重要:本例没有做小写化和 trim,它们不应该悄悄成为隐含规则。
findByTitle 返回了新的结果数组,但其中仍引用原记录对象。若调用方允许修改结果对象,原数据也可能被修改;需要隔离写入的应用应另外设计不可变数据或拷贝策略。这个短示例展示的是索引和碰撞处理,不包含数据库持久化、权限控制或并发更新。
4. 键碰撞是政策带来的结果,不能覆盖记录
最容易写出的索引是 index.set(key, record)。对上面的 n1 与 n2,第二次写入会覆盖第一次,因为两个规范化键相同。此时检索少了一条记录,问题出在容器结构:程序把“一组可等价检索的记录”存成了“唯一记录”。
示例使用桶数组,让两条记录都留下来。在搜索场景里,这种合并检索范围是有意设计;在唯一用户名或业务编号场景里,则可能需要拒绝重复或人工处理。检索相等不能直接推导为记录身份相同。 独立的 id 正是为了避免标题政策变化时,记录本身跟着消失或改名。
如果现有系统准备从 NFC 改为 NFKC,我会先生成一份候选索引,把同一新键对应多个旧 id 的组列出来。确认每组业务含义后,再确定是全部保留、阻止新建重复项,还是实施明确的数据合并。不能只重算字段并加一个唯一约束,等上线失败时才发现历史记录已经冲突。
KEY_POLICY 在这个内存示例中用于标明规则。在持久化系统里,可以把政策版本与派生键一起保存,或者纳入索引构建元数据;查询端和写入端必须使用同一版。若后来增加大小写折叠、空白处理或其他转换,应该视为政策升级,重新检查结果集合,而不是把它们当成无影响的代码整理。
5. 转换顺序影响边界、长度和高亮位置
拼接后的整体仍需要检查
两个片段各自是 NFC,并不保证拼接结果也是 NFC。这一点由 UAX #15 的拼接说明明确指出,本次脚本也验证了这个例子:
const joined = 'e'.normalize('NFC') + '\u0301'.normalize('NFC');
joined === '\u00E9'; // false
joined.normalize('NFC') === '\u00E9'; // true
如果标题由多个片段组装,检索键应从最终标题生成。这个例子同样说明为什么不能随便把完整文本切成块、逐块转换,再假设连接起来的结果满足同一个规范化约定。流式处理需要能处理跨块边界的实现;本文没有提供流式规范化器。
长度校验应针对实际保存值
实验中,e\u0301 经 NFC 后的 UTF-16 长度由 2 变成 1,fi 经 NFKC 后则由 1 变成 2。转换可能改变序列长度。因此,“先按原文本限长、随后转换并保存”不一定等价于“先转换、按保存值限长”。
我的安排是分别定义原文容量与派生键容量,再检查各自真正写入的值。保留原文的系统不应只验证一个更短的键,就假定原字段也满足限制。具体怎样选择字符和字节单位,可以参看文字长度与输入校验。
搜索键的下标不能直接回填原文
fi 变成 fi 后,键里的两个位置对应原文里的一个码点。规范化键上的匹配偏移,因而不能直接拿去 slice 原标题并加高亮。本文返回整条原记录,刻意没有伪造这种偏移映射。
若要实现子串检索与原文高亮,就需要另外维护转换与原文范围的对应关系,或采用能返回原文范围的匹配方案,并验证组合字符、展开字符和边界变化。精确标题检索能够成立,并不证明一个全文高亮实现已经正确。
6. 规范化处理到哪一层就应停在哪一层
NFKC 的比较表已经显示 A 与 a 不相等。需要忽略大小写时,应另外决定采用何种大小写匹配政策,以及支持哪些语言。随手追加 toLowerCase(),就会扩大本文定义的等价范围;即使某批英文样本看起来正常,也不能据此宣称所有语言都已处理。
同样,本文样本中的规范化不会把 é 变成 e。忽略重音需要另设检索规则。原文是否保留、哪些用户认为这种匹配合理、结果是否变得过宽,都应纳入评估。规范化 API 提供了编码形式转换,不会替产品决定“用户想找的词”。
相似字形也需要分开讨论。拉丁 a 与西里尔 а 在本次 NFC 和 NFKC 比较中仍然不同;Unicode 对可混淆字符有独立的机制,见 UTS #39 的 Confusable Detection。把规范化后的键当作登录身份或安全判断的全部依据,会超出这里已经验证的行为。
还有一个实践上的分层原则:字符串是否合法、是否允许控制字符、是否能作为业务字段,都需要各自校验。不要用一个函数名叫 cleanText,把这些处理与规范化连成不可解释的黑箱。函数名与政策版本越明确,后续越容易判断某次变更会影响什么。
7. 发布一条文本政策之前,留下哪些证据
本文的脚本不仅打印表格,还断言查询结果保留两个冲突记录、展示原来的全角标题、大小写仍然不同、首空格仍然有效,以及非法代理码元被拒绝。这样的断言检查的是政策边界,能发现“为了方便而顺手增加一个转换”造成的行为变化。
用于真实系统时,我还会保存一份业务样本:哪些不同写法应该匹配,哪些必须保持区别,每项都标出理由。测试数据不能只有应该成功的重音例子,还需要不该被合并的编号、名字和格式。只有一张全是 true 的成功表,很难证明系统保住了有价值的差别。
最终的数据关系应当可以讲清楚:原文用于展示与重建,派生键用于声明过的匹配政策,独立标识用于记录身份。 NFC、NFKC 的选择只是其中一环。把冲突、顺序和索引版本一起设计好,规范化才会成为可维护的检索规则,而不是一行看似万能、实际悄悄改掉数据的字符串调用。