使用方法
- 把要转换的文本粘进输入框。页面打开时已经预置了 5 行示例,包含土耳其语、希腊语、德语、汉字与 emoji,下面的统计卡显示的就是它的实测结果。
- 选语言环境。默认是 Unicode 默认映射,跨浏览器一致;选
tr会让不带点的I转成土耳其语的 ı(U+0131)而不是i。只有处理土耳其语内容时才需要切。 - 点「转成小写」,或在输入框里按 Ctrl + Enter(macOS 用 Cmd + Enter)立即重算。文本框里的 Enter 保留给换行。
- 看码点变化这一项。它是本页最关键的口径:默认模式下示例文本的码点数增加了 1,因为土耳其语的
İ(U+0130)转成i加一个组合尖音符(U+0307)——一个字符变成了两个码点。切到tr时码点数不变。 - 点「只看变化的行」把结果区收窄到实际发生变化的行,其余行折叠显示;点「复制结果」把全部小写文本带走;点「载入示例」回到默认 5 行;点「清空」清空(空文本是合法输入,会照常输出空结果,不报错)。
这个字母转小写工具能解决什么问题
一是把 URL、文件名、路由统一成小写。多数 Web 服务器在 Linux 上区分大小写:把 /Product/Detail 部署成 /product/detail 后,所有指向大写路径的旧链接会 404。批量转一遍再改配置是最省事的做法——但要注意这不是 slugify:本页只改大小写,不会把空格换成连字符、不会去掉标点。要那套处理用「URL别名生成器」或「Slugify字符串工具」。
二是做大小写不敏感的去重与比对。两个文件名 README.md 和 readme.md 在 Windows 上是同一个文件,在 Git 仓库里却是两条记录。统一转小写后去重,能扫出这类重复提交。注意这是单向归一:不同名但同小写的文件会碰撞,所以别直接拿转换结果当唯一键。
三是处理土耳其语内容时的正确性校验。这是本页最不可替代的场景:土耳其语的标准做法是 i → İ、I → ı,与英语的 i → i、I → i 完全不同。如果你的系统按英语规则处理土耳其语文本,用户搜不到自己输入的词。切到 tr 档位就能立刻看到差异。
四是规范化数据库主键与搜索索引。入库前统一小写,查询时也统一小写,Apple 和 apple 就能互相匹配。代价是原始大小写信息丢失,展示时要另存一份原始值。
本页不做这些事:不做首字母大写或逐词大写(那是格式整形,用「句子大小写转换器」或「Capitalize开头字母工具」);不做驼峰或下划线命名转换;不做全大写——那正好是本站已完成的「字母转大写」页,与本页互为反向操作,注意区分两者的 locale 陷阱差异:转大写时 ß 会展开成 SS,转小写时 ß 保持不变。要按字符种类统计请用「字母统计」与「字符数统计工具」。
原理:toLowerCase 到底按什么规则映射
调用的是 String.prototype.toLowerCase(),规则由 Unicode 标准定义,不是简单的 ASCII 表。ECMAScript 规范规定它使用 Unicode Default Case Conversion 算法,加上 toLowerCase 的上下文规则。这个算法把每个码点查一张 Unicode 数据表,表里定义了三类映射:单字符映射(A → a)、无映射(保持原样)、条件映射(依赖上下文,例如希腊语的最终 sigma)。关键取舍:用 Unicode 表而不是 charCodeAt 加 32 的做法,因为后者只对 ASCII 的 A-Z 有效,遇到 Ä、Σ、İ 会完全错。代价是转换结果不保证长度不变——有的映射会 1 个码点变 2 个。
第一条要记住的规则:土耳其语的 I 有三个不同的写法,toLowerCase() 与 toLocaleLowerCase('tr') 对它们的处理完全相反。三个码位是:I(U+0049,拉丁大写 I)、ı(U+0131,无点小写 i,土耳其语特有)、İ(U+0130,带点大写 I,土耳其语特有)。默认映射下:'I'.toLowerCase() 得 'i'(U+0069);而 'I'.toLocaleLowerCase('tr') 得 'ı'(U+0131)。反过来 'İ'.toLowerCase() 得两个码点:U+0069(i)加 U+0307(组合尖音符),显示成 i̇——因为 Unicode 没有「带尖音符的小写 i」这个预组合字符,只能用「基础字母 + 组合符」两码位表示。这是默认模式下码点数增加的唯一常见原因。
第二条:ß 转小写不变,转大写才展开。'ß'(U+00DF)的小写就是它自己,toLowerCase() 原样返回。但 toUpperCase() 会把它展开成 两个码点 SS(因为德语传统写法与新正字法允许 ss 代替 ß)。所以 'STRASSE'.toLowerCase() 得 'strasse',与 'straße' 不等价——这在做小写归一去重时是个真实的坑。反方向也有一条:大写形态的 ẞ(U+1E9E,2017 年才加入 Unicode 的 capital sharp s)转小写会正确地回到 ß(U+00DF),单码点。
第三条:希腊语的 Σ(U+03A3)是上下文相关的。它在词首或词中转成 σ(U+03C3,普通 sigma),在词尾转成 ς(U+03C2,最终 sigma)。实测:'Σ'.toLowerCase() 单独一个字母时是 σ(没有前文,算词首);'ΟΔΟΣ' 转成 οδος,末尾的 Σ 变成 ς;而 'ΣΑ' 转成 σα,开头是 σ。判定依据是 Unicode SpecialCasing.txt 的 Final_Sigma 条件:Σ 前若有字母、且后面不是字母时视为词尾。所以「同一个字符在词中位置不同结果不同」,这是补角式大小写转换里唯一的上下文依赖项。希腊语 locale(el)不改变这条规则——它本来就是 Unicode 默认行为。
第四条:JavaScript 字符串是 UTF-16,所以「长度」和「字符数」不是一回事。按 ECMAScript 规范,x.length 返回的是 UTF-16 码元个数,而 [...x].length 用字符串迭代器逐个 码点走。两者在遇到补充平面字符时不同:'😀'(U+1F600)在 UTF-16 里是一对代理 D83D DE00,算 2 个码元、1 个码点。所以本示例的输入是 73 码元 / 72 码点,差的正是那个表情。emoji 与汉字都不受 toLowerCase() 影响——它们在 Unicode 大小写表里没有映射,原样返回。
第五条:toLocaleLowerCase() 的参数决定用哪套区域规则。'HELLO'.toLocaleLowerCase('tr') 仍是 hello(E 之类不受土耳其语规则影响),说明区域设置只影响那几个特例字母,不是整体换表。但要注意 'I' 在英语里是常用字母,'i' 也是;而土耳其语里 i 才是常用字母、ı 是罕见字母——所以土耳其语用户输入的 I 必须转成 ı 才对得上,这正是要切 tr 的原因。az(阿塞拜疆语)行为与土耳其语一致,实测 'I'.toLocaleLowerCase('az') 同样得 ı。
NFC 归一化为什么放在转换之前。Unicode 的同一字符有两种表示:预组合的 é(U+00E9,单码点)与分解的 e + U+0301(组合尖音符,两码点)。两者在屏幕上完全一样,但码点数不同、字节数不同、字符串比较结果也不同。做小写归一前先 normalize('NFC'),可以避免「看起来一样的两份文本转完还是不一样」。本页默认勾选这一项;本示例文本本身已是 NFC 形式,勾不勾结果相同,但对你从 Word 或 macOS 复制的文本通常有影响。
完整示例(数值均为实测)
输入框里预置的 5 行示例是:
| 行 | 输入 | 默认模式输出 | tr 模式输出 |
|---|---|---|---|
| 1 | ORDER #A-1024 | order #a-1024 | order #a-1024 |
| 2 | İstanbul ısparta | i̇stanbul ısparta | istanbul ısparta |
| 3 | ΟΔΟΣ ΣΟΦΟΣ | οδος σοφος | οδος σοφος |
| 4 | Straße ẞ 中文原样 | straße ß 中文原样 | straße ß 中文原样 |
| 5 | 😀 MIXED Case 123 | 😀 mixed case 123 | 😀 mıxed case 123 |
五种口径的实测数字:输入 73 码元 / 72 码点;默认模式输出 74 码元 / 73 码点(码点数 +1);tr 模式输出 73 码元 / 72 码点(不变)。5 行全部发生变化(第 4 行只改了 ẞ → ß 一处)。输入含 13 个 ASCII 大写字母、4 个汉字;24 个码点发生了大小写映射,48 个码点没有任何映射因而原样保留。
三行值得逐位看:
- 第 2 行:
İ在默认模式下变成i加组合尖音符(码位U+0069 U+0307),这是码点数 +1 的来源;切到tr后İ转成i,码位数守恒。注意ısparta里的无点ı(U+0131)两种模式都不变——它本来就是小写。 - 第 3 行:
ΟΔΟΣ ΣΟΦΟΣ转成οδος σοφος,码位序列是U+03BF U+03B4 U+03BF U+03C2空格U+03C3 U+03BF U+03C6 U+03BF U+03C2。两个词末尾的Σ都变成了ς(U+03C2),而第二个词开头的Σ变成σ(U+03C3)——同一个字符,因为词中位置不同而结果不同。 - 第 5 行:
😀前后完全不变(它没有大小写映射),MIXED与Case正常转小写;tr模式下MIXED → mıxed——把I转成了无点的ı。这一行最能说明为什么英语内容不能切tr。
最后看 ß 的对照:'Straße ẞ' 在本工具(转小写)下是 'straße ß',长度不变;只有在转大写时 'ß' 才会展开成 'SS'(1 码点变 2 码点)。所以拿本页结果去和「字母转大写」页的结果对照看,能直观体会到两个方向的长度行为不对称。
十二组码位映射与输入范围对照
下表列出最值得注意的映射,「码点数变化」列是重点——非 −1 的行说明转换后长度与字符数不再相等:
| 输入 | 码位 | toLowerCase 输出 | 输出码位 | 码点数变化 | 说明 |
|---|---|---|---|---|---|
| I | U+0049 | i | U+0069 | 0 | 默认映射;tr 下改成 ı |
| ı | U+0131 | ı | U+0131 | 0 | 无点小写 i,两种模式都不变 |
| İ | U+0130 | i̇ | U+0069 U+0307 | +1 | 1 码点变 2 码点,唯一会变长的常见情况 |
| I(tr) | U+0049 | ı | U+0131 | 0 | toLocaleLowerCase('tr') 的结果 |
| ß | U+00DF | ß | U+00DF | 0 | 转小写不变;转大写才变 SS |
| ẞ | U+1E9E | ß | U+00DF | 0 | 大写形态正确回到 ß |
| Σ | U+03A3 | σ | U+03C3 | 0 | 单独出现按词首处理 |
| ΟΔΟΣ | — | οδος | …U+03C2 | 0 | 词尾 Σ → 最终 sigma ς |
| ΣΑ | — | σα | U+03C3… | 0 | 词首 Σ → 普通 sigma σ |
| Dž | U+01C5 | dž | U+01C6 | 0 | 标题大小写字母转小写斜体 |
| K | U+212A | k | U+006B | 0 | 开尔文符号 K(U+212A),是独立符号码位,同样映射到小写 k |
| 😀 中文 | — | 😀 中文 | — | 0 | 无大小写映射,原样保留 |
输入范围与边界:上限 20 万 UTF-16 码元(输入框的 maxlength,按码元计)。换成实际能装多少:纯 ASCII 可装 20 万字符,纯汉字约 10 万个(每个汉字 1 码元、UTF-8 3 字节),纯 emoji 只能装 10 万个(每个 2 码元)。按 80 字符一行算,ASCII 文本约 2500 行。20 万码元做一次 toLowerCase 是毫秒级操作。
边界、精度与常见误用
- 码点数可能增加,不会减少。Unicode 的大小写映射里,把 1 个码点变成多个码点的情况确实存在(实测
İ→U+0069 U+0307,+1)。反向不会出现——没有任何小写形式会映射成 2 个码点。所以输出长度 ≥ 输入长度(按码点算),这一点在做「截断到 N 字符」的业务逻辑时要记住:转换后再截断,和先截断再转换,结果长度不一样。 - 「已是无小写」的输入仍然会原样输出,不报错。输入
hello得hello,码点变化 0,映射数 0。这是正确行为不是故障——toLowerCase()是幂等函数(f(f(x)) = f(x)),对已经全小的文本再转一次结果不变。页面上「已是无小写,保持不变」这项显示的就是无映射的码点数。 - 空文本与纯空白是合法输入。
''.toLowerCase()长度 0,' '.toLowerCase()长度 3(空白没有大小写映射,原样保留)。本页不会因为空输入报错——这是文本转换工具,不是数值计算器,没有「除零」之类的失败模式。 - 唯一会报错的路径是超上限。超过 20 万码元时页面给出具体提示(说明是页面保护而非算法限制),且不会静默截断已有内容。
TextEncoder在极旧浏览器上不存在,但本页主路径只用toLowerCase与normalize,两个都是 ES5 / ES6 标准方法,兼容性很好。 - 增补平面字符在多数语言里没有大小写映射。数学字母数字区(U+1D400 起,比如「𝔘」)实测不发生变化——这些字符在 Unicode 里没有登记大小写关系。这一点常被误解为「字体里的字母应该能转换」:字体只是把普通字母画成了花体,码位仍是普通字母;而真正使用数学字母区码位的文本才不转换。emoji 同理。
normalize('NFC')会改变码点数,所以它必须排在转换之前。NFC 把分解形式合并成预组合形式(如e+U+0301 → U+00E9),码点数减少;反过来 NFD 会增加。本页的顺序是先 NFC 归一、再转小写,这样输出是规范形式。反过来做(小写后再 NFC)也能得到同样结果,但先做归一化对输入更友好——能同时处理「分解形式的 İ」这种更麻烦的情况。- 全角字母会转成半角小写。
A(U+FF21 全角大写 A)实测转成a(U+FF41 仍是全角)——全角不会变半角,因为 Unicode 把全角字母登记为独立码位并各自定义了小写映射。要全角转半角必须用「全角半角转换」,本页不做这件事。 - 只影响大小写,不影响其它任何字符。标点、数字、空格、emoji、汉字全部原样保留。想同时做去空格、删标点这类清洗,请用「删除多余空格」或「删除标点工具」,把它们和大小写转换混在一起会让归一化结果难以追溯。
常见问题
为什么转小写之后字符串反而变长了?
因为 Unicode 的映射表里存在「1 个码点 → 2 个码点」的情况。实测最常见的就是土耳其语的 İ(U+0130,带点大写 I):它的小写是 i 加组合尖音符(U+0307),显示成 i̇,因为 Unicode 里没有「带尖音符的小写 i」这个预组合字符。本页示例文本在默认模式下码点数从 72 增到 73,正是这一个字符造成的。切到 tr 模式就不会变长,因为那时 İ 直接映射到单码点的 i。
ß 转小写为什么不变成 SS?
因为 ß(U+00DF)本来就是小写字母,toLowerCase() 查表发现无需映射,原样返回。展开成 SS 的是反方向:toUpperCase() 会把 ß 扩成两个大写 S(2017 年前的 Unicode 甚至直接返回 SS 字符串)。这造成一个实际坑:'STRASSE' 转小写得 'strasse','straße' 转小写得 'straße',两者不相等——拿小写形式做去重键时,德语文本会漏掉一部分重复。要彻底归一得先把 ß 和 ẞ 统一替换成 ss。
希腊语里 Σ 转出来有时是 σ 有时是 ς,对吗?
对的,这是 Unicode SpecialCasing.txt 里的 Final_Sigma 条件规则:Σ 只在词尾转成最终 sigma ς(U+03C2),在词首或词中转成 σ(U+03C3)。实测 'ΟΔΟΣ' → 'οδος'(末尾是 ς),'ΣΑ' → 'σα'(开头是 σ),'Σ' 单独出现 → 'σ'(没有前文,按词首算)。所以转换结果依赖字符在词中的位置,这是 toLowerCase 唯一的上下文相关行为。希腊语 locale 档位不改变这条规则,它本来就是默认行为。
切到 tr 档位后 MIXED 变成了 mıxed,这是错了吗?
不是错,是土耳其语的正确规则。土耳其语把 i 当常用字母、ı(无点小写)当罕见字母,所以 toLocaleLowerCase('tr') 必须把 I 转成 ı 才能和用户输入的词对上。代价是英语内容会被改坏——本例第 5 行 MIXED 在 tr 下变成 mıxed,拿去和英语数据库比对就匹配不上了。所以只在处理土耳其语/阿塞拜疆语文本时切 tr,其它语言一律用默认档。az(阿塞拜疆语)行为与土耳其语一致。
为什么 x.length 和 [...x].length 差 1?
因为 JavaScript 字符串内部是 UTF-16,x.length 数的是码元,而 [...x] 用字符串迭代器逐个码点走。两者只在遇到补充平面字符(码位 > U+FFFF)时才不同。本页示例里有个 😀(U+1F600),它在 UTF-16 里是一对代理项 D83D DE00,所以算 2 个码元、1 个码点,输入的 73 码元对应 72 码点。汉字与 BMP 内的常用符号两者一致,所以纯中文文本里不会看到差异。统计「有几个字」时建议用码点口径。
全角字母会被转成半角吗?
不会。实测 A(U+FF21 全角大写 A)转成 a(U+FF41),仍是全角。Unicode 把全角字母登记为独立码位并各自定义了小写映射,所以「全角 A」的小写就是「全角 a」。要真正做全角转半角(涉及 U+FF01~U+FF5E 这一整段与 ASCII 的对应关系),请用「全角半角转换」工具。视觉上容易混淆,但码位上这是两回事。
转小写能用来做 URL 吗?有什么要注意的?
能,但本页只解决大小写这一件事,做 slug 还缺三步:空格要换成连字符、标点要删或转义、非 ASCII 要音译或直接丢弃。所以 /Product Name 转小写只是 /product name,仍然不是合法 URL。完整流程请用「URL别名生成器」或「Slugify字符串工具」。另外注意服务器差异:Linux 上的 Nginx 与 Apache默认区分大小写,改成小写路径后所有旧的大写链接会 404,必须同步做 301 跳转;Windows 与 macOS 的默认文件系统不区分,但生产环境的前置 CDN 往往区分。
我输入的文本会被上传或保存吗?
不会。转换完全由页面内的 toLowerCase() 与 normalize() 完成,没有任何 fetch 或 XMLHttpRequest 调用,也不写 localStorage;刷新页面后输入框回到那段 5 行预置示例。想自己确认,打开开发者工具的 Network 面板,输入文字时观察是否有请求发出——不会有。20 万码元的上限也纯粹是页面保护,与算法能力无关。