从 Lodash `upperCase` 迁移到 Remeda:`toUpperCase(toTitleCase())` 组合方案与源码级解析
从 Lodash upperCase 迁移到 Remeda:toUpperCase(toTitleCase()) 组合方案与源码级解析
本文讲解如何把 Lodash 的 _.upperCase(input) 迁移到 Remeda 中对应的组合写法 toUpperCase(toTitleCase(input))。你会掌握两者在单词切分、输入归一化、空值处理三方面的核心差异,理解 toUpperCase 与 toTitleCase 各自的源码实现与类型推断能力,并拿到可直接落地的"归一化版""空输入兜底版"两套完整替代代码。本文以仓库迁移映射文档 upperCase.md 为主体,结合 toUpperCase.ts、toTitleCase.ts 及内部 words.ts 的实现展开。
这份文档在仓库中的位置与用途
在 Remeda 文档站的 packages/docs/src/content/mapping/ 目录下,存放着一整套"从其他库迁移到 Remeda"的对照说明,按库分为 lodash/、ramda/、just/ 三组,每组内一个 Markdown 文件对应一个外部库函数。每个文件的 frontmatter 声明了 category(分类)与 remeda(对应 Remeda 函数的引用),例如本文件的 frontmatter 为:
category: String
remeda: toTitleCase
即 upperCase 属于 String 类目,对应 Remeda 的 toTitleCase。这些映射内容由 content.config.ts 通过 Astro 的 glob loader 加载(以文件名作为函数名生成 ID),再由迁移页面 [library].astro](packages/docs/src/pages/migrate/[library].astro) 按分类渲染成卡片,卡片头部会链接回 Lodash 官方文档对应锚点(URL 模板见 [urls.ts),正文则展示替换方案。也就是说,本文讨论的迁移方案正是这套映射体系中 Lodash upperCase 条目的完整内容。
核心结论:Lodash 没有一对一映射,需要组合两个函数
Lodash 的 _.upperCase(input) 会先把字符串拆成单词、再全部转大写并以下划线(_)分隔,例如 _.upperCase("fooBar") 得到 "FOO_BAR"。Remeda 并没有直接等价的 upperCase,官方推荐的替代是组合 toTitleCase 与 toUpperCase:
toTitleCase(input)先把字符串按 Remeda 的规则拆分为空格分隔的单词并首字母大写,例如"fooBar"→"Foo Bar";toUpperCase(...)再把结果整体转成大写,"Foo Bar"→"FOO BAR"。
因此 Lodash 的 "FOO_BAR"(下划线分隔)与 Remeda 组合结果的 "FOO BAR"(空格分隔)在分隔符上并不完全一致,这一点在替换时需要注意。原文档用三个要点概括了两者的全部关键差异,下面逐一结合源码展开。
差异一:单词切分策略——伪语言切分 vs 简单规则切分
Lodash 使用伪语言学的单词切分(pseudo-linguistic word splitting)来处理特殊字符,可能产生不准确的结果。Remeda 则采用基于 type-fest 的 Words 类型定义的更简单切分方式,只适合标识符(identifiers)和内部键(internal keys)这类简单字符串。
从源码看,Remeda 的切分由内部函数 words 实现(见 words.ts),它逐字符扫描并维护一个当前单词缓冲区,只在三类边界处切分:
- 小写转大写:如
"helloWorld",在world的W处切分(第 60 行规则/[a-z]$/u.test(word) && /[A-Z]/u.test(character)); - 连续大写后接小写:如
"HELLOWorld",当单词末尾是两个及以上大写字母、下一个字符是小写时,把最后一个大写字母归入新单词再切分(第 67 行规则),这样单个大写字母(如"Dog")不会被视为独立单词; - 数字与字符边界:数字与字母互转时切分,如
"abc123"、"123abc"(第 74 行规则)。
分隔符则只有 -、_ 以及一整套 Unicode 空白字符(见第 4–34 行的 WHITESPACE 常量与 WORD_SEPARATORS 集合),分隔符本身不进入单词。
结论:Remeda 的方案是可预测的、确定性的规则切分,能覆盖 camelCase、PascalCase、kebab-case、snake_case 等编程命名场景,但它不试图理解语言。若涉及语言与区域(locale)细节的语言学处理,原文档明确建议改用内置的
Intl.Segmenter等能力。
差异二:输入归一化——Lodash 会 deburr 和去撇号,Remeda 不会
Lodash 在切分前会对输入做归一化处理,包括 deburr(去除变音符)以及移除撇号。Remeda 的单词切分更简单,不包含这些归一化步骤,因此如果需要等价行为,就必须在调用前手动处理输入。
仓库中同样有一份针对 Lodash deburr 的映射文档 deburr.md,它明确指出 Remeda 不提供 deburr,并列举了 Lodash deburr 硬编码启发式的缺陷(漏掉部分重音字符如 ḩ、ố、ῷ、й;错误"简化"本无变音符的连字如 Æ、Œ;把语义无关的相似字符互换如 Ð、Ø、Þ)。替代方案是使用 normalize("NFD") 分解字符后,再通过正则移除组合变音符号。
结论:迁移
upperCase时,若输入可能包含重音字符或撇号,需要在调用toTitleCase之前自行归一化(具体写法见下文"Normalized"一节);若只是处理 ASCII 标识符与键名,则无需任何预处理。
差异三:空输入/undefined 支持——Lodash 宽松,Remeda 严格
Lodash 允许无参调用 _.upperCase() 或传入 undefined;Remeda 的 toUpperCase 与 toTitleCase 均要求传入字符串参数,不支持空调用。因此迁移时必须先处理这些情况(完整写法见下文"Missing input"一节)。
Basic usage:基础替换写法
原文档给出的最小替换代码如下:
// Lodash
_.upperCase(input);
// Remeda
toUpperCase(toTitleCase(input));
这个组合的每一步都有明确的运行时行为:
toTitleCase(input)先切词再Capitalize每个单词并join(" ")(见 toTitleCase.ts 的实现);toUpperCase(...)等价于String.prototype.toUpperCase,并把每个单词(含首字母)全部转为大写。
toTitleCase 对大小写混合输入还内置了一个细节:当输入完全不含小写字母时,会先整体 toLowerCase() 再切分(toTitleCase.ts 中的 LOWER_CASE_CHARACTER_RE.test(data) ? data : data.toLowerCase()),这正是 "FOO_BAR"(SCREAMING 风格)也能被正确切词的原因。该行为与 Lodash _.upperCase("FOO_BAR") 的输出思路一致,可通过 toTitleCase.test.ts 中的 sCREAMING_SNAKE_CASE 用例验证。
两个函数都支持 data-first 与 data-last 两种调用形态
toUpperCase 与 toTitleCase 都通过 purry.ts 实现了"数据优先(data-first)"与"数据最后(data-last)"双签名,因此既可以直接调用,也可以放进 pipe 链:
import { pipe, toTitleCase, toUpperCase } from "remeda";
// data-first(上文基础写法)
toUpperCase(toTitleCase("helloWorld")); // "HELLO WORLD"
// data-last:pipe 链式风格
pipe("helloWorld", toTitleCase(), toUpperCase()); // "HELLO WORLD"
两种形态由 toUpperCase.ts 中的函数重载声明(@dataFirst / @dataLast)与底部的 purry(toUpperCaseImplementation, args) 分发逻辑(第 52–54 行)共同支撑;toTitleCase 则通过判断首个参数是否为字符串来区分两种形态(toTitleCase.ts)。
类型层面:Uppercase<T> 字面量类型推断
toUpperCase 的返回类型并不是简单的 string,而是 Uppercase<T>:
export function toUpperCase<T extends string>(data: T): Uppercase<T>;
这意味着传入字符串字面量时,返回类型会被精确推断为对应的大写字面量类型。类型测试文件 toUpperCase.test-d.ts 验证了这一点:
toUpperCase("hello world")推断为"HELLO WORLD";toUpperCase("prefix_123" as \prefix_{number}\`)` 推断为 `` `PREFIX_{Uppercase<${number}>}` ``;- 非字面量(
as string)输入则退化为Uppercase<string>。
同样地,toTitleCase 的类型层也基于 type-fest 的 Words、Join、Capitalize 等类型构造(toTitleCase.ts),对字面量输入可精确到 "Hello World" 级别的结果类型(见 toTitleCase.test-d.ts)。也就是说,这套组合在编译期就能对大小写转换结果进行强类型约束,而不只是运行时正确。
Normalized:带归一化的完整替换
当输入可能含有重音字符或撇号、且希望行为接近 Lodash 时,原文档给出的完整写法是:
// Lodash
_.upperCase(input);
// Remeda + Native
toUpperCase(
toTitleCase(
input
// "Promote" diacritics to be independent characters in the string.
.normalize("NFD")
// Remove apostrophes and all independent diacritic characters.
.replace(/['\u2019\u0300-\u036f]/g, ""),
),
);
两步归一化的作用拆解如下:
normalize("NFD"):把带组合变音符的字符(如é)分解为"基础字母 + 独立组合变音符"(e+́),使变音符变成可以被正则匹配掉的独立字符;replace(/['\u2019\u0300-\u036f]/g, ""):删除 ASCII 撇号(')、弯引号撇号(’,即\u2019)以及\u0300-\u036f整个"Combining Diacritical Marks"(组合变音符号)Unicode 区块。
注意 \u0300-\u036f 这个区间与 deburr.md 中推荐的简化方案使用的区间一致——该文档明确说明这种 NFD + replace 方法比 Lodash 的 deburr 更准确,但并非 deburr 的完全等价替代(因为 Lodash 还会处理撇号、连字等问题,且存在上述硬编码启发式缺陷)。
需要强调的是:Remeda 的 toUpperCase 本身对 Unicode 有很好的运行时支持。根据 toUpperCase.test.ts 中的测试用例,它可以直接处理:
- 带变音符的文本:
"café naïve"→"CAFÉ NAÏVE"(保持原有重音); - 非拉丁文字:
"москва"→"МОСКВА"、"ελλάδα"→"ΕΛΛΆΔΑ"; - emoji:
"🎉party"→"🎉PARTY"; - 代理对(astral plane):
"𝒽ello"→"𝒽ELLO"; - 组合字符与德语 ß:
"straße"→"STRASSE"。
因此归一化只在"希望去掉重音、与 Lodash 行为对齐"时才是必要的,单纯做大小写转换无需任何预处理。
Missing input:空输入与 undefined 的兜底
由于 Remeda 不支持 _.upperCase() 的无参调用或 undefined 输入,原文档给出了两种兜底策略:
// Lodash
_.upperCase();
_.upperCase(input);
// Remeda 方案一:三目表达式
("");
input !== undefined ? toUpperCase(toTitleCase(input)) : "";
// 方案二:nullish 合并
toUpperCase(toTitleCase(input ?? ""));
两种方案都保证:当 input 为 undefined 时输出空字符串 ""。方案一语义更显式,方案二更简洁。若业务上要求空输入返回其他默认值,将 "" 换成对应默认值即可。另外,toTitleCase("") 与 toUpperCase("") 对空字符串的处理也已由测试覆盖:空串输入得到空串输出(见 toTitleCase.test.ts 与 toUpperCase.test.ts 的 data-first 用例)。
进一步控制切分行为:preserveConsecutiveUppercase 选项
toTitleCase 还暴露了一个可选配置项 preserveConsecutiveUppercase,默认值为 true(定义于 toTitleCase.ts)。它决定连续大写字母在切词后的去留方式:
true(默认):"XMLHttpRequest"→"XML Http Request"(保留XML的大写形式);false:"XMLHttpRequest"→"Xml Http Request"(只保留每个单词首字母大写)。
实现上,该选项控制的是 Capitalize 时对单词剩余部分的处理:为 true 时保留原样,为 false 时 toLowerCase()(toTitleCase.ts)。类型层同样通过 OptionalOptionsWithDefaults 关联了默认值类型,保证类型与运行时一致(第 18–26 行)。
这一点与 Lodash 迁移直接相关:Lodash 的 _.camelCase 等转换对连续大写有特定处理,因此同一映射体系中的 camelCase.md 明确建议在 toCamelCase 时使用 { preserveConsecutiveUppercase: false } 来对齐 Lodash 结果。虽然 upperCase 最终全部转大写、连续大写的影响会被抹平,但在理解 toTitleCase 中间产物(如 "Foo Bar" vs "Foo BAR")时,这一选项仍是排查迁移差异的关键。相关行为均有测试覆盖,见 toTitleCase.test.ts 的 preserveConsecutiveUppercase option 小节。
何时应该放弃这套方案,改用原生能力
原文档与 toUpperCase/toTitleCase 的源码注释(toUpperCase.ts、toTitleCase.ts)都明确给出了适用边界:
- Remeda 方案适用:标识符、内部键、枚举值等简单字符串的规范化;编程命名风格(camelCase / snake_case / kebab-case / PascalCase)之间的转换。
- 应改用原生能力:涉及语言与区域差异的语言学文本处理。推荐组合为
Intl.Segmenter(granularity: "word")负责按语言规则切词,配合toLocaleUpperCase/toLocaleLowerCase负责大小写转换——它们才是为语言环境差异而生的 API。 - 关于大小写转换的精度:
toUpperCase本质是对String.prototype.toUpperCase的封装,因此共享其已知的 locale 不准确性;需要按 locale 精确转换时应改用toLocaleUpperCase,纯展示场景则建议直接用 CSS 的text-transform: uppercase(它是 locale-aware 的,且不改变字符串本身)。
迁移检查清单
把 Lodash 的 _.upperCase(input) 完整迁移到 Remeda,可按以下清单核对:
- 确认
input一定非空、非undefined;否则用input ?? ""或三目表达式兜底,且明确空输入期望的返回内容; - 若输入含重音字符或撇号且要求与 Lodash 行为对齐,先做
normalize("NFD")+replace(/['\u2019\u0300-\u036f]/g, "")归一化; - 用
toUpperCase(toTitleCase(input))替换,data-first 与pipe两种风格任选; - 接受输出用空格(
"FOO BAR")而非下划线("FOO_BAR")分隔单词的差异; - 若输入包含
XMLHttpRequest这类连续大写片段且对中间结果敏感,按需显式传{ preserveConsecutiveUppercase: true | false }; - 涉及自然语言文本时,放弃本方案,改用
Intl.Segmenter+toLocaleUpperCase。
完成以上步骤后,即可在获得强类型字面量推断(Uppercase<T>)与统一 data-first/data-last API 的同时,保持与原 Lodash 行为的可控对齐。