freeCodeCamp 每日编程挑战解析:用 JavaScript 实现 Title Case「Capitalize It」
导读
本篇围绕 freeCodeCamp 开源课程中 daily-coding-challenges-javascript(每日编程挑战·JavaScript)区块的第 126 题 Challenge 126: Capitalize It(challenge 126 原始文档)展开。这道题要求实现一个 titleCase(title) 函数,把任意输入字符串按「每个单词首字母大写、其余字母小写」的标题大小写规则格式化。读完本文,你将掌握该题目的完整约束、四个自动化断言的含义、从种子代码到通过的逐步实现思路、多种可替换写法与边界情况处理,并能理解这类挑战文件(Markdown + Front-matter + --description--/--hints--/--seed--/--solutions--)在 freeCodeCamp 课程体系中是如何被解析、构建与判定的。
一、题目速览:题目在仓库中的位置与文件结构
原始挑战文件位于:
文件以 YAML Front-matter 开头,声明了该挑战的元数据:
---
id: 691b559495c5cb5a37b9b486
title: "Challenge 126: Capitalize It"
challengeType: 28
dashedName: challenge-126
---
各字段含义如下:
| 字段 | 值 | 含义 |
|---|---|---|
id |
691b559495c5cb5a37b9b486 |
全仓库唯一的挑战 ID,同一 ID 也登记在区块清单 daily-coding-challenges-javascript.json 的 challengeOrder 中(该文件中对应的条目是 Challenge 126: Capitalize It) |
title |
Challenge 126: Capitalize It |
显示标题,按「每日挑战序号 + 题名」命名 |
challengeType |
28 |
挑战类型编号 |
dashedName |
challenge-126 |
用于 URL / 文件命名的短横线形式 |
其中 challengeType: 28 值得专门说明。在 packages/shared/src/config/challenge-types.ts 中,类型常量被集中定义:
const dailyChallengeJs = 28;
const dailyChallengePy = 29;
也就是说,28 对应的是 JavaScript 每日编程挑战(dailyChallengeJs),29 则对应 Python 版本。同文件还给出了该类型的两条关键映射:
// 挑战编辑器视图类型:classic(经典代码挑战)
[dailyChallengeJs]: 'classic',
// 提交方式:tests(由测试用例自动判定)
[dailyChallengeJs]: 'tests',
这解释了为什么本文这道题使用「代码 + 断言」的形式:它属于 classic 视图,代码运行后由平台执行 --hints-- 中的断言进行判定。
二、需求拆解:三道 Title Case 规则
原始文档的 --description-- 部分是本题的完整需求描述,原文如下:
Given a string
title, return a new string formatted in title case using the following rules:
- Capitalize the first letter of each word.
- Make all other letters in each word lowercase.
- Words are always separated by a single space.
翻译并拆解为三条明确规则:
- 每个单词的首字母大写:无论输入中该词是
hello、Hello还是HELLO,输出时首字母一律为大写字母H。 - 单词内其余字母全部小写:例如
JAVASCRIPT输出时应为Javascript,而不是保持全大写。 - 单词之间始终以单个空格分隔:输入本身保证单词之间只有一个空格。这意味着实现时无需考虑
" "(连续空格)、制表符、换行等分隔符的鲁棒性,可以直接放心地以" "作为唯一分隔依据——这是一个重要的前提约定,直接影响了下面「用split(" ")」这一做法是否安全。
这三个条件合在一起,定义了标准的英文「标题大小写」(Title Case)语义:每个词只保留一个首字母大写,其余一律小写。
三、测试断言:四个输入输出的含义
文档的 --hints-- 区块给出了四个自动化断言,它们以 assert.equal(...) 的形式写在 JS 代码块中,由课程判定器在用户代码运行后执行。逐个分析:
assert.equal(titleCase("hello world"), "Hello World");
覆盖「全小写输入」:hello world 中每个单词都全小写,只需把每个词的首字母大写。这是最基础的一档。
assert.equal(titleCase("the quick brown fox"), "The Quick Brown Fox");
覆盖「多个单词、全小写输入」:四个单词逐一处理,验证对单词数量没有写死、循环处理正确。
assert.equal(titleCase("JAVASCRIPT AND PYTHON"), "Javascript And Python");
覆盖「全大写输入」:这是最容易出错的一档——只把首字母大写是不够的,其余字母必须被主动转小写,否则 JAVASCRIPT 会错误地原样保留。
assert.equal(titleCase("AvOcAdO tOAst fOr brEAkfAst"), "Avocado Toast For Breakfast");
覆盖「大小写混杂输入」:AvOcAdO 这类词内部大小写完全随机,输出必须被"归一化"为 Avocado。此断言检验的是规则 2(其余字母全部小写)与规则 1 的组合效果,是本题含金量最高的用例。
四个用例的语义可以汇总成一张表:
| 输入 | 期望输出 | 主要验证点 |
|---|---|---|
"hello world" |
"Hello World" |
基本首字母大写 |
"the quick brown fox" |
"The Quick Brown Fox" |
多单词逐个处理 |
"JAVASCRIPT AND PYTHON" |
"Javascript And Python" |
全大写需转小写 |
"AvOcAdO tOAst fOr brEAkfAst" |
"Avocado Toast For Breakfast" |
混杂大小写归一化 |
顺带一提,把 titleCase 用在第四档输入上、由它返回完全规范化的 "Avocado Toast For Breakfast",本身就是对大小写归一化能力的一次完整检验,可作为你本地自测时最关键的回归用例。
四、种子代码:为什么它「通过不了测试」
文档的 --seed-- → --seed-contents-- 提供了初始代码:
function titleCase(title) {
return title;
}
这是一个最小骨架:函数已声明、参数已就位、返回值类型正确(字符串),但它原样返回输入,不做任何转换。对照测试:
titleCase("hello world")返回"hello world",而期望是"Hello World",断言失败。
种子代码的作用是让学习者明确「函数签名已经定好、不要改动,只需填充函数体」,这与 freeCodeCamp 经典代码挑战的通用模式一致:学习者面对的是半成品函数,需要补齐逻辑让 --hints-- 中的断言全部通过。
五、逐步实现:Split → Map → Join 三段式解法
5.1 算法设计:把「整串处理」降维成「逐词处理」
观察三规则可以发现,规则 1 和规则 2 都是针对单个单词内部的变换,只有规则 3 描述了单词之间的分隔关系。因此自然的设计思路是:
- 用
split(" ")依据单个空格把字符串切成单词数组(规则 3 保证分隔符唯一,切割是确定的); - 对每一个单词执行同一套大小写变换:首字母大写 + 其余小写;
- 用
join(" ")把处理完的单词按单个空格重新拼回字符串(保持分隔符不变)。
这样就把「对整个字符串做大小写变换」这个较难一次到位的问题,降维成「对每个单词做统一变换」,逻辑单一、易于验证。
5.2 参考实现与逐行讲解
文档 --solutions-- 区块给出的参考实现如下:
function titleCase(title) {
return title
.split(" ")
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
逐段解读这条链式调用:
title.split(" ")—— 以单个空格切分。结合规则 3,这里不传正则而是传普通字符串" ",是安全且可读的写法;同时切分发生在.map之前,先产生了数组中间结果。.map(word => ...)—— 对每个词执行回调,返回新数组。不修改原字符串或原数组,体现函数式、无副作用风格(也正因为split/map/join都返回新值,链式写法才能成立)。word.charAt(0).toUpperCase()—— 取首字符并转大写。这里选用charAt(0)而非word[0]的原因在 5.3 详述;当词首是字母(数字、标点如"1st"、"'")时,toUpperCase()对该字符是幂等操作,因此这条写法对「首字符不是字母」的边界同样安全。word.slice(1).toLowerCase()—— 取第 2 个字符起的所有剩余字符并整体转小写。若单词只有 1 个字符,slice(1)返回空字符串"",拼接结果恰好是「单字母小写后的本身」,符合规则 2。- 最后用
"+"把「大写首字符」与「小写剩余部分」拼成完整单词,经.join(" ")还原为标题串。
对整个算法做复杂度分析:split、map、join 各遍历一次字符/单词,总时间复杂度为 O(n)(n 为字符串长度),且只需常数级额外空间,属于对该问题的最优线性解。
5.3 为什么用 charAt(0) 而不是 word[0]
word[0] 在 ES5+ 中也能取到首字符,但存在一个历史遗留缺陷:当索引位置不存在时,word[0] 返回 undefined,而 undefined.toUpperCase() 会抛出 TypeError。对本文场景,若 title 是空字符串,"".split(" ") 得到 [""],map 回调中的 ""[0] 即为 undefined,直接崩溃;而 "".charAt(0) 返回 "","".toUpperCase() 为 "","".slice(1) 为 "",拼接后输出 "",优雅地处理了空输入。因此在面向「边界用例可能出现在隐藏断言中」的课程环境里,参考实现特意选用 charAt(0) 是更稳健的选择。
5.4 可以替换的等价写法
以下写法在语义上等价,可作为学习对照(均为我们基于题面推导的替代实现,不是仓库原文档内容):
// 写法 A:用 String.prototype.slice 取首字符
function titleCaseA(title) {
return title
.split(" ")
.map(word => word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
// 写法 B:用正则一次性匹配并替换每个单词
function titleCaseB(title) {
return title.replace(/\b\w+\b/g, word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
);
}
// 写法 C:逐词遍历 + 累积
function titleCaseC(title) {
const words = title.split(" ");
const result = [];
for (const word of words) {
result.push(word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
}
return result.join(" ");
}
几点取舍说明:
- 写法 B 的
\b单词边界基于正则\w(字母数字下划线),且对输入中连续多个空格也能容错——但本题已保证单词间单空格,所以该优势用不上,反而正则可读性略差; - 写法 C 用显式循环与数组累积,更贴近命令式风格,便于初学者打断点调试;
- 三种写法都会把
"JAVASCRIPT AND PYTHON"正确转为"Javascript And Python",因为关键动作都是「首大写 + 余小写」。
提交前建议在自己的 Node.js 环境(或浏览器开发者工具控制台)里把四个断言原样跑一遍:
console.log(titleCase("hello world") === "Hello World"); // true
console.log(titleCase("the quick brown fox") === "The Quick Brown Fox"); // true
console.log(titleCase("JAVASCRIPT AND PYTHON") === "Javascript And Python"); // true
console.log(titleCase("AvOcAdO tOAst fOr brEAkfAst") === "Avocado Toast For Breakfast"); // true
全部输出 true,即可确认与判定器的期望一致。
六、常见误区与边界情况清单
把本题的易错点整理如下,可作为自测对照表:
| 场景 | 示例 | 常见错误 | 正确结果 | 对策 |
|---|---|---|---|---|
| 全大写输入 | "JAVASCRIPT" |
只做大写首字母,输出 "JAVASCRIPT" |
"Javascript" |
必须 slice(1).toLowerCase() |
| 混杂大小写 | "AvOcAdO" |
只把首字母转大写 | "Avocado" |
首大写之外,剩余统一小写 |
空字符串 "" |
titleCase("") |
word[0] 取到 undefined 抛错 |
"" |
使用 charAt(0) |
| 单字符单词 | "a b" |
slice(1) 误写为 slice(0) |
"A B" |
slice(1) 对单字符返回空串,拼接正确 |
| 含非首字母数字的词 | "3d model" |
误判首字符需要变换 | "3d Model" |
toUpperCase() 对非字母幂等,"3d" 仍保持小写 |
需要再次强调的是:因为规则 3 明确规定「单词之间始终只有单个空格」,本实现不需要处理 " hello world" 这类多空格输入,也不需要调用 trim()。如果脱离了这道题的约定去推广到通用标题转换工具,则要另行考虑分词与去空白策略——这是把「做对题」升级为「做好工具」时需要补充的健壮性设计,而不是本题的隐藏要求。
七、在课程体系中的位置:challengeType 28 背后的源码链路
本挑战并不仅仅是孤立的算法练习,它在 freeCodeCamp 仓库内有一条完整的源码链路,理解它有助于你摸清「一份 Markdown 是如何变成一道可判定题目的」。
7.1 区块登记与关卡排序
本题 ID 691b559495c5cb5a37b9b486 被登记在区块结构中:curriculum/structure/blocks/daily-coding-challenges-javascript.json。该 JSON 声明了区块的若干行为属性:
{
"isUpcomingChange": true,
"usesMultifileEditor": true,
"helpCategory": "JavaScript",
"disableLoopProtectTests": true,
"blockLayout": "legacy-challenge-list",
"challengeOrder": [ ... ]
}
其中:
isUpcomingChange: true表示该区块属于即将上线的课程。对应地,在课程工具链中,curriculum/src/config.ts 通过环境变量SHOW_UPCOMING_CHANGES(值为字符串'true'时生效)控制是否把这类区块纳入渲染范围;challengeOrder数组以 ID + 标题的形式维护挑战的先后顺序,本挑战是数组中的第 126 条(编号从 Challenge 1 起连续递增);- 相邻题目也可互为延伸参照:例如 Challenge 37「Sentence Capitalizer」与 Challenge 38「Slug Generator」同样涉及字符串大小写/文本转换,学完本题后可顺路练习。
7.2 挑战类型到构建器的映射
在构建侧,packages/challenge-builder/src/build.ts 维护了一张「挑战类型 → 构建函数」的分派表:
[challengeTypes.dailyChallengeJs]: buildJSChallenge,
challengeTypes.dailyChallengeJs 的值即 28,因此本文件被分派到 buildJSChallenge——与普通 js 类型共用同一套 JS 挑战构建逻辑。同时,在前端视图层,packages/shared/src/config/challenge-types.ts 将 28 映射为 'classic' 视图、'tests' 提交类型,与第六节前段所述的判定流程一一对应。
7.3 每日挑战的呈现入口
在客户端,每日挑战有专门的呈现组件,例如 client/src/components/daily-coding-challenge/widget.tsx 提供「今日挑战」入口,并跳转到 /learn/daily-coding-challenge/... 路由;client/src/utils/daily-coding-challenge-validator.ts 则给出了数据库侧每日挑战记录的结构校验(含 challengeNumber、title、description、按 javascript/python 分语言的 tests 与 challengeFiles)。这说明「Capitalize It」所属的 JS 每日挑战题源,在数据模型上最终会被组织为按日期/编号排布的、同时含 JS 与 Python 两个语言版本测试与骨架文件的记录——第 28 号类型正是这套体系中「JavaScript 一侧」的标识。
八、小结
Challenge 126「Capitalize It」是一道非常适合入门的字符串处理题:题面用三条规则把「标题大小写」定义得清晰无歧义,四个断言从「全小写、多单词、全大写、混杂大小写」四个角度覆盖了实现必须处理的全部输入形态。参考解法 split(" ") → map(首字符大写 + 余部小写) → join(" ") 结构简洁、线性复杂度、无副作用,并通过对 charAt(0) 的选用天然规避了空输入崩溃。
从这道题出发,你既可以把实现替换为正则或命令式循环来巩固 JavaScript 数组与字符串 API,也可以顺着 daily-coding-challenges-javascript.json 的 challengeOrder 继续刷同区块的其他题目;同时,理解 challengeType: 28 在 challenge-types.ts 与 build.ts 中的映射,也能让你对 freeCodeCamp「一份 Markdown 挑战文件如何被解析、构建并按测试自动判定」的完整流水线建立直观认识。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00