免费代码训练营「构建个人作品集网页」完整通关指南:从用户故事到自动化测试
freeCodeCamp「响应式网页设计」认证经典项目——构建个人作品集网页(Build a Personal Portfolio Webpage),要求用纯 HTML 与 CSS 从零搭建一个含欢迎区、作品区、固定导航栏和个人主页链接的作品集页面,并逐条通过内置自动化验收测试。阅读本文,你将吃透该项目全部 11 条用户故事(User Stories)与对应的 JS 断言(Hints)校验逻辑,掌握 position: fixed 吸顶导航、100vh 满屏欢迎区、媒体查询与锚点菜单等关键实现手法,并拿到一份可直接通过全部测试的完整代码。
本文主体基于 freeCodeCamp 开源仓库中的课程文件 bd7158d8c242eddfaeb5bd13.md,并结合仓库内的课程结构配置与官方 Solution 做深度拆解。
一、项目定位:这是一道怎样的挑战
从仓库的课程结构可以确认它的地位与运行方式:
- Block 级配置见 build-a-personal-portfolio-webpage-project.json:该 Block 的
challengeOrder仅包含本挑战一个条目;usesMultifileEditor: true表示在多功能编辑器中以「HTML + CSS 两个文件」的形式编辑;helpCategory: "HTML-CSS"标注其归类。 - 超级模块级配置见 responsive-web-design-22.json:
build-a-personal-portfolio-webpage-project是responsive-web-design-22超级模块(含 22 个 Block)的收官项目,排在learn-css-transforms-by-building-a-penguin等课程块之后。 - 挑战文件头部的元数据
challengeType: 14与saveSubmissionToDB: true,说明它属于会记录进认证进度的「项目型」提交。
原文档给出的 Objective 只有一句话:构建一个与官方演示站点功能相似的 App,不要复制该演示项目,并加上自己的个人风格("Give it your own personal style")。
同时原文档有一条重要 Note:务必在 HTML 中加入 <link rel="stylesheet" href="styles.css"> 来关联样式表,否则 CSS 全部不生效,测试无法通过。这是新手最常见的「零样式」事故根源。
二、需求规格:11 条 User Stories 逐条精读
原文档把验收标准描述为 11 条用户故事,本质上是「作品集必须能做什么」的行为契约:
- 作品集应有一个 welcome section(欢迎区),其
id为welcome-section; - 欢迎区应包含一个含文本的
h1元素; - 作品集应有一个 projects section(作品区),其
id为projects; - 作品区应包含至少一个
class为project-tile的元素来容纳某个项目; - 作品区应包含至少一个指向项目的链接;
- 作品集应有一个 navbar(导航栏),其
id为navbar; - 导航栏应包含至少一个可点击、能在页面各区块间页内跳转的链接;
- 作品集应有一个
id为profile-link的链接,点击后在新标签页打开你的 GitHub 或 freeCodeCamp 主页; - 作品集应至少使用一个媒体查询(media query);
- 欢迎区的高度应等于视口高度;
- 导航栏应始终保持在视口顶部(页面滚动也不离开)。
表面看这些都是基础能力,但真正决定成败的,是隐藏在 Hints 段落里那套浏览器端 JS 断言——下面逐条翻译这些「阅卷标准」。
三、测试机制:Hints 里的 JS 断言是如何验收的
原文档在 --hints-- 段给出了与每条 User Story 对应的 JS 断言。读懂它们,就等于拿到了精确到像素的评分标准。
3.1 关键区块必须真实存在
const el = document.getElementById('welcome-section');
assert.isNotNull(el);
projects、navbar 两条断言结构完全相同。结论:welcome-section、projects、navbar 三个 id 必须一字不差地存在,任何拼写偏差(大小写、连字符、换行)都会直接失败。
3.2 欢迎区的语义与内容约束
assert.isAbove(
document.querySelectorAll('#welcome-section h1').length,
0,
'Welcome section should contain an h1 element '
);
assert.isAbove(
document.querySelectorAll('#welcome-section h1')?.[0]?.innerText?.length,
0,
'h1 element in welcome section should contain your name or camper name '
);
两条断言合起来的意思是:#welcome-section 内至少有一个 h1,且其中第一个 h1 的 innerText 长度大于 0。建议直接写姓名或 camp 昵称;只放注释或空白不算「有文本」。
3.3 作品区结构与链接约束
assert.isAbove(document.querySelectorAll('#projects .project-tile').length, 0);
assert.isAbove(document.querySelectorAll('#projects a').length, 0);
作品区 #projects 内至少要有一个 class="project-tile" 的容器,并且至少存在一个 <a> 链接指向某个真实项目(可以指向外部托管页或仓库内页面)。
3.4 导航栏必须是「锚点菜单」
const links = [...document.querySelectorAll('#navbar a')].filter(
nav => (nav?.getAttribute('href') || '').substring(0, 1) === '#'
);
assert.isAbove(links.length, 0, 'Navbar should contain an anchor link ');
测试会遍历 #navbar 内所有 <a>,过滤出 href 以 # 开头的链接并断言其数量大于 0。也就是说,导航栏里只放外链不算数,必须要有 href="#projects"、href="#welcome-section" 这类页内锚点链接。
3.5 个人主页链接:标签与打开方式双重校验
const el = document.getElementById('profile-link');
assert.isNotNull(el);
assert.strictEqual(el.tagName, 'A'); // 必须是 <a>
assert.strictEqual(el.target, '_blank'); // 必须新标签页打开
#profile-link 必须挂在真正的 <a> 元素上(用 <div> 冒充无效),且必须显式书写 target="_blank"。
3.6 至少一个媒体查询
const htmlSourceAttr = Array.from(document.querySelectorAll('source')).map(el => el.getAttribute('media'))
const cssCheck = new __helpers.CSSHelp(document).getCSSRules('media')
assert.isTrue(cssCheck.length > 0 || htmlSourceAttr.length > 0);
验收方式有两种:样式表中存在 @media 规则(通过 CSSHelp.getCSSRules('media') 探测),或文档中存在带 media 属性的 <source> 元素。常规做法是在 CSS 中书写 @media (max-width: ...);若 <link> 没写对导致 CSS 未被加载,这里会拿不到任何 @media 规则而失败——这也印证了原文档 Note 强调关联样式表的原因。
3.7 吸顶导航:唯一带「滚动后复检」的动态断言
const timeout = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
const navbar = document.getElementById('navbar');
assert.approximately(
navbar?.getBoundingClientRect().top, 0, 15,
"Navbar's parent should be body and it should be at the top of the viewport "
);
window.scroll(0, 500);
await timeout(1);
assert.approximately(
navbar?.getBoundingClientRect().top, 0, 15,
'Navbar should be at the top of the viewport even after scrolling '
);
window.scroll(0, 0);
这条断言隐藏了三个关键信息:
- 两次采样:先检查初始状态,再把页面滚动到
y=500、等待 1ms 后复检。「初始在顶、一滚就跑」的实现(例如仅把导航放在文档顶部而不做固定定位)会在第二次断言失败; - 判定基准:
getBoundingClientRect().top是导航栏相对当前视口的坐标,滚动后仍须近似为0(容差 15px)。只有position: fixed(或sticky等效布局)能持续满足; - 父级约束:断言消息提示 "Navbar's parent should be body",即
#navbar最好作为body的直接子元素,避免被滚动容器包裹产生额外偏移。
四、从零实现:HTML 骨架与 CSS 关键布局
原文档的 --seed-contents-- 提供的种子是两个空文件:
也就是说官方不给任何起始代码,HTML 与 CSS 都要你自己写。下面给出按测试要求整理、可直接运行的最小完整实现(讲解用,建议在此基础上加入你的真实内容与个人风格)。
4.1 index.html:语义化骨架
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles.css">
<title>Personal Portfolio</title>
</head>
<body>
<nav id="navbar">
<a href="#welcome-section">Home</a>
<a href="#projects">Projects</a>
<a href="#contact">Contact</a>
</nav>
<main>
<section id="welcome-section">
<h1>Hi, I'm Camper</h1>
<p>A front-end learner from freeCodeCamp</p>
</section>
<section id="projects">
<h2>These are some of my projects</h2>
<div class="project-tile">
<a href="https://your-demo-link.example/tribute" target="_blank">Tribute Page</a>
</div>
<div class="project-tile">
<a href="https://your-demo-link.example/survey" target="_blank">Survey Form</a>
</div>
</section>
<section id="contact">
<h2>Let's work together...</h2>
<a id="profile-link"
href="https://www.freecodecamp.org/your-username"
target="_blank" rel="noopener noreferrer">freeCodeCamp Profile</a>
</section>
</main>
</body>
</html>
对照需求自查:
| 需求编号 | 落实位置 |
|---|---|
| 1、2 | <section id="welcome-section"> 内含非空 <h1> |
| 3、4、5 | <section id="projects"> 内有 .project-tile 与指向项目的 <a> |
| 6、7 | #navbar 为 body 直接子元素,内含 href="#..." 锚点链接 |
| 8 | <a id="profile-link" target="_blank"> 指向 freeCodeCamp/GitHub 主页 |
4.2 styles.css:吸顶、满屏与媒体查询
/* 全局重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ---------- 导航栏:固定于视口顶部 ---------- */
#navbar {
position: fixed; /* 相对视口固定,是 3.7 断言的前提 */
top: 0;
left: 0;
width: 100%;
display: flex;
justify-content: flex-end;
background-color: #be3144;
z-index: 10;
}
#navbar a {
color: #fff;
padding: 1rem 1.5rem;
text-decoration: none;
}
#navbar a:hover {
background-color: #45567d;
}
/* ---------- 欢迎区:高度 = 视口高度 ---------- */
#welcome-section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100vh; /* 需求 10:满屏 */
background: linear-gradient(45deg, #3a3d40 0%, #181719 100%);
color: #f0f0f0;
}
/* ---------- 作品区 ---------- */
#projects {
text-align: center;
padding: 6rem 1rem;
background-color: #45567d;
color: #f0f0f0;
}
.project-tile {
display: inline-block;
margin: 1rem;
background: #303841;
}
.project-tile a {
display: block;
padding: 1.5rem;
color: #f0f0f0;
text-decoration: none;
}
.project-tile a:hover {
background: #be3144;
}
/* ---------- 联系区 ---------- */
#contact {
text-align: center;
padding: 6rem 1rem;
background-color: #303841;
color: #f0f0f0;
}
/* ---------- 媒体查询(需求 9):小屏适配 ---------- */
@media (max-width: 720px) {
#navbar {
justify-content: center;
}
.project-tile {
display: block;
width: 90%;
margin: 1rem auto;
}
}
三处与断言的强对应关系:
#navbar { position: fixed; top: 0; }—— 满足 3.7 的动态滚动断言(getBoundingClientRect().top ≈ 0);#welcome-section { height: 100vh; }—— 满足需求 10(100vh= 视口高度的 100%);- 文件末尾存在
@media—— 满足 3.6 的CSSHelp.getCSSRules('media')探测。
4.3 原文档官方 Solution 中的设计手法
原文档在 --solutions-- 给出了一套官方参考实现(HTML + CSS 双文件),其导航与满屏布局的核心片段如下:
/* 官方 Solution:导航栏固定 */
nav {
position: fixed;
width: 100%;
text-align: right;
font-size: 24pt;
top: 0%;
right: 5px;
background-color: #000000;
color: #ffffff;
}
/* 官方 Solution:媒体查询 */
@media (max-width: 500px) {
nav {
display: none; /* 小屏隐藏导航的响应式策略 */
}
}
/* 官方 Solution:各 section 满屏并垂直居中 */
#welcome-section {
display: table-cell;
vertical-align: middle;
width: 100vw;
height: 100vh;
}
官方实现的可提炼手法有三点:
- 用
position: fixed+top: 0%实现吸顶,再辅以right调整位置; - 借助
100vh/100vw让区块占满一屏,用display: table-cell; vertical-align: middle或 flex 做内容垂直居中; - 将
<nav id="navbar">放在body的首层直接子元素位置,满足 3.7 断言对父容器的约束。
此外,原文档还附赠了一个最简可通过样例(第二份 Solution),直观展示「过测」的下限:
<head><style>@media (max-width: 500px){nav{display: none;}}</style></head><body><nav id="navbar"><a href="#projects">text</a> | </nav><main><section id="welcome-section"><h1>text</h1></section><hr><section id="projects"><h1>Projects</h1><h2 class="project-tile"><a id="profile-link" target="_blank" href="https://freecodecamp.org">text</a></h2></section><hr></body></html>
有趣的是,这份最简实现把 #profile-link 放进了 #projects 内部、把 @media 直接内联进 <style> 标签——说明测试只检查存在性、位置与关键行为,并不限制结构归属或要求内联/外联写法。但认证的意义不止于「过测」:请务必在满足断言的基础上,写出有完整配色、真实项目链接与个人品牌的成品页面。
五、常见失败点与排查对照表
结合前文断言,把最容易翻车的点整理成「症状 → 根因 → 对应断言」对照表:
| 症状 | 根因 | 对应断言 |
|---|---|---|
welcome-section/projects/navbar 报 null |
id 拼写不一致(大小写、连字符、空格) |
getElementById(...) |
| h1 断言不通过 | #welcome-section 内无 h1,或 h1 内容为空 |
querySelectorAll('#welcome-section h1')、innerText.length |
| 导航栏有链接却报错 | href 不以 # 开头(放了外链) |
过滤后 links.length > 0 |
| 滚动后导航栏掉出视口 | 未用 position: fixed;或 #navbar 被包在滚动容器内 |
getBoundingClientRect().top 二次断言 |
| media query 断言失败 | CSS 未通过 <link> 正确加载,或确实没写 @media |
CSSHelp(...).getCSSRules('media') |
profile-link 断言失败 |
挂在非 <a> 元素上;或漏写 target="_blank" |
el.tagName、el.target |
| 页面整体无样式 | 忘记 <link rel="stylesheet" href="styles.css"> |
原文档 Note 强调项 |
排查技巧:直接在浏览器 DevTools Console 中执行与断言等价的命令,例如 document.getElementById('welcome-section')、document.querySelectorAll('#projects .project-tile').length;验证吸顶则手动 window.scrollTo(0, 500) 后读取 document.getElementById('navbar').getBoundingClientRect().top。
六、在仓库内继续深挖与自行验证
本挑战完整保存在仓库课程目录中,可继续查阅:
- 挑战全文(需求、User Stories、Hints 断言、空种子、官方 Solution 双版本):bd7158d8c242eddfaeb5bd13.md;
- Block 结构信息(
challengeOrder、usesMultifileEditor、helpCategory、blockLayout):build-a-personal-portfolio-webpage-project.json; - 超级模块 Block 顺序(可看到该项目前的课程块序列):responsive-web-design-22.json。
本地练习建议:新建一个独立文件夹,放入上面 4.1/4.2 的 index.html 与 styles.css,直接双击打开即可预览;若浏览器阻止了某些加载方式,可用任意静态服务器(如 python3 -m http.server)起一个本地服务访问。请始终保留原文档 Note 强调的 <link rel="stylesheet" href="styles.css">,这是外部样式表在多功能编辑器中生效、并被测试读取到的前提。
结语
「构建个人作品集网页」是整个响应式网页设计课程体系中的收官项目,也是对语义化 HTML、id/class 选择器、固定定位、视口单位与媒体查询的一次综合验收。把它当作一份「可执行的契约」:User Stories 定义需求,Hints 断言定义可验证的验收标准,官方 Solution 与最简样例则分别代表「参考质量」与「过测下限」。在这两者之间留出空间,注入你自己的项目、配色与故事——你得到的将不止是一张认证,而是一个可以长期维护、真正上线的个人主页。
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