/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
事情起因很普通:一份多语言供应商名单要合并去重,再统一成 Title Case 显示在后台下拉框里。名单不大,14 行,德文、土耳其文、希腊文、连字各有一点。第一版代码只有一行——new Set(rows.map(s => s.toLowerCase())),跑出来是 9 条;有人觉得太多,改成 toUpperCase(),变成 5 条;换成 Intl.Collator 做比较,7 条;什么都不做的原样去重,14 条。
同一份输入、同一个「忽略大小写」的说法,五种实现给出四个不同的答案,而且没有一种报错。这不是实现质量差别,而是「忽略大小写」这四个字在 Unicode 里本来就不是一件事:
- 大小写映射不是 1:1:32 个样本里 7 个 转换后码元数会变,ß 1→2、ffi 1→3、İ 小写化 1→2;
- 映射还依赖位置:希腊文词尾 sigma 会让同一个大写字母 Σ 在词中落成 σ、在词尾落成 ς;
- 映射依赖 locale:'I'.toLocaleLowerCase('tr') 得到的是 ı,不是 i;
- 映射对表外的字符直接不存在:34 个样本里 6 个 大小写双向都不变,非 BMP 字母用 charAt(0) 那套首字母大写惯用法会整步失效。
这篇文章把四条各自做成实测,最后给一份能直接抄的折叠键实现、一份逐码点的首字母大写实现,和一张「大小写转换前该问的 12 个问题」。
一、先摊开这份名单和五种口径的结果
14 行名单(就是上面那批,含刻意的重复):
straße STRASSE Straße strasse
İstanbul ISTANBUL istanbul ıstanbul
Σ aaa σ aaa ς aaa
file FILE file
五种「忽略大小写」的实现与它们的唯一数:
| 原样 | 不做任何折叠 | 14 |
| lower 键 | s.toLowerCase() | 9 |
| Collator | Intl.Collator('und', {sensitivity:'accent'}) 两两比较 | 7 |
| upper 键 | s.toUpperCase() | 5 |
| 手工安全折叠 | 小写化后再补 ß→ss、İ/ı→i、连字展开、ς→σ | 5 |
逐条明细(lower 键与手工折叠键都印出来,upper 键因为只剩 5 类没单独列):
"straße" lower="straße" fold="strasse"
"STRASSE" lower="strasse" fold="strasse"
"Straße" lower="straße" fold="strasse"
"strasse" lower="strasse" fold="strasse"
"İstanbul" lower="i̇stanbul" fold="i̇stanbul"
"ISTANBUL" lower="istanbul" fold="istanbul"
"istanbul" lower="istanbul" fold="istanbul"
"ıstanbul" lower="ıstanbul" fold="istanbul"
"Σ aaa" lower="σ aaa" fold="σ aaa"
"σ aaa" lower="σ aaa" fold="σ aaa"
"ς aaa" lower="ς aaa" fold="σ aaa"
"file" lower="file" fold="file"
"FILE" lower="file" fold="file"
"file" lower="file" fold="file"
四个数各自漏在哪里,正好对应后面四节:
- lower 键 = 9:straße / Straße / STRASSE / strasse 四种写法被折成两种(ß 小写化还是 ß,STRASSE 小写化成 strasse),四种土耳其 i 写法被折成三种(İstanbul 小写化后是 i 加上组合上句点,与 istanbul 不相等),三种 sigma 写法折成两种(ς 不受 toLowerCase 影响),file 那组折成两种(连字小写化还是连字)。
- upper 键 = 5:反过来,ß、fi 大写化成 SS、FI 之后与拉丁拼写完全一致,Σ/σ/ς 全并成 Σ。但 İstanbul 与 ISTANBUL 在 upper 键下仍然不同(İ 大写化是它自己),ıstanbul 与 istanbul 也不同。
- Collator = 7:它能认 Σ/σ/ς 与 fi/FI 的等价,却仍然把 ß 与 ss 判成不同(accent 档给的是 1 或 −1,不是 0)。
- 手工折叠 = 5:这档不是「更聪明」,而是明确定义了业务上的等同关系——把连字展开、把 ß 换成 ss、把两种土耳其 i 归到 i、把词尾 sigma 归到 σ。它是唯一需要你去写出来的那一个。
顺带一条实测:14 对组合里,「lower 键是否相等」与「upper 键是否相等」这两个判定本身就有 4 对不一致(straße/STRASSE、Straße/strasse、ffl/FFL、file/FILE 是 lower 不同而 upper 相同)。所以不存在一个「标准的大小写不敏感相等」,只有选定的口径。
二、大小写映射不是 1:1:码元数会变的 7 个样本
String.prototype.toUpperCase/toLowerCase 走的是 Unicode 的全映射(full case mapping),一个字符可以映射成多个字符。32 个样本里长度会变的 7 个,逐条实测(码元数 / UTF-8 字节数):
| ß | 1 / 2 B | 2 / 2 B = SS | 1 / 2 B | 小写没有对应单字符的大写形式,只能展开 |
| fi | 1 / 3 B | 2 / 2 B = FI | 1 / 3 B | 码元变多、字节反而变少 |
| ſt | 1 / 3 B | 2 / 2 B = ST | 1 / 3 B | 同上 |
| ffi | 1 / 3 B | 3 / 3 B = FFI | 1 / 3 B | 一个字符炸成三个 |
| İ | 1 / 2 B | 1 / 2 B | 2 / 3 B = i + U+0307 | 小写化时新增一个组合符 |
| ΐ | 1 / 2 B | 3 / 6 B = Ι + U+0308 + U+0301 | 1 / 2 B | 希腊文带分音符重音,展开成三码元 |
| ʼn | 1 / 2 B | 2 / 3 B = U+02BC + N | 1 / 2 B | 前面那个是撇号,不是字母 |
三件事要从这两张表里拿稳:
同一页工具上也能量到这两条:İstanbul 8 码元 / 9 字节,小写化后 9 码元 / 10 字节;straße 6 码元 / 7 字节,大写化后 7 码元 / 7 字节(多一个码元、字节不变)。
三、有大小写但没有映射的字符:6 个双向惰性样本
映射表里没有条目时,转换是静默的空操作——不报错、不提示、结果与输入逐字节相同。34 个样本里 toUpperCase 与 toLowerCase 双向都不变的有 6 个:
𝐀 数学粗体大写字母(U+1D400,2 码元)
№ 序号标志
㍿ 日文方头字「株式会社」
1 数字
中 汉字
ᵫ 小型字母 u(U+1D6B)
同一批里「大写化不变」的有 18 个、「小写化不变」的有 20 个。这个不对称值得记一下:像 Ω、Ⱥ、Ŋ、Œ、ẞ、Σ、ΟΔΟΣ 这些已经大写的串,大写化是空操作,但小写化有效;反过来 ß、fi、æ、ʒ、dž 小写化是空操作,大写化有效。所以「跑了大写化之后字符串没变」有两种完全不同的含义:它本来就全大写,或者它压根没有映射。 只看输出长度判断「转换成功」会同时踩中这两种。
𝐀 那一行尤其容易看走眼:它在 Unicode 里视觉上确实有粗体小写对应(数学字母表符号区段),但 JavaScript 的映射表不为数学字母表符号定义大小写,实测 '𝐚'.toUpperCase() === '𝐚'、'𝐀'.toLowerCase() === '𝐀' 都成立。同理 № 里的 N、㍿ 里的汉字,任何大小写操作都是空。
四、位置敏感:同一个字母,词中和词尾不是同一个字符
希腊文的大写 sigma 小写化时,结果取决于它在词里的位置(Unicode 的 Final_Sigma 特殊映射)。6 组实测,右边是输出码点:
"Σ" → "σ" [03C3]
"ΑΣΒ" → "ασβ" [03B1 03C3 03B2] ← 词中:普通 σ
"ΟΔΟΣ" → "οδος" [03BF 03B4 03BF 03C2] ← 词尾:终形式 ς
"ΣΣΣ" → "σσς" [03C3 03C3 03C2] ← 一串同字母,末位仍换成 ς
"ςΣς" → "ςσς" [03C2 03C3 03C2] ← 已经是终形式的保持
"ΑΒΣ ΓΔ" → "αβς γδ" [03B1 03B2 03C2 0020 03B3 03B4] ← 词尾判定连着空白分词
对做键值折叠的人来说,这意味着:σ 与 ς 是两个不同的码点,toLowerCase 不会把它们并起来(它只处理大写→小写方向)。名单里 σ aaa、ς aaa 两种写法在小写键下算两条,在大写键下算一条(都升成 Σ);想两向都稳,就得显式做折叠(ς → σ)。而 ΣΣΣ 小写化会得到 σσς,再拿它去和原串比「是不是幂等」,你会以为函数不稳定——其实是映射本身带上下文。
另一个同源的位置敏感映射是 Β… 不,更常见的坑是 Dž 这类标题式连字(U+01C5 是「大写 D + 小写 z」的单一码位):它的大写化是 DŽ、小写化是 dž,三个不同码位,而 Dz 两个码位的大写化是 DZ。实测 Džurad 小写化得到 džurad(5 码元),大写化得到 DŽURAD——两个方向都保持 5 码元,但落到的码位与 DZ 系完全不同,折叠时需要按码位处理而不是按「看起来是什么」。
五、locale 敏感:i 家族在不同语言里不是同一个字母
土耳其语和阿塞拜疆语有两对 i:带点的 İ/i 与不带的 I/ı,它们的映射与默认口径交叉。实测:
| ISTANBUL | istanbul | ıstanbul | ISTANBUL | ISTANBUL |
| İstanbul | i̇stanbul(i + U+0307) | istanbul | İSTANBUL | İSTANBUL |
| i | i | i | I | İ |
| I | i | ı | I | I |
| fileID | fileid | fileıd | FILEID | FİLEID |
| INFO | info | ınfo | INFO | INFO |
三条口径结论:
六、非 BMP:charAt(0) 那套首字母大写会整步失效
「取首字符大写 + 其余小写」是 Title Case 的常见写法。问题在于 JavaScript 字符串是 UTF-16 码元序列,charAt(0) 取到的可能是半个字符。实测下表两版:「码元版」= s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(),「码点版」= 先用 Array.from 切成码点数组、再做同样两步。
"𐐨𐐴𐐨"(Deseret,U+10428 U+10434 U+10428) 码元6 码点3
全串 upper = "𐐀𐐌𐐀"(U+10400 U+1040C U+10400,正确)
码元版 = "𐐨𐐴𐐨"(原样,首字母没大写) 码点版 = "𐐀𐐴𐐨"(正确)
"𞤢𞥁"(Adlam,U+1E922 U+1E941) 码元4 码点2
全串 upper = "𞤀𞤟"(U+1E900 U+1E91F,正确)
码元版 = "𞤢𞥁"(原样) 码点版 = "𞤀𞥁"(U+1E900 U+1E941,首字母大写生效)
"𐖜"(Vithkuqi,U+1059C) 码元2 码点1
码元版 = "𐖜"(原样) 码点版 = "𐕵"(U+10575,正确)
"x😀y" 码元4 码点3 码元版="X😀y" 码点版="X😀y"(一致)
"Džurad" 码元5 码点5 码元版="DŽurad" 码点版="DŽurad"(一致,但都不是期望的 Džurad)
"ffi" 码元1 码点1 码元版="FFI" 码点版="FFI"(码元数 1→3)
6 个非 BMP / 特殊样本里 3 个 两版结果不同,全都是「码点版对、码元版什么都不做」。机制很直白:首字符是代理对时,charAt(0) 拿到前代理(孤立代理码元没有任何大小写映射),slice(1) 从后代理开始(同样没有映射),于是整步静默跳过。
好消息是它不会把字符串弄坏——x😀y 这类 emoji 不在下标 0 时,两个码元都保持原样,字节数不变(4 码元 / 6 字节原样)。坏消息是它「什么都没做」的样子,和「这个字符本来就不需要大写」完全一样(见第三节那 6 个惰性字符)。判定方法只有一个:拿码点版的输出与码元版比对,不一致就说明码元版漏了。
顺带两条相关口径,实测在同一批样本上分叉:
- 「什么算小写/大写」:/[A-Z]/ 对 ΩÀßDžİ𝐀 命中 0 个,/\\p{Lu}/u 命中 3 个。用 ASCII 字符类做「首字母是否已大写」的判断,对带音标的字母一律给出「不是大写」的结论。
- 驼峰边界的识别:只看「ASCII 小写紧跟 ASCII 大写」时,13 条样本有 6 条 与按 \\p{Ll}\\p{Lu} 识别的段数不同(àB、āB、ßS、éE、ñA、üU 在前者眼里是一个词,在后者眼里是两个)。
七、字符之外的坑:空白口径与「看不见但算内容」
大小写工具几乎总有「空输入不处理」的守卫,而 trim() 与 /s+/ 认定的空白范围比肉眼宽:
被判成空(实测 6 类,直接不处理):
""(空串)、" "(3 个空格)、"\\t\\t"(2 个制表符)、"\\n"、
"\\uFEFF"(BOM,3 字节)、"\\u3000"(全角空格,3 字节)
不被判成空、原样进输出的:
"\\u200B"(零宽空格)→ 既不被 trim() 认空白,也不被 /\\s+/ 切分,输出仍是 1 码元 / 3 字节
后三类(BOM、全角空格、换行)屏幕上看着「有东西」,守卫却按空处理;反过来零宽空格这种「屏幕上什么都没有」的字符会一路活到输出。粘帖带 BOM、从 Excel 复制带全角空格,是最常见的两种「为什么它说我没输入」。
另一类是同一页面里的两套空白口径:直接大写化/小写化两个模式不做任何 trim 或合并空白," hello world "(17 字节)输出还是 17 字节、首尾空格原样保留;而依赖切词的那几个模式会 trim 并合并连续空白,同一输入只剩 10–11 字节(camelCase/PascalCase 10 字节,Title Case/snake_case/kebab-case 11 字节)。复制粘贴一轮之后首尾空格消失,不是你点的按钮不同,是两个模式走的路径不同。 同理 aAb 里的全角 A(U+FF21)也在映射表内:实测「首字母大写」模式得到 Aab——首字符 ASCII a 被大写化,中间的全角 A 被小写化成了全角 a。映射表覆盖的不只是 ASCII 字母,只是各分支是否调用它不一致。
规模上没有任何护栏:200000 字符的输入实测 11 ms 完成、切出 40001 个词,没有长度上限、没有 try/catch。所以「粘贴一整个日志文件」这件事不会失败,只会安静地按上面的规则处理掉。
八、可抄实现
折叠键:先 NFC,再小写化,再补上「映射不会自动做的等价」。这是本文那张 14 行名单唯一能给出「按业务等同」结果的做法,也是它和 toUpperCase() 版(凑巧也 5 条)的区别——它明确,可解释、可增删。
const LIGATURES = {
'\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl',
'\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'ft',
};
// 业务折叠键:确定、locale 无关、跨端可复现
function caseFoldKey(s) {
return s
.normalize('NFC') // 先把分解式合成,避免 à 与 a+U+0300 分家
.toLowerCase() // 默认(locale 无关)口径的大写→小写
.replace(/[\\uFB00-\\uFB06]/g, m => LIGATURES[m]) // 连字展开:toLowerCase 不展开已经是小写的连字
.replace(/\\u00DF/g, 'ss') // ß → ss(小写化不会替你做)
.replace(/[\\u0130\\u0131]/g, 'i') // İ ı → i:跨语言实体名的常见分家点
.replace(/\\u03C2/g, '\\u03C3') // 词尾 ς → σ
.normalize('NFC'); // 替换之后再合成一次,防止替换引入分解式
}
首字母大写:按码点走,别按下标。
function titleCase(firstUpperRest) {
const cps = Array.from(firstUpperRest); // 码点数组,代理对不会被切开
if (cps.length === 0) return '';
const head = cps[0].toUpperCase(); // 无映射时返回原样,不会损坏
const tail = cps.slice(1).join('').toLowerCase();
return head + tail;
}
// 需要真正「词」级的 Title Case 时,用 Intl.Segmenter 按字素切:
// 'á'(a+U+0301) 是 1 个字素、2 个码元;'🇨🇳' 是 1 个字素、4 个码元
自检函数:把「静默空操作」和「长度变化」都量出来,用来在上线前知道这批数据里有多少条会白跑。
function caseAudit(rows) {
const rep = { lenChanged: 0, inertUpper: 0, inertLower: 0, localeRisk: 0, nonBmp: 0 };
const risky = /[\\u0130\\u0131Ii]/; // i 家族:locale 敏感区
for (const s of rows) {
if (s.toUpperCase().length !== s.length || s.toLowerCase().length !== s.length) rep.lenChanged++;
if (s.toUpperCase() === s) rep.inertUpper++;
if (s.toLowerCase() === s) rep.inertLower++;
if (risky.test(s)) rep.localeRisk++;
if ([…s].some(c => c.codePointAt(0) > 0xFFFF)) rep.nonBmp++;
}
return rep;
}
在本文那份 14 行名单上跑 caseAudit,实测落点是 lenChanged=4(straße、Straße、İstanbul、file 这四行会因大小写化改变码元数——前三行分别靠 ß→SS 与 İ→i+U+0307,第四行靠 fi→FI)、inertUpper=3、inertLower=8、localeRisk=6(四行土耳其写法,加上 FILE、file——判定式里只要出现 I 或 i 就算风险行,取向是宁多勿少)、nonBmp=0。结论是这批数据的主要风险既不在长度也不在非 BMP,而在折叠键与 locale;lenChanged=4 比直觉的 1 行多出一倍,因为多数人会忘记 straße 这类串在大写化方向也会变长。
九、对照现场
这些分歧我是一边在 大小写转换 上手填、一边把它的转换函数逐字抽出来在 Node 里复核的。它把「7 种模式」平铺在同一屏,好处是能一次看到同一份输入在不同口径下的输出并排摆着:İstanbul 那行在小写化模式下多出的组合符、aAb 那行里被顺手小写化的全角字母、BOM 或全角空格被判成空输入时那种「什么都没发生」的样子,都是肉眼可比的。要注意的是它只给结果、不给判据:这一页的 Title Case 会把词内其余字母一律小写化,所以 HTTPServer 之类的写法在这里从来不是「原样保留首字母大写」的语义;那部分属于切词与往返行为的话题,本文的焦点是字符层的映射本身,不重复展开。
网硕互联帮助中心




评论前必须登录!
注册