regex 总览待审核
字符的真面目 · 同字多形 + normalization

normalization:同一个「Å」为什么有多种 code point 写法

同一个 abstract character 往往对应不止一种 code point 序列:Å 既可以是 precomposed 的单一 code point U+00C5,也可以由 Acombining ring above U+030A 两个 code point 拼成; 此外还有一类 compatibility 变体(全角 对应 A 对应 1𝕏 对应 X),同样是同一字符的不同写法。 它们渲染结果一致,字节序列却不同,=== 会直接判定为不相等。(大小写差异属于另一套机制 case folding,不在 normalization 的职责内,见 case folding 那页;那里还涉及 locale 差异。) 为将这些「同字多形收敛到统一形态,Unicode 定义了四种 normalization form:NFC / NFD / NFKC / NFKD。

先厘清 precomposed:base character + combining mark 合并进单一 code point 的写法。Å 既可以是 precomposed 的单一 code point U+00C5,也可以是 A(U+0041)加 combining ring above U+030A 两个 code point——渲染相同、字节不同NFC 倾向 precomposed(尽量 compose)、NFD 倾向 decomposed(拆开)。precomposed 之所以存在,是为与 Latin-1 等遗留编码无损往返(Å 在这些编码里本就是单字节)。注意 precomposed 是 Unicode 冻结的有限清单:许多组合(如同时带上点与下点的 q̣̇)没有 precomposed 形态,即便 NFC 也只能停在 decomposed 态,至多按 ccc 重排 combining mark 的顺序。

根源:同一字符的多种 code point 写法 —— Unicode equivalence

问题源于 Unicode 的一项设计:abstract charactercode point 序列并非一一对应——同一字符往往可由不止一种 code point 序列表示, Unicode 规定这些写法互相等价(Unicode equivalence)。normalization 所做的即:将互相等价的各种写法按规则收敛到唯一形态, 使 ===、索引、查重得以可靠。等价分两层,恰好对应 NF(normalization form)是否带 K:

四种 normalization form 是两个维度的组合:采用哪层等价(canonical / compatibility)× 收敛到哪种形态(尽量 composed / 彻底 decomposed):

Composed
合成 precomposed 字符
Decomposed
拆成 base character + combining mark
Canonical
规范等价
NFC
存储 / 传输 / 比较的通用默认
NFD
Compatibility
兼容等价
NFKC
搜索 / 去重 / 防伪,有损
NFKD
K(Kompatibility)= 额外执行一步 compatibility decomposition;带 C = 最后再 compose 回去,带 D = 停在 decomposed 态。

这两层等价并非临时判断,而是查每个 code point 的 UCD 记录(UCD 是什么见 \p{…} 属性类那页)中两个字段计算得出:

四种 normalization form

输入一个字符,观察四种 form 各将其转换为何。NFC/NFD 仅在 composed / decomposed 间转换、字形不变;NFKC/NFKD 执行 compatibility decomposition,可能改变字形(带圈数字、ligature、上标都会被还原为基本字符)。 末尾的 q+乱序记号 没有 precomposed 形态,但 NFD/NFC 仍会按 ccc 调换两个 combining mark 的次序——这正是 canonical ordering:

原始输入

四种 normalization form(变化的行高亮)

NFC vs NFKC 的取舍:NFC(尽量 compose)是存储 / 传输 / 比较的通用默认,无损。 NFKC 将 compatibility 变体(①→1fi→fi²→2𝕏→X)归约为基本字符,有损—— 适合搜索 / 去重 / identifier 防伪这类「只关注本质」的场景,不应用其回存原文。

normalization 究竟合并哪些「同字多形」

「同字多形」并非零散特例,而是由 UCD 为每个 code point 登记的 Decomposition_Mapping 字段计算得出——映射是否带 compatibility tag,决定其归入 canonical 层还是 compatibility 层。下面列出两层各自合并的全部类目

Compatibility Equivalence:仅 NFKC / NFKD 处理

每条 compatibility decomposition 都带一个 tag(angle-bracket tag),标明变体类型。全角 / 半角即其中的 <wide> / <narrow>,此外还有一系列同类:

tag类目例(NFKC 后)
<wide>
<narrow>
全角 / 半角 —— 下游还涉及 display width 与正则匹配,见 下文「全角 / 半角」一节A→Aア→ア、全角空格  →普通 space
<font>数学字母变体(script / double-struck / fraktur)𝕏→Xℝ→R
<circle>带圈 alphanumeric①→1Ⓐ→A
<square>方块单位 / 缩写㎡→m2㈱→(株)
<super>
<sub>
superscript / subscript²→2₂→2
<fraction>分数½→1⁄2(带 fraction slash)
<compat>杂项 catch-all:ligature、Roman numeral…fi→fiⅫ→XII
<noBreak>no-break spaceNBSP U+00A0→U+0020
<vertical>竖排标点变体︱→—
<small> / <initial>small form 变体、Arabic 字形位置变体、Arabic initial / medial / final 形
这些「语义相关、字形 / 格式不同」的变体仅由带 K 的 normalization 合并;NFC / NFD 一概不处理。代价是丢失「全角 / 带圈 / 上标」这层信息。

Canonical Equivalence:NFC / NFD 即可处理

不带 compatibility tag 的 decomposition 属 canonical 层,字形与语义完全相同:

类目
precomposed ↔ decomposed(带 diacritic 的 Latin 字母)Å = U+00C5A + combining ring above U+030A
combining mark 的 ccc 重排序(canonical ordering)上点 / 下点先后不同的 q̣̇ → 排成同序后相等
Singleton(单 code point 的 canonical 别名,易被忽略——即便 NFC 也会改变 code point)ohm 号 Ω U+2126→Greek Ω、ångström 号 Å U+212BU+00C5、CJK compatibility 表意字 U+F900→统一 code point
Hangul(算法式 compose / decompose)字母 + ↔ 音节
Singleton 一行有违直觉:即便 NFC 也会替换 code point(尽管字形无可见变化)——「NFC 无损」不等于「NFC 不改 code point」。
看似同义、normalization 却合并的:下列情形常被误以为 normalization 可解决,实则各属其他机制—— 大小写(Kelvin 号 Kk)属 case folding; 繁简 / 异体字( / )是同一词的不同 code point,见 Han unification 那页; 同形混淆(Cyrillic а vs Latin a)靠 skeleton,见 confusables 那页; 排序等价(忽略大小写 / diacritic 至某一 level)靠 collation,见 collation 那页

比较前先 normalize:看似相同,=== 却不等

左右两框默认放入「precomposed Å」与「A + combining ring above」——肉眼无法区分。直接 === 判定不等;各自 normalize('NFC') 后方相等:

深入一类:全角 / 半角(<wide> / <narrow>)

compatibility 表中的 <wide> / <narrow> 值得单独展开。全角 (U+FF21)与普通 A(U+0041)是不同 code point,位于 Halfwidth and Fullwidth Forms 区块(U+FF00~U+FFEF)。除被 NFKC 归约(上方四形 lab 点 / 即见),「全角是另一个 code point」还在 display width正则匹配上各留一个后果——这两者均不归 normalization 处理,却都由此而起。

区块解剖:全角不过是 ASCII code point 平移 +0xFEE0

区块 U+FF00~U+FFEF 收纳几组「改变宽度的副本」:全角 ASCII(U+FF01 !~U+FF5E ~)是 ASCII U+0021~U+007E 整体平移 +0xFEE0 的副本;半角片假名(U+FF61~U+FF9F);全角 / 半角符号变体(¥ ¢ 等)。输入文本,将其中 ASCII / 全角字符成对列出,观察 code point 偏移:

半角 ↔ 全角配对(仅列有宽度副本的字符)

整段互转

偏移并非处处规整:全角空格U+3000,不在本区块、亦不走 +0xFEE0,对应普通 space U+0020半角片假名也无法靠减偏移还原(还涉及 カ+゙ → ガ),交由 NFKC 处理。

后果其一 · display width(East_Asian_Width)

其一是 code point 的 East_Asian_Width(EAW) 属性:monospace 字体中 、全角 两列,ASCII A一列。按字符数对齐的表格一旦混入全角 / CJK 即错位——str.length 无法反映这一点:

abcd| ← 4 个窄字符,1 列对齐 中文字符| ← 4 个宽字符,却占 8 列

逐字符实测渲染宽度(getBoundingClientRect)求列数,占两列者底线标绿,并给出内嵌表查得的 EAW 值(JS 的 \p{…} 无法查询 EAW):

逐字符 EAW 值与实测列数

含义列数
WWide,东亚宽字符(CJK、kana、Hangul)中 ぁ 한2
FFullwidth,全角(ASCII 的全角变体)A ! $2
HHalfwidth,半角(katakana 等的半角形)ア カ1
NaNarrow,窄(ASCII 字母数字标点)A 1 !1
NNeutral,中性(其余不参与东亚排版的字符)é Ω1
AAmbiguous,歧义——东亚环境占 2 列、否则 1 列★ ° §1 / 2
W / F 恒占 2 列;A 取决于上下文(终端的「东亚宽度」设置),误算列宽是经典陷阱。

按宽度截断同样不能按 code point 切:同取 6 个单位,纯 ASCII 留 6 字、CJK 占 12 列早已超框。对比「按 code point 切」与「按列宽切」:

6

后果其二 · 正则匹配:\d / \w 仅识别 ASCII

其二体现在匹配:JS 正则的 \d 恒等于 [0-9]\w 恒等于 [A-Za-z0-9_](无论是否加 u flag),全角 / 一律不命中。或先 NFKC 归约回 ASCII 再以 \d 匹配(勾选下方开关即见),或改用基于 Unicode property 的 \p{Nd} / \p{L}——全角 的 gc 正是 Nd。注意 \p{Nd} 匹配所有 decimal digit(含 Arabic-Indic ٣٤),无法直接传入 parseInt,故「需作为数值使用」时仍以先 NFKC 再 \d 更稳妥:

实用建议:处理 username / 去重 / 缓存 key 前先 normalize(通常 NFC),否则「看似相同」的两个串会被视作不同;仅关注本质的搜索 / 防伪、以及将全角数字 / 字母归约回 ASCII 用 NFKC,但不应用其回存原文。 全角 / 半角的两个后果可归纳为:排版 / 对齐应计列数(EAW)而非 length;正则校验应先 NFKC 再按 ASCII 匹配。 大小写不归 normalization 处理——那是另一套 case folding(正则 i flag 即走此机制),见 case folding 那页