freeCodeCamp JavaScript 项目实战:用 HTML/CSS/JavaScript 构建 Palindrome Checker(回文检测器)
本篇技术指南围绕 freeCodeCamp 仓库中 JavaScript 认证路径的项目挑战「Build a Palindrome Checker」展开,完整解读其需求规格、自动化验收测试与参考实现。读者将掌握回文检测的核心算法(字符清洗 + 大小写归一 + 正反对齐比较)、三个必备 DOM 元素的交互设计,以及如何写出能一次性通过全部 18 组浏览器端断言测试的完整应用。文章所有需求与代码均取自仓库内英文源文档 657bdc55a322aae1eac3838f.md,结构定义见下方各结构 JSON,可放心对照练习。
挑战在课程体系中的位置
在进入代码之前,先厘清这个挑战在仓库课程结构里的位置。它的 Front Matter 写明 challengeType: 14、saveSubmissionToDB: true,属于需要完整提交作品并由测试脚本自动验收的项目型挑战,且评测通过后的提交会作为项目记录存入数据库。
这道挑战的唯一 ID 为 657bdc55a322aae1eac3838f,在仓库中同时被两个课程块引用:
- 传统项目块
build-a-palindrome-checker-project:定义在 build-a-palindrome-checker-project.json,其challengeOrder仅包含上述一个挑战;块采用blockLayout: "legacy-link"、helpCategory: "JavaScript"、usesMultifileEditor: true,对应英文源文档位于curriculum/challenges/english/blocks/build-a-palindrome-checker-project/,在 javascript-algorithms-and-data-structures-22.json 所描述的 legacy JavaScript 认证路径中出现。 - 新版 Lab 块
lab-palindrome-checker:定义在 lab-palindrome-checker.json,挂载于 javascript-v9.json 的basic-regex(基础正则)模块之中,作为学完「正则表达式」系列课程后的动手练习;同样的挑战源文档也镜像存放于curriculum/challenges/english/blocks/lab-palindrome-checker/目录。
两个块共用同一份题目与验收标准。挑战在 --seed-- 阶段只提供三个空的 html/css/js 代码槽位,学习者需要从零开始、自由发挥个人风格,最终"完成全部用户故事并通过下方全部测试"。
回文判定:先理解"什么是回文"
文档开篇用 <dfn> 定义了核心概念:
A palindrome is a word or phrase that can be read the same way forwards and backwards, ignoring punctuation, case, and spacing.
即:回文(palindrome)是指正着读与倒着读完全一致的单词或短语——但必须先忽略标点、大小写与空格。
文档紧接着给出了本挑战最关键的两条预处理要求(Note):
- 移除所有非字母数字字符(non-alphanumeric characters),包括标点、空格与各种符号;
- 统一大小写(全部转为小写或大写均可)。
之所以必须如此,是因为验收用例里出现了大量"不干净"的输入:race car(空格)、_eye(下划线)、A man, a plan, a canal. Panama(大小写、逗号、句点与空格并存)、0_0 (: /-\ :) 0-0(括号、冒号、斜杠、反斜杠、连字符混搭)等。它们之所以能成为回文,全靠先把这些干扰字符剔除、再比较正反序列。
可以把这个判定过程抽象为一步"归一化 + 对称比较":
normalized = 原输入 去掉所有非字母数字字符 并统一转为小写
isPalindrome = normalized === normalized 逆序排列后得到的字符串
例如 A man, a plan, a canal. Panama 清洗后变为 amanaplanacanalpanama,恰好正反一致;而 almostomla 与 nope 清洗后仍是自身,但正反不同,因此不是回文。注意字母与数字之外的任何字符都会被丢弃,连下划线 _ 这种"看似内容"的符号也要去掉(这正是 _eye 仍是回文的原因:清洗后只剩 eye)。
用户故事(User Stories)逐条拆解
文档给出了可执行的验收需求,共分三部分。
1. DOM 结构三要素(必备骨架)
- 一个
id为"text-input"的input元素; - 一个
id为"check-btn"的button元素; - 一个
id为"result"的div、span或p元素(三者任选其一即可,测试只校验标签名属于这三者之一)。
这三个 id 必须原样精确存在,它们是后续所有交互测试与结果输出测试的定位锚点。
2. 空输入校验
当 #check-btn 被点击时,若 #text-input 中没有任何值,必须弹出一个内容为 Please input a value 的 alert。
3. 一组固定输入/输出对照(核心行为)
下表完整列出文档用户故事第 5~17 条要求,当文本框中输入指定内容并点击按钮后,#result 元素必须包含对应的结果文本:
| 输入内容 | #result 必须包含的文本 |
|---|---|
A |
A is a palindrome |
eye |
eye is a palindrome |
_eye |
_eye is a palindrome |
race car |
race car is a palindrome |
not a palindrome |
not a palindrome is not a palindrome |
A man, a plan, a canal. Panama |
A man, a plan, a canal. Panama is a palindrome |
never odd or even |
never odd or even is a palindrome |
nope |
nope is not a palindrome |
almostomla |
almostomla is not a palindrome |
My age is 0, 0 si ega ym. |
My age is 0, 0 si ega ym. is a palindrome |
1 eye for of 1 eye. |
1 eye for of 1 eye. is not a palindrome |
0_0 (: /-\ :) 0-0 |
0_0 (: /-\ :) 0-0 is a palindrome |
| `five | _/ |
注意观察每个结果的判定:判断依据是"清洗并统一大小写后的序列"是否回文,但展示给用户的文本必须完整回显用户输入的原始字符串(包括下划线、符号、标点),再拼接上 is a palindrome 或 is not a palindrome。
自动化验收测试(hints)的技术细节
真正的"裁判"是文档 # --hints-- 段中的 18 组浏览器端断言脚本。它们由测试框架在页面内直接执行,逐条验证 DOM 与交互行为。理解这些脚本的写法,等于直接读懂了评分标准。下面拆解其关键技巧。
元素存在性校验
前三组测试通过 document.getElementById 取得元素并检查其 nodeName(统一转小写后比对):
const el = document.getElementById('text-input');
assert.strictEqual(el?.nodeName?.toLowerCase(), 'input');
其余两组同理,分别校验 #check-btn 必须是小写 button,#result 的标签名必须命中 ['div', 'span', 'p'] 三者之一:
const el = document.getElementById('result');
assert(['div', 'span', 'p'].includes(el?.nodeName?.toLowerCase()));
这里使用了可选链 ?.:若元素不存在,el?.nodeName 为 undefined,断言直接失败,从而精确捕获"缺元素"的错误。
空输入触发 alert 的测试写法
测试通过重写 window.alert 来"捕获"弹窗文本,再模拟输入为空、派发 change 事件并点击按钮:
const inputEl = document.getElementById('text-input');
const checkBtn = document.getElementById('check-btn');
let alertMessage;
window.alert = (message) => alertMessage = message; // Override alert and store message
inputEl.value = '';
inputEl.dispatchEvent(new Event('change'))
checkBtn.click();
assert.strictEqual(alertMessage?.trim().replace(/[.,?!]+$/g, '').toLowerCase(), 'please input a value');
对比时测试会把捕获到的消息先 trim(),再去掉末尾的 . , ? !,最后统一转小写。这意味着你的弹窗文案大小写、末尾句号可灵活,但核心短语必须是 Please input a value(归一化后必须等于 please input a value)。
结果文本的断言模式
每条输入/输出用例的测试都遵循同一套模式:写入 inputEl.value → dispatchEvent(new Event('change')) → 模拟 checkBtn.click() → 读取 resultEl.innerText 并归一化后与期望值比较:
inputEl.value = 'A';
inputEl.dispatchEvent(new Event('change'))
checkBtn.click();
assert.strictEqual(resultEl.innerText.trim().replace(/[.,?!]+$/g, '').toLowerCase(), 'a is a palindrome');
归一化规则(trim、去末尾标点、转小写)意味着结果可以写成 A is a palindrome.、A is a palindrome 等变体而都能通过——前提是中间的语义短语"<原文> is a palindrome / is not a palindrome"保持完整,并且 #result 元素的 innerText 能读到这些文字(即不能把结果藏在 display:none 的容器里且不显示出来,测试读取的是渲染文本)。
随机回文用例
除固定用例外,还有两个"防作弊"的随机生成测试。其一会动态构造一个字母数字回文:随机抽取 4 个字符,再镜像拼接出 c1+c2+c3+c4+c4+c3+c2+c1 这样的对称短语(字符池为 abcdefghijklmnopqrstuvwxyz0123456789),然后断言结果必须是 phrase is a palindrome:
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
const firstLetter = characters.charAt(Math.floor(Math.random() * charactersLength));
const secondLetter = characters.charAt(Math.floor(Math.random() * charactersLength));
const thirdLetter = characters.charAt(Math.floor(Math.random() * charactersLength));
const fourthLetter = characters.charAt(Math.floor(Math.random() * charactersLength));
const phrase = firstLetter + secondLetter + thirdLetter + fourthLetter + fourthLetter + thirdLetter + secondLetter + firstLetter;
inputEl.value = phrase;
inputEl.dispatchEvent(new Event('change'))
checkBtn.click();
assert.strictEqual(resultEl.innerText.trim().replace(/[.,?!]+$/g, '').toLowerCase(), phrase + ' is a palindrome');
其二是构造一个必然不是回文的 4 字母随机串——生成逻辑特意保证四个字母互不重复(每抽出一个字符后都从字符池中移除并缩减池长),这样正反必然不同:
let characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
let charactersLength = characters.length;
const firstIndex = Math.floor(Math.random() * charactersLength);
const firstLetter = characters.charAt(firstIndex);
characters = characters.slice(0, firstIndex) + characters.slice(firstIndex + 1);
charactersLength--;
// ... 依次生成互不重复的 secondLetter / thirdLetter / fourthLetter ...
const phrase = firstLetter + secondLetter + thirdLetter + fourthLetter;
inputEl.value = phrase;
inputEl.dispatchEvent(new Event('change'))
checkBtn.click();
assert.strictEqual(resultEl.innerText.trim().replace(/[.,?!]+$/g, '').toLowerCase(), phrase + ' is not a palindrome');
这类随机测试的存在,排除了"针对固定输入写死输出"的取巧实现——你的算法必须对任意字母数字组合都判断正确。
参考实现源码逐段剖析
文档 # --solutions-- 给出了完整可运行的官方参考实现(HTML + CSS + JavaScript 三个文件)。以下原样摘录,再逐段讲解。
HTML 骨架(index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
type="image/png"
href="https://cdn.freecodecamp.org/universal/favicons/favicon.ico"
/>
<title>Palindrome Checker</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="container">
<img
class="freecodecamp-logo"
src="https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg"
alt="freeCodeCamp Logo"
/>
<h1 class="title">Is it a Palindrome?</h1>
<div class="palindrome-div">
<label for="text-input"
>Enter in text to check for a palindrome:
</label>
<input class="palindrome-input" id="text-input" value="" type="text" />
<button class="palindrome-btn" id="check-btn">Check</button>
<div class="results-div hidden" id="result"></div>
</div>
<div class="palindrome-definition-div">
<p class="palindrome-definition">
<span role="img" aria-label="light-bulb">💡</span>
A <dfn>palindrome</dfn> is a word or sentence that's spelled the same
way both forward and backward, ignoring punctuation, case, and
spacing.
</p>
</div>
</main>
<script src="script.js"></script>
</body>
</html>
要点对照:
- 三个被测元素精确命中:
input#text-input、button#check-btn、div#result(用div即可满足"div/span/p 任一"的约束); label的for="text-input"与输入框id绑定,点击标签可聚焦输入框,属于基础可访问性;#result初始带hiddenclass,即结果区默认不可见,判定完成后再显示;<script src="script.js">放在</body>之前,保证脚本执行时 DOM 已就绪;- 页面底部用
role="img"+aria-label呈现灯泡 emoji,把装饰性符号转成可被读屏软件识别的图片语义。
CSS 样式(styles.css)
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Verdana, Geneva, Tahoma, sans-serif;
background-color: #0a0a23;
color: #ffffff;
}
.container {
width: 100%;
min-height: 100vh;
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.freecodecamp-logo {
height: 30px;
margin-bottom: 20px;
}
.title {
text-align: center;
padding: 10px 0;
font-size: 2.5rem;
margin-bottom: 20px;
}
.palindrome-div {
width: min(100vw, 450px);
min-height: 100px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
padding: 20px;
margin: 10px 0;
background-color: white;
box-shadow: 0 6px 6px #002ead;
}
label {
color: #0a0a23;
margin-bottom: 20px;
}
.palindrome-btn {
width: 90px;
border: none;
padding: 10px;
border-radius: 15px;
background-color: #5a01a7;
color: #fff;
cursor: pointer;
}
.palindrome-input {
height: 30px;
width: 250px;
text-align: center;
font-size: 1.2rem;
margin: 10px;
border: none;
border-bottom: 2px solid #5a01a7;
}
.palindrome-input:focus {
border-bottom: 3px solid #5a01a7;
}
.palindrome-input::placeholder {
text-align: center;
}
.user-input {
font-size: 1.4rem;
margin-top: 10px;
text-align: center;
}
.results-div {
overflow-y: auto;
word-wrap: break-word;
min-height: 50px;
color: black;
}
.hidden {
display: none;
}
.palindrome-definition-div {
width: min(100vw, 450px);
font-size: 1.3rem;
min-height: 140px;
background-color: #00471b;
margin-top: 20px;
padding: 20px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.palindrome-definition {
vertical-align: middle;
text-align: center;
}
样式设计上值得注意的几点:
- 全局重置
margin/padding为 0 并开启box-sizing: border-box,统一盒模型; .container用flex+min-height: 100vh实现垂直水平居中,整页深蓝底(#0a0a23)、白色结果卡(白底 +#002ead阴影圆角)是 freeCodeCamp 项目视觉的一贯风格;width: min(100vw, 450px)让卡片在移动端不溢出视口,兼顾响应式;- 关键的是
.hidden { display: none; }:它在判定完成前隐藏#result,而 JS 会在写入结果后移除该类以显示结果; .results-div的word-wrap: break-word与overflow-y: auto保证超长输入(如0_0 (: /-\ :) 0-0)换行后仍可读、不撑破布局。- 样式本身不影响测试通过:文档明确"Give it your own personal style",你可以完全按个人风格重写以上视觉,只要保留必须的 id 与交互语义。
JavaScript 核心逻辑(script.js)
const userInput = document.getElementById("text-input");
const checkPalindromeBtn = document.getElementById("check-btn");
const resultDiv = document.getElementById("result");
const checkForPalindrome = (input) => {
const originalInput = input; // Store for later output
if (input === "") {
alert("Please input a value");
return;
}
// Remove the previous result
resultDiv.replaceChildren();
const lowerCaseStr = input.replace(/[^A-Za-z0-9]/gi, "").toLowerCase();
let resultMsg = `<strong>${originalInput}</strong> ${
lowerCaseStr === [...lowerCaseStr].reverse().join("") ? "is" : "is not"
} a palindrome.`;
const pTag = document.createElement("p");
pTag.className = "user-input";
pTag.innerHTML = resultMsg;
resultDiv.appendChild(pTag);
// Show the result.
resultDiv.classList.remove("hidden");
};
checkPalindromeBtn.addEventListener("click", () => {
checkForPalindrome(userInput.value);
userInput.value = "";
});
userInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
checkForPalindrome(userInput.value);
userInput.value = "";
}
});
这段代码是整个挑战的核心,值得逐行理解:
- 缓存 DOM 引用:开头一次性获取
#text-input、#check-btn、#result,后续事件回调直接复用; - 空值拦截:
if (input === "")时调用alert("Please input a value")并return,对应第 4 条用户故事;注意此处判断的是严格的空字符串,若你希望同时拦截纯空格输入可自行加强为input.trim() === "",但这不影响官方用例; - 清理上一次结果:
resultDiv.replaceChildren()将上一次点击生成的<p>全部移除,避免连续点击时旧结果残留堆叠; - 归一化核心语句:
input.replace(/[^A-Za-z0-9]/gi, "").toLowerCase()- 正则
/[^A-Za-z0-9]/gi的含义是:取反字符组[^...]匹配"既不是字母也不是数字"的任意字符,g全局替换、i忽略大小写,因此会把标点、空格、下划线、斜杠、连字符等全部删除,只留下 ASCII 字母与数字; .toLowerCase()统一为小写,消除大小写差异——这就是文档 Note 中两条要求的落地;
- 正则
- 回文比较:
[...lowerCaseStr].reverse().join("")先把字符串用展开运算符拆成字符数组,再reverse()反转、join("")拼回字符串,最后与自身严格相等比较。展开成数组再反转比直接操作字符串更直观,也能正确处理按码元(code unit)迭代的场景; - 结果消息构造:三目运算符按比较结果拼接
is/is not,外层用模板字符串生成<strong>${originalInput}</strong> is a palindrome.——originalInput是点击那一刻未经清洗的原始输入,保证结果回显用户真实输入的符号与大小写; - 结果渲染与显示:动态创建
<p class="user-input">,用innerHTML注入富文本(加粗原文),appendChild挂到#result下,最后classList.remove("hidden")让结果区可见; - 双击交互兼容:
click事件监听按钮点击;keydown事件里监听Enter键,让用户在输入框内直接回车也能触发判定。两种入口执行完都会清空输入框userInput.value = "",便于连续测试多组输入。
安全性提示:参考实现用 innerHTML 注入用户原文,这是教学示例的常见简化写法。若你追求更严谨的实践,可在自研版本中用 textContent 加节点拆分的方式来避免把用户输入当 HTML 解析(注意测试读取 innerText,只要最终 #result 里出现 <原文> is a palindrome 语义文本即可通过)。
逐条对齐测试:动手前先读懂这些陷阱
综合用户故事与测试脚本,可以提炼出决定"一次通过"的关键清单:
- id 必须逐字符精确:
text-input、check-btn、result,大小写敏感,引号不能省; #result标签名受限:只能是div、span、p之一,用section/article等会直接挂掉第二条存在性断言;- 空值 alert:点击按钮且输入为空时必须触发
window.alert,文案核心为Please input a value;若你根本没有调用alert,测试捕获到的alertMessage会是undefined,断言失败; - 结果必须真实可见:测试通过
innerText读取文本,若结果区持续display: none,innerText读不到内容,断言失败——这正是参考实现里"写入后移除hidden"的原因; - 判定看清洗后字符串,回显看原始字符串:判定
_eye时按eye算(回文),但展示必须写_eye is a palindrome; - 字符清洗同时删下划线:
/[^A-Za-z0-9]/gi不保留_,这是0_0 (: /-\ :) 0-0判为回文(清洗后为0000)的前提; - 兼容随机用例:算法必须对随机生成的字母数字串(8 位对称回文与 4 位互异非回文)输出正确结论,不能依赖固定字符串匹配;
- 连续点击不残留:每次点击前清空旧结果(
replaceChildren()),否则历史结果会影响下一次断言读取; - 输出文案归一化容忍度:句末句点、大小写均可灵活,但
is a palindrome与is not a palindrome的语义短语必须与原文拼接完整。
如何在本仓库中查看与验证
- 挑战英文源文档:657bdc55a322aae1eac3838f.md(同 ID 的副本位于
curriculum/challenges/english/blocks/lab-palindrome-checker/),内含完整描述、用户故事、全部 hints 与参考解决方案; - 课程块结构定义:build-a-palindrome-checker-project.json 与 lab-palindrome-checker.json,前者标记
blockLayout: "legacy-link"、后者标记blockLayout: "link",均声明usesMultifileEditor: true——即学员在分栏编辑器中同时编辑index.html、styles.css、script.js三个文件(与参考实现中<link rel="stylesheet" href="styles.css">、<script src="script.js">的引用关系一致); - 挂载的认证路径:javascript-v9.json 中把
lab-palindrome-checker编排在basic-regex模块(紧接着workshop-spam-filter反垃圾邮件工作坊之后),可见该挑战被设计为正则表达式学习成果的落地检验;legacy 侧的路径则见 javascript-algorithms-and-data-structures-22.json。
小结
Palindrome Checker 是一个"麻雀虽小、五脏俱全"的前端交互项目:它考验的是精确理解需求 → 设计 DOM 结构 → 用正则完成数据清洗 → 用数组反转完成对称比较 → 用事件监听串联交互的完整链路。文档给出的验收测试还额外教会了你两件事:测试可以重写 window.alert 来捕获弹窗内容,可以通过归一化比较(trim + 去尾标点 + 小写)来容忍实现细节的差异。照着上文的清单逐条实现,再把三份参考代码作为对照基准,你就能稳稳通过全部 18 组断言,并把这道经典题目变成自己的 JavaScript 实战积累。
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 StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00