freeCodeCamp 每日编程挑战解析:用 JavaScript 实现 Consonant Case 字符串转换(Challenge 163)
freeCodeCamp 开源仓库中的「每日编程挑战」(Daily Coding Challenges)为学习者提供了一年 365 道短小精悍的算法练习题。本文聚焦其中的 Challenge 163: Consonant Case,完整讲解题目规则、hints 测试断言、参考解法与多种实现思路,并基于仓库源码剖析这道题从课程 Markdown 到线上答题系统的完整流转链路(schema 校验 → GraphQL 抓取 → 数据库 seed → API 下发),让读者既能独立解出题目,也能理解它在 freeCodeCamp 技术栈中的真实位置。
题目速览:Challenge 163 的核心规则
题目位于仓库的 daily-coding-challenges-javascript 挑战块,challengeType: 28 属于每日编程挑战类型。题目要求实现函数 toConsonantCase(str),将传入的「变量名」字符串按以下三条规则转换:
- 所有辅音字母转为大写(consonant → uppercase);
- 所有元音字母(
a、e、i、o、u,不区分大小写)转为小写; - 所有连字符
-转为下划线_。
题目对除字母和连字符之外的其他字符(如 _、~、数字、符号)没有提出任何转换要求——它们应当原样保留,这正是该题容易被忽略的隐藏约束,也是官方参考解法中最值得学习的细节。
题目同时存在于 JavaScript 与 Python 两个语言版本的挑战块(见 dev-playground.json,其中并列定义了 daily-coding-challenges-javascript 与 daily-coding-challenges-python),两道同题号题目的标题与描述必须完全一致(在 helpers.ts 中有专门的校验逻辑)。
读懂 hints:四个测试断言的含义
题目以 # --hints-- 形式给出了 4 个 assert.equal 断言,它们既是评判依据,也精确划定了函数的行为边界。逐条分析:
| 输入 | 期望输出 | 考察点 |
|---|---|---|
"helloworld" |
"HeLLoWoRLD" |
全小写输入:辅音大写、元音小写 |
"HELLOWORLD" |
"HeLLoWoRLD" |
全大写输入:先做大小写归一化 |
"_hElLO-WOrlD-" |
"_HeLLo_WoRLD_" |
大小写混合 + 连字符转下划线 |
"_~-generic_~-variable_~-name_~-here-~_" |
"_~_GeNeRiC_~_VaRiaBLe_~_NaMe_~_HeRe_~_" |
特殊字符 _、~ 原样保留,连字符全部转换 |
最后一个用例揭示了最关键的行为细节:_~-generic_~-... 转换后变成 _~_GeNeRiC_~_...——输入中 _~ 之后紧跟 -,输出里 _~ 保持原样而 - 变为 _,字符串整体长度不变,只发生了字符级替换。函数必须对字符串进行逐字符处理,而不是基于单词分割或正则整体替换,否则很容易在特殊字符边界上出错。
这些 hint 最终会被解析为 tests 数组写入数据库。根据 challenge-schema.js 的定义,tests 数组中的每个元素是 { text, testString } 结构——text 是给人看的说明文字,testString 是真正在浏览器中执行断言用的代码字符串,也就是上文中 assert.equal(...) 那几行。
从零实现:官方参考解法逐行拆解
题目给出的参考解法(# --solutions--)如下:
function toConsonantCase(str) {
const vowels = "aeiouAEIOU";
let result = "";
for (let char of str) {
if (char === "-") {
result += "_";
} else if (/[a-zA-Z]/.test(char)) {
if (vowels.includes(char)) {
result += char.toLowerCase();
} else {
result += char.toUpperCase();
}
} else {
result += char;
}
}
return result;
}
逐行拆解其设计逻辑:
const vowels = "aeiouAEIOU":元音集合同时包含大小写。这样后续只需判断「当前字符是否在元音集合中」,无需先归一化大小写。for (let char of str):for...of逐字符迭代,天然满足「非字母、非连字符字符原样保留」的要求。这是解法正确性的关键——它按字符索引遍历,而不是按单词或按空白分割。- 分支一
char === "-":先处理连字符,直接拼接_。 - 分支二
/[a-zA-Z]/.test(char):用正则判定是否为英文字母,是字母且非元音 →toUpperCase()大写;是元音 →toLowerCase()小写。 - 分支三(隐式 else):数字、下划线、波浪号、空格等其他字符直接原样拼接。
return result:返回拼接结果,不修改原字符串(函数无副作用)。
该解法的核心思想可以概括为**「先判类型,再定大小写」**:先区分三类字符(连字符 / 字母 / 其他),再在字母类中区分元音与辅音。分支顺序决定了语义——连字符优先于字母判断,而字母判断又优先于「原样保留」,任何字符都必然落入恰好一个分支。
思路拓展:三种等价实现对比
除了官方解法,这道题还有多种等价写法。下面给出两种,便于理解题目的本质与取舍:
方案一:字符编码区间判断(避免正则)
function toConsonantCase(str) {
const vowels = new Set("aeiouAEIOU");
let result = "";
for (const char of str) {
const isLetter = /^[a-z]$/i.test(char);
if (char === "-") {
result += "_";
} else if (isLetter && !vowels.has(char)) {
result += char.toUpperCase();
} else if (isLetter && vowels.has(char)) {
result += char.toLowerCase();
} else {
result += char;
}
}
return result;
}
方案二:正则整体替换(注意边界陷阱)
function toConsonantCase(str) {
return str
.replace(/-/g, "_")
.replace(/[aeiou]/gi, m => m.toLowerCase())
.replace(/[b-df-hj-np-tv-z]/gi, m => m.toUpperCase());
}
方案二代码最简洁,但有一个需要注意的缺陷:/[b-df-hj-np-tv-z]/gi 这类辅音区间写法依赖 ASCII 码表连续性(b–d、f–h、j–n、p–t、v–z 的间隔恰好跳过元音),虽然对本题目字母集有效,却容易在改造成其他字符集时出错。相比之下,逐字符扫描 + 显式元音集合的方案可读性与可维护性最佳,这也是官方选择它的原因。
复杂度分析:三种方案的时空复杂度均为 O(n)(n 为输入字符串长度)。for...of 与 includes/Set.has 都是线性扫描;若追求极端性能,可将元音集合换为 Set 使单字符查询摊还 O(1),但对普通变量名长度而言差异可忽略。
解出题目后:它如何进入每日答题系统
这道 Challenge 163 并不是孤立的一道题,它是「每日编程挑战」产品功能的一环。仓库中从课程文件到线上答题的完整链路如下:
- 课程 Markdown 是唯一内容源:题目描述、hints、seed 代码、官方解法全部定义在 694596b0585c11170ac7c7fb.md 中,并通过 challenge-schema.js 做结构校验(
challengeType取值 0–33、tests数组必须含text与testString等)。 - Gatsby GraphQL 抓取:
dev-playground超级块(superblock)下定义了 JavaScript 与 Python 两个挑战块。种子脚本通过 fetchChallenges 向运行中的 client 的___graphql端点发起 GraphQL 查询,按superBlock: "dev-playground"、block: "daily-coding-challenges-${language}"过滤并按challengeOrder排序抓取全部挑战。 - JS 与 Python 配对合并:combineChallenges 将同题号的 JS/Python 挑战合并为一条记录——题号取自数组下标(
challengeNumber: i + 1,故 Challenge 163 即第 163 条),title去掉"Challenge 163: "前缀,描述经removeSection去掉解析器添加的<section>包装,测试与代码文件则按语言分别挂到javascript/python字段。 - 按天排期写入 MongoDB:seed-daily-challenges.ts 以
2025-08-11为起始日,每道题向后顺延一天(START_DATE.getTime() + i * ONE_DAY_IN_MS),脚本还硬编码了EXPECTED_CHALLENGE_COUNT = 365用于数量自检,并冻结起始日期防止发布后误改。因此 Challenge 163 对应的自然日是2025-08-11 + 162 天。 - API 按日期下发:API 层提供 daily-coding-challenge 路由,包括
/daily-coding-challenge/date/:date(按YYYY-MM-DD查询)、/day/:day(按MM-DD查询)、/today、/month/:month、/all、/newest六个公开 GET 接口。所有查询都以「美国中部时区的今天」为截止边界,未来日期的题目不会提前泄露——Challenge 163 也要到其排期日期当天才可访问。 - 响应结构与客户端消费:单题响应结构在 daily-coding-challenge.ts 的 schema 中定义,包含
id、date、challengeNumber、title、description以及javascript/python两组{ tests, challengeFiles }。客户端侧 widget.tsx 与 calendar.tsx 负责展示与日历导航,helpers.ts 则提供getTodayUsCentral、formatDate、formatDisplayDate等日期工具——所有「今天」均以America/Chicago时区计算。
从这条链路可以看出:学习者答题时填写的 toConsonantCase 实现会被浏览器中的测试运行器执行,而其依据正是题目 Markdown 中那 4 条 assert.equal;而「今天该做哪道题」则由种子脚本的日期排期与 API 的时区边界共同决定。
测试驱动验证:确保实现通过全部断言
完成实现后,可以把它直接放进浏览器控制台或 Node.js 环境,用题目提供的 4 个断言验证:
// 粘贴你的实现
function toConsonantCase(str) { /* ... */ }
// 运行题目 hints
const cases = [
["helloworld", "HeLLoWoRLD"],
["HELLOWORLD", "HeLLoWoRLD"],
["_hElLO-WOrlD-", "_HeLLo_WoRLD_"],
["_~-generic_~-variable_~-name_~-here-~_", "_~_GeNeRiC_~_VaRiaBLe_~_NaMe_~_HeRe_~_"]
];
let passed = 0;
for (const [input, expected] of cases) {
const actual = toConsonantCase(input);
const ok = actual === expected;
console.log(`${ok ? "PASS" : "FAIL"} toConsonantCase(${JSON.stringify(input)}) => ${JSON.stringify(actual)}`);
if (ok) passed++;
}
console.log(`${passed}/${cases.length} tests passed`);
全部 4 条通过即说明实现满足题目约束。在此基础上可以再做一轮自测,验证边界行为:
- 空字符串:
toConsonantCase("")应返回""(for...of对空串不产生任何迭代)。 - 无字母输入:
toConsonantCase("-_~123")应返回"__~123"——连字符被替换,其余原样。 - 非英文字母:带重音的字符(如
é)不属于[a-zA-Z],应原样保留。
小结
Challenge 163 是一道典型的「字符级转换」题目,其考点集中于三点:大小写归一化(无论输入是全大写、全小写还是混合)、字符分类处理(辅音 / 元音 / 连字符 / 其他四类互斥分支)以及对非字母字符的保真。官方参考解法用「元音集合 + 正则判定 + 逐字符遍历」给出了简洁可靠的实现;而将这道题放回仓库语境中,它又展示了 freeCodeCamp 内容与工程体系的联动方式——一份 Markdown 定义题目,GraphQL 抓取配对,种子脚本排期入库,API 按时下发,最终在浏览器中完成一次真实的算法训练。
如果你还想继续练习同系列题目,仓库中同一挑战块还包含 Challenge 15: camelCase(6821ebce237de8297eaee790.md)、Challenge 114: Camel to Snake(69162d64f96574d9bb629eff.md)、Challenge 140: SCREAMING_SNAKE_CASE(69272dcf1c24b44fd79137c3.md)等同属命名规范转换主题的练习题,它们的解题思路与本题目一脉相承。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
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