前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
首页程序员面试题库JavaScript String normalize NFC NFD 比较
JavaScript数据处理

两个看起来一样的 é,为什么用 === 比较是 false?

同一个 é 可以存成一个码点 \u00E9,也可以存成 e 加组合重音 \u0301,屏幕上看不出区别,但 === 逐个比较代码单元,结果是 false。比较、去重、建索引之前,先对两边调用 normalize() 统一成同一种形式。

前端进阶之旅 · 一题精讲更新于 2026.09.29
JavaScript#数据处理
先看核心答案读代码示例
理解线索

四种规范形式按两个维度区分

  1. NFC 与 NFD规范等价,NFC 合成成一个码点,NFD 拆成基础字符加组合符号
  2. NFKC 与 NFKD兼容等价,额外把全角字母、连字、上标这类变体折叠成普通字符
  3. normalize()返回新字符串,默认参数是 NFC,传非法形式名抛 RangeError

macOS 的文件系统常把文件名存成接近 NFD 的形式,用户上传的文件名和数据库里的名字对不上,经常就是这个原因。

核心回答

先记住这个答案

因为 Unicode 允许同一个字符有多种码点写法。é 可以是一个预组字符 \u00E9,也可以是字母 e 后面跟一个组合重音 \u0301,渲染出来一模一样。而 JavaScript 的 === 只逐个比较代码单元,序列不同就是 false。解决办法是比较前对两边都调用 normalize(),把它们统一成同一种规范形式,默认的 NFC 适合大多数场景。

  • 显示一样的字符,底层码点序列可能不同
  • normalize() 默认是 NFC,尽量合成预组字符
  • NFKC 会把全角、连字、上标折叠掉,只适合做搜索键
  • 两边都要归一化,只改一边等于没改

先看两个 é 的真实区别

这类问题在线上通常表现为「搜不到」:用户明明输入了 José,数据库里也有 José,查询却没有结果。原因往往是一边来自 macOS 或某些输入法,存的是拆开的形式,另一边是合成的形式。

同一个 é 的两种写法JavaScript
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)。

兼容形式会改变字符本身JavaScript
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
true

NFC 和 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() 再匹配,要么在正则里把两种写法都列出来。

从一道题,走向一组知识

把知识连起来

数据处理

split('').reverse().join('') 反转带 emoji 的字符串,为什么会乱码?

用 e 加组合重音写的 é,按码点反转后重音会跑到别的字母上,和归一化讲的是同一种组合字符。

数据处理

为什么一个 emoji 的 length 是 2?怎么正确遍历字符串?

归一化处理的是码点序列不同,代理对讲的是一个码点怎么占两个代码单元,两者合起来才能说清字符串长度。

参考资料

  • JavaScript:数据处理参考资料

示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。

本题目录
  1. 先记住这个答案
  2. 先看两个 é 的真实区别
  3. NFC、NFD 和带 K 的两种形式
  4. 项目里怎么落地
  5. 容易答错的地方
  6. 面试官还会怎么问
  7. 把知识连起来
读懂,再试着讲出来

先看核心答案,再读代码。最后展开追问,检查自己有没有遗漏边界。

试着回答追问
浏览全部面试题理解原理,也关注真实的使用场景。回到顶部 ↑