侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 154 篇文章
  • 累计收到 7 条评论

一个字母大写之后变成两个,长度这一栏就不能信了

2026-9-30 / 0 评论 / 9 阅读

一个字母大写之后变成两个,长度这一栏就不能信了

邮箱校验里最省事的写法是把两边都转成小写再比。这行代码在绝大多数数据上没问题,碰到 ß、İ、fi 这几个字符就不成立了:转换本身会改变长度。

下面所有数字都是本机 node 跑出来的,样本是十来个有代表的字符与单词。

== 1. 同一个串,大写和小写之后的长度 ==
  ß(U+00DF)原始长度 1 → 大写 SS(2)→ 小写 ß(1)  德语小写 eszett
  ẞ(U+1E9E)原始长度 1 → 大写 ẞ(1)→ 小写 ß(1)  德语大写 eszett
  İ(U+0130)原始长度 1 → 大写 İ(1)→ 小写 i̇(2)  带点的大写 I(土耳其语)
  ı(U+0131)原始长度 1 → 大写 I(1)→ 小写 ı(1)  无点的小写 i(土耳其语)
  fi(U+FB01)原始长度 1 → 大写 FI(2)→ 小写 fi(1)  连字 fi
  Dž(U+01C5)原始长度 1 → 大写 DŽ(1)→ 小写 dž(1)  标题体 Dž
  ς(U+03C2)原始长度 1 → 大写 Σ(1)→ 小写 ς(1)  希腊语词尾 sigma
  Σ(U+03A3)原始长度 1 → 大写 Σ(1)→ 小写 σ(1)  希腊语大写 sigma
  straße(U+0073 U+0074 U+0072 U+0061 U+00DF U+0065)原始长度 6 → 大写 STRASSE(7)→ 小写 straße(6)  德语单词,街
  finish(U+FB01 U+006E U+0069 U+0073 U+0068)原始长度 5 → 大写 FINISH(6)→ 小写 finish(5)  连字开头的英文词

德语的小写 ß 转大写是 SS,长度从 1 变成 2。连字 fi 转大写是 FI,同样从 1 变成 2。straße 这个词转大写之后是 STRASSE,长度从 6 变成 7。

反过来也有:带点的大写 I(U+0130)转小写之后是 i 加一个组合点,长度从 1 变成 2;土耳其语的 İSTANBUL 转小写之后 9 个码元,原串 8 个。

同一个比较,两种写法给出相反的答案

「忽略大小写地比较两个串」不是一个规则,是两个。都转大写再比,和都转小写再比,在同一组样本上结论能完全相反。

== 3. 两种「忽略大小写」比较,在同一组样本上结论相反 ==
  "ß" 和 "SS":都转大写再比 → true;都转小写再比 → false

ß 和 SS:转大写之后都是 SS,判定相等;转小写之后一个是 ß 一个是 ss,判定不等。İ 和 i̇ 这组正好倒过来,只有转小写才相等。选哪一种,取决于拿到的数据是哪一类,没有一种写法能同时覆盖。

往返一次,原串就找不回来了

转换不保长度,也不保原样。大写再小写,回不到起点:

== 2. 大写再小写,回不到原串的样本 ==
  ß → 大写 → 小写 = ss,与原串相等:false

十组样本全部返回 false。ς 转大写是 Σ,再转小写变成 σ,词尾 sigma 这个位置信息在第一次转换里就丢了。拿转换后的值做缓存键和去重,等于用一个有损的值当身份。

语言里本来就有现成的比较器

排序和查找不该用字符串转换来做。Intl.Collator 按语言规则比较,sensitivity: "base" 这一档直接让 straße 和 STRASSE 判为相等,返回 0。

== 4. 排序/查找里不受影响的写法(Intl.Collator 与 NFC 归一)==
  Intl.Collator("de", {sensitivity:"base"}): "straße" vs "STRASSE" 比较结果 0
  "straße".localeCompare("STRASSE") = 1
  "fi".normalize("NFKC") = fi,长度 2
  "İ".normalize("NFD") 码点 U+0049 U+0307,长度 2
  Intl.Collator().compare("ß","ss") = 1

同一组里还有两条有用的:normalize("NFKC") 把连字 fi 折成 fi,长度变 2,这一步不丢信息,只是把兼容字符规范化;normalize("NFD") 把 İ 拆成 I 加组合点,方便按基字符处理。归一和大小写转换是两件事,前者可逆,后者在有损的那几个字符上不可逆。

落到代码里的三条规则

比较用 Collator,别用转换

需要「忽略大小写」时把两边交给 new Intl.Collator(locale, { sensitivity: 'base' }),它返回 0 就是相等。locale 要显式给,用运行环境的默认值会在不同机器上得到不同结论。

入库前做一次归一,而不是每次比较时临时转

用户名的唯一性判断,放在写入前的那一步做。归一化(NFKC 加统一小写)的结果单独存一列并加唯一索引,后续的查找和缓存键都用这一列。这样做的代价是表里多一列,收益是同一份数据的键永远只有一个形态。

长度和截断按码点算,不按 .length 算

.slice(0, 6) 这种写法按 UTF-16 码元切,遇到代理对会把一个字符切成两半。展示名截断、日志脱敏这类场合,用 [...str].slice(0, n).join('') 或者 Intl.Segmenter。

== 5. 真实场景:邮箱与用户名校验的两种写法 ==
  输入 STRASSE@example.com
  转小写后比较 "strasse@example.com":true
  转小写后比较 "straße@example.com":false
  取前 6 个字符当展示名:STRASS(长度按 UTF-16 计 6)
  "İSTANBUL".toLowerCase() = i̇stanbul,长度 9(原串 8)
  按长度截前 1 个字符:i 码点 U+0069

什么时候不必管这一套

全站数据都在同一个 locale、且历史上从没出现过扩展字符的,按老写法处理就行,多一层归一化换来的是没用的复杂度。反过来说,只要系统面向国际用户、或者有用户自己填的昵称和公司名,早晚会遇到这几个字符:这时候一次归一化,比事后排查「两个看着一样的用户名能同时注册」便宜得多。

我的取舍是先看数据里有没有扩展字符,有就加归一化列,没有就在代码里留一行注释,标清楚这一段依赖 ASCII 假设。这条注释比多写十行归一化代码更容易在半年后被人看见。

    🤞 分享