首页
/ freeCodeCamp JavaScript 项目实战:用 HTML/CSS/JavaScript 构建 Palindrome Checker(回文检测器)

freeCodeCamp JavaScript 项目实战:用 HTML/CSS/JavaScript 构建 Palindrome Checker(回文检测器)

2026-09-07 17:12:32作者:毕习沙Eudora

本篇技术指南围绕 freeCodeCamp 仓库中 JavaScript 认证路径的项目挑战「Build a Palindrome Checker」展开,完整解读其需求规格、自动化验收测试与参考实现。读者将掌握回文检测的核心算法(字符清洗 + 大小写归一 + 正反对齐比较)、三个必备 DOM 元素的交互设计,以及如何写出能一次性通过全部 18 组浏览器端断言测试的完整应用。文章所有需求与代码均取自仓库内英文源文档 657bdc55a322aae1eac3838f.md,结构定义见下方各结构 JSON,可放心对照练习。

挑战在课程体系中的位置

在进入代码之前,先厘清这个挑战在仓库课程结构里的位置。它的 Front Matter 写明 challengeType: 14saveSubmissionToDB: 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.jsonbasic-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):

  1. 移除所有非字母数字字符(non-alphanumeric characters),包括标点、空格与各种符号;
  2. 统一大小写(全部转为小写或大写均可)。

之所以必须如此,是因为验收用例里出现了大量"不干净"的输入: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,恰好正反一致;而 almostomlanope 清洗后仍是自身,但正反不同,因此不是回文。注意字母与数字之外的任何字符都会被丢弃,连下划线 _ 这种"看似内容"的符号也要去掉(这正是 _eye 仍是回文的原因:清洗后只剩 eye)。

用户故事(User Stories)逐条拆解

文档给出了可执行的验收需求,共分三部分。

1. DOM 结构三要素(必备骨架)

  1. 一个 id"text-input"input 元素;
  2. 一个 id"check-btn"button 元素;
  3. 一个 id"result"divspanp 元素(三者任选其一即可,测试只校验标签名属于这三者之一)。

这三个 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 palindromeis 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?.nodeNameundefined,断言直接失败,从而精确捕获"缺元素"的错误。

空输入触发 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.valuedispatchEvent(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">&#128161;</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-inputbutton#check-btndiv#result(用 div 即可满足"div/span/p 任一"的约束);
  • labelfor="text-input" 与输入框 id 绑定,点击标签可聚焦输入框,属于基础可访问性;
  • #result 初始带 hidden class,即结果区默认不可见,判定完成后再显示;
  • <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,统一盒模型;
  • .containerflex + min-height: 100vh 实现垂直水平居中,整页深蓝底(#0a0a23)、白色结果卡(白底 + #002ead 阴影圆角)是 freeCodeCamp 项目视觉的一贯风格;
  • width: min(100vw, 450px) 让卡片在移动端不溢出视口,兼顾响应式;
  • 关键的是 .hidden { display: none; }:它在判定完成前隐藏 #result,而 JS 会在写入结果后移除该类以显示结果;
  • .results-divword-wrap: break-wordoverflow-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 = "";
  }
});

这段代码是整个挑战的核心,值得逐行理解:

  1. 缓存 DOM 引用:开头一次性获取 #text-input#check-btn#result,后续事件回调直接复用;
  2. 空值拦截if (input === "") 时调用 alert("Please input a value")return,对应第 4 条用户故事;注意此处判断的是严格的空字符串,若你希望同时拦截纯空格输入可自行加强为 input.trim() === "",但这不影响官方用例;
  3. 清理上一次结果resultDiv.replaceChildren() 将上一次点击生成的 <p> 全部移除,避免连续点击时旧结果残留堆叠;
  4. 归一化核心语句input.replace(/[^A-Za-z0-9]/gi, "").toLowerCase()
    • 正则 /[^A-Za-z0-9]/gi 的含义是:取反字符组 [^...] 匹配"既不是字母也不是数字"的任意字符,g 全局替换、i 忽略大小写,因此会把标点、空格、下划线、斜杠、连字符等全部删除,只留下 ASCII 字母与数字;
    • .toLowerCase() 统一为小写,消除大小写差异——这就是文档 Note 中两条要求的落地;
  5. 回文比较[...lowerCaseStr].reverse().join("") 先把字符串用展开运算符拆成字符数组,再 reverse() 反转、join("") 拼回字符串,最后与自身严格相等比较。展开成数组再反转比直接操作字符串更直观,也能正确处理按码元(code unit)迭代的场景;
  6. 结果消息构造:三目运算符按比较结果拼接 is / is not,外层用模板字符串生成 <strong>${originalInput}</strong> is a palindrome.——originalInput 是点击那一刻未经清洗的原始输入,保证结果回显用户真实输入的符号与大小写;
  7. 结果渲染与显示:动态创建 <p class="user-input">,用 innerHTML 注入富文本(加粗原文),appendChild 挂到 #result 下,最后 classList.remove("hidden") 让结果区可见;
  8. 双击交互兼容click 事件监听按钮点击;keydown 事件里监听 Enter 键,让用户在输入框内直接回车也能触发判定。两种入口执行完都会清空输入框 userInput.value = "",便于连续测试多组输入。

安全性提示:参考实现用 innerHTML 注入用户原文,这是教学示例的常见简化写法。若你追求更严谨的实践,可在自研版本中用 textContent 加节点拆分的方式来避免把用户输入当 HTML 解析(注意测试读取 innerText,只要最终 #result 里出现 <原文> is a palindrome 语义文本即可通过)。

逐条对齐测试:动手前先读懂这些陷阱

综合用户故事与测试脚本,可以提炼出决定"一次通过"的关键清单:

  • id 必须逐字符精确text-inputcheck-btnresult,大小写敏感,引号不能省;
  • #result 标签名受限:只能是 divspanp 之一,用 section/article 等会直接挂掉第二条存在性断言;
  • 空值 alert:点击按钮且输入为空时必须触发 window.alert,文案核心为 Please input a value;若你根本没有调用 alert,测试捕获到的 alertMessage 会是 undefined,断言失败;
  • 结果必须真实可见:测试通过 innerText 读取文本,若结果区持续 display: noneinnerText 读不到内容,断言失败——这正是参考实现里"写入后移除 hidden"的原因;
  • 判定看清洗后字符串,回显看原始字符串:判定 _eye 时按 eye 算(回文),但展示必须写 _eye is a palindrome
  • 字符清洗同时删下划线/[^A-Za-z0-9]/gi 不保留 _,这是 0_0 (: /-\ :) 0-0 判为回文(清洗后为 0000)的前提;
  • 兼容随机用例:算法必须对随机生成的字母数字串(8 位对称回文与 4 位互异非回文)输出正确结论,不能依赖固定字符串匹配;
  • 连续点击不残留:每次点击前清空旧结果(replaceChildren()),否则历史结果会影响下一次断言读取;
  • 输出文案归一化容忍度:句末句点、大小写均可灵活,但 is a palindromeis not a palindrome 的语义短语必须与原文拼接完整。

如何在本仓库中查看与验证

  • 挑战英文源文档657bdc55a322aae1eac3838f.md(同 ID 的副本位于 curriculum/challenges/english/blocks/lab-palindrome-checker/),内含完整描述、用户故事、全部 hints 与参考解决方案;
  • 课程块结构定义build-a-palindrome-checker-project.jsonlab-palindrome-checker.json,前者标记 blockLayout: "legacy-link"、后者标记 blockLayout: "link",均声明 usesMultifileEditor: true——即学员在分栏编辑器中同时编辑 index.htmlstyles.cssscript.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 实战积累。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388