Unicode归一化是全球化字符串比较的基础设施;未归一化时,'é'(U+00E9)与'e\u0301'(e+组合符)因码点不同导致===等操作失败,须在全链路统一使用NFC归一化。

字符串看起来一样却匹配失败?大概率是 Unicode 归一化没做对。直接上结论:normalize() 不是“锦上添花”,而是全球化字符串比较的基础设施 —— 没它,===、includes()、indexOf() 在带重音、变音、组合符号的文本上基本不可信。
为什么 'é' === 'é' 会返回 false
因为两个“é”可能根本不是同一个东西:一个是预组合字符 '\u00e9'(U+00E9),另一个是基础字母 'e' 加组合重音符 '\u0301'(U+0301),即 'e\u0301'。JavaScript 的字符串比较是逐码点(code unit)比的,'\u00e9' 长度为 1,'e\u0301' 长度为 2,自然不等。
这种差异在真实场景中高频出现:
- 用户用不同输入法(如 macOS 按 Option+E 再按 E 得到
'e\u0301';Windows 输入法或复制粘贴常得'\u00e9') - 浏览器 API 返回值不统一(
input.value、textContent、FormData.get()可能混用 NFC/NFD) - 后端数据库(如 MySQL utf8mb4)默认不归一化,存进去什么样就查出来什么样
该选 NFC 还是 NFD:看你的使用场景
绝大多数业务场景只用 'NFC'(默认),除非你明确需要拆解重音做模糊处理。
-
'NFC':把'e\u0301'合成'\u00e9',保留视觉最简形式。适合显示、存储、常规等价匹配 —— 用户输入、表单提交、API 响应、数据库入库前统一调用.normalize('NFC')即可 -
'NFD':把'\u00e9'拆成'e\u0301',方便后续正则过滤或忽略重音(比如用/[aeiou]\u0301/gu批量去掉所有重音符) -
'NFKC'/'NFKD':会把全角数字、上标¹、罗马数字Ⅻ等映射为 ASCII 等价形式,容易误伤(比如把用户昵称“ABC”转成“ABC”),非明确兼容需求不要碰
匹配前必须两端都 normalize,否则白干
只 normalize 用户输入,不 normalize 数据库字段或配置项,照样失败。常见翻车点:
- 前端
input.value.normalize('NFC')后发给后端,后端没 normalize 就直接查 MySQL —— 库里存的是原始 NFD,查不到 - 用
new RegExp(pattern.normalize('NFC')),但忘记对目标字符串也str.normalize('NFC'),.test()仍返回 false - 搜索框输入 “cafe”,想匹配数据库里的 “café”,结果只 normalize 了输入,没 normalize 字段值(或字段值是旧数据,未归一化入库)
正确做法:在数据流入系统的**第一入口**就归一化,比如 Express 中间件对 req.body 统一 .normalize('NFC'),MySQL 存储前也 normalize;前端搜索时,确保 searchTerm 和 item.title 都经过相同形式的 normalize。
length 计算和 normalize 的关系别搞混
normalize() 不解决 length 返回错的问题(比如 '?'.length === 2)。那是 UTF-16 代理对导致的,跟归一化无关。如果你要统计“人眼可见字符数”,该用 Array.from(str).length 或 [...str].length;如果要做截断、校验长度,优先用 codePointAt() 遍历或 Intl.Segmenter(新标准,支持 emoji、ZWJ 序列等)。
真正容易被忽略的,是归一化必须**贯穿全链路**:从前端输入、网络传输、后端解析、数据库存储、再到查询比对 —— 任一环节漏掉,等价性就崩了。它不像加个 polyfill 那样“加完就完事”,而是一种数据契约。

















