从 Lodash `upperCase` 迁移到 Remeda:`toUpperCase(toTitleCase())` 组合方案与源码级解析

原创2026-10-12 22:21:341,163 阅读
文章标签:开发工具

从 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),它逐字符扫描并维护一个当前单词缓冲区,只在三类边界处切分:

  1. 小写转大写:如 "helloWorld",在 world 的 W 处切分(第 60 行规则 /[a-z]$/u.test(word) && /[A-Z]/u.test(character));
  2. 连续大写后接小写:如 "HELLOWorld",当单词末尾是两个及以上大写字母、下一个字符是小写时,把最后一个大写字母归入新单词再切分(第 67 行规则),这样单个大写字母(如 "Dog")不会被视为独立单词;
  3. 数字与字符边界:数字与字母互转时切分,如 "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, ""),
  ),
);

两步归一化的作用拆解如下:

  1. normalize("NFD"):把带组合变音符的字符(如 é)分解为"基础字母 + 独立组合变音符"(e + ́),使变音符变成可以被正则匹配掉的独立字符;
  2. 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,可按以下清单核对:

  1. 确认 input 一定非空、非 undefined;否则用 input ?? "" 或三目表达式兜底,且明确空输入期望的返回内容;
  2. 若输入含重音字符或撇号且要求与 Lodash 行为对齐,先做 normalize("NFD") + replace(/['\u2019\u0300-\u036f]/g, "") 归一化;
  3. 用 toUpperCase(toTitleCase(input)) 替换,data-first 与 pipe 两种风格任选;
  4. 接受输出用空格("FOO BAR")而非下划线("FOO_BAR")分隔单词的差异;
  5. 若输入包含 XMLHttpRequest 这类连续大写片段且对中间结果敏感,按需显式传 { preserveConsecutiveUppercase: true | false };
  6. 涉及自然语言文本时,放弃本方案,改用 Intl.Segmenter + toLocaleUpperCase。

完成以上步骤后,即可在获得强类型字面量推断(Uppercase<T>)与统一 data-first/data-last API 的同时,保持与原 Lodash 行为的可控对齐。

登录后查看全文
remeda