先记住这个答案
因为 Unicode 允许同一个字符有多种码点写法。é 可以是一个预组字符 \u00E9,也可以是字母 e 后面跟一个组合重音 \u0301,渲染出来一模一样。而 JavaScript 的 === 只逐个比较代码单元,序列不同就是 false。解决办法是比较前对两边都调用 normalize(),把它们统一成同一种规范形式,默认的 NFC 适合大多数场景。
- 显示一样的字符,底层码点序列可能不同
normalize()默认是 NFC,尽量合成预组字符- NFKC 会把全角、连字、上标折叠掉,只适合做搜索键
- 两边都要归一化,只改一边等于没改
先看两个 é 的真实区别
这类问题在线上通常表现为「搜不到」:用户明明输入了 José,数据库里也有 José,查询却没有结果。原因往往是一边来自 macOS 或某些输入法,存的是拆开的形式,另一边是合成的形式。
const composed = '\u00E9' // é:一个预组字符
const decomposed = 'e\u0301' // é:e + 组合重音
console.log(composed.length, decomposed.length) // 长度不同
console.log(composed === decomposed) // false
console.log(composed.normalize() === decomposed.normalize()) // true
console.log(decomposed.normalize('NFC').length) // 合成后变成 1
console.log(composed.normalize('NFD').length) // 拆开后变成 2查看输出与解释
1 2
false
true
1
2组合重音 \u0301 本身不单独显示,它会叠加在前一个字符上。所以 'e\u0301' 看起来是一个字,length 却是 2。
normalize() 不传参数时用 NFC,意思是先把字符拆到底,再尽量合成预组字符。两边都转成 NFC 之后,序列一致,比较就成立了。字符串是不可变的,normalize() 返回新字符串,原来的变量不会变,要用返回值。
NFC、NFD 和带 K 的两种形式
四种形式按两个问题区分。第一个问题是「合成还是拆开」:C 是合成(Composition),D 是拆开(Decomposition)。第二个问题是「只处理真正等价的字符,还是连长得像的变体也一起折叠」:带 K 的是后者,K 表示兼容(Compatibility)。
console.log('file1'.normalize('NFKC')) // 全角字母和数字变成半角
console.log('fi'.normalize('NFKC')) // 连字 fi 拆成 f 和 i
console.log('x²'.normalize('NFKC')) // 上标 2 变成普通的 2
console.log('file1'.normalize('NFC') === 'file1') // NFC 不动全角字符查看输出与解释
file1
fi
x2
trueNFC 和 NFD 只处理「规范等价」的字符,也就是在任何场合都代表同一个东西,比如两种 é。转换前后语义不变,可以放心用来存储。
NFKC 和 NFKD 更激进,会把全角字母、连字、上标、圆圈数字这类「兼容等价」的变体都折叠成普通字符。这对搜索很有用:用户输入全角的 file 也能搜到 file。但 x² 变成 x2 意思已经完全变了,所以兼容形式只适合生成搜索键,不能用来改写用户原文。
项目里怎么落地
原则是在数据进入系统的边界统一处理,而不是在每个比较的地方临时处理。
比如用户注册、提交表单时,服务端把用户名 normalize('NFC') 后再存;查询时对搜索词做同样的处理。如果历史数据里已经混着两种形式,需要跑一次迁移,或者额外存一列归一化后的值专门用来查询。前端做本地去重时也一样,用 Set 或 Map 之前先把 key 归一化,否则 'Jos\u00E9' 和 'Jose\u0301' 会被当成两个不同的用户。
需要保留原文的字段,比如用户填的昵称、文章正文,展示时用原始值,只在比较和索引时用归一化值。这样既不会让用户觉得自己的输入被篡改,又能保证查得到。
容易答错的地方
- 以为只有带重音的字母才受影响
- 韩文音节也有同样的问题,
'가'.normalize('NFD').length是 2,会拆成两个字母。还有一些单个字符在 NFC 下也会变,比如埃格斯特朗符号\u212B会被转成字母\u00C5,两者显示出来都是 Å。所以不能只凭「这段文本没有重音」就跳过归一化。 - 只归一化查询这一侧
- 数据库里存的是 NFD,查询时转成 NFC,照样匹配不上。归一化必须两边采用同一种形式,这也是为什么要在入库时就统一。
- 把归一化当成大小写或相似字符的解决方案
normalize()不处理大小写,比较前该toLowerCase()的还得做。它也不处理跨文字系统的形近字,比如拉丁字母 a 和西里尔字母 а。防止用户名仿冒需要单独的规则,归一化只是其中一步。
面试官还会怎么问?
normalize() 传一个不存在的形式会怎样?
会抛 RangeError。参数只能是 'NFC'、'NFD'、'NFKC'、'NFKD' 四个之一,不传就是 'NFC'。
用 localeCompare 比较能绕过这个问题吗?
在大多数实现里,'\u00E9'.localeCompare('e\u0301') 返回 0,因为排序规则把规范等价的字符当成相等。但它是为排序设计的,性能比 === 差,还会受语言设置影响,不适合拿来做 Map 的 key 或者数据库索引。等值判断和去重还是先归一化再用 ===。
正则能匹配到拆开形式的 é 吗?
/\u00E9/ 只匹配合成的那种,遇到 'e\u0301' 会失败,加 u 标志也没用,因为 u 处理的是代理对,不是组合字符。要么先对文本做 normalize() 再匹配,要么在正则里把两种写法都列出来。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。