第10章 10.2 终极项目:仿 V2EX 论坛前端
上一章我们学会了用 Jest 写测试,确保代码质量万无一失。但光有测试可不行,咱得真刀真枪做个完整项目——这就好比学车时背熟了交规,还得真正上路开一圈才知道会不会。今天这章,就是你 JavaScript 学习之路的「毕业项目」:仿 V2EX 论坛前端。做完这一章,你就是有项目经验的前端开发者了,不是「会几个语法」的小白。
🎯 开场 3 分钟:为什么要学这个?
你有没有这种感觉——学了一堆 ES6 语法、学会了 Vue/React 基础,但看到别人做的论坛、博客、后台系统,还是不知道「从哪里下手」?
这就是典型的「知识点碎片化」问题。
V2EX 是程序员圈子里很火的论坛,它的界面简洁、功能典型:帖子列表、详情页、评论、主题切换。如果你也能手写一个仿 V2EX 的前端,说明你掌握了:
- 页面路由(不同 URL 展示不同内容)
- 状态管理(登录状态、主题偏好存在哪里)
- 数据请求(从 API 获取帖子列表)
- 主题切换(白天/黑夜模式)
学完这章,你手里会有一整个「可运行\n\n
\n\n
\n\n、可改造」的项目代码。 以后面试「你做过什么项目」,这就可以拿出来讲了。
🧱 基础 25 分钟:核心概念
在做项目之前,咱们先把手里的工具认清楚。就像装修房子,你得知道锤子、钉子、电钻都干嘛用,才能动手。
10.2.1 什么是 SPA(单页应用)?
是什么:传统网站你点个链接,浏览器会「刷」一下加载新页面。SPA 呢?点链接只是「换了个内容」,页面本身不刷新,就像手机 App 一样流畅。
生活类比:你刷微信朋友圈,往下滑内容变了,但微信 App 本身没关没开——这就是 SPA 的感觉。传统的知乎每次点文章要等页面转圈,SPA 知乎就丝滑多了。
为什么用:用户体验好、页面切换快、可以做一些「页面不刷新但内容变」的炫酷效果。
怎么用:
// 传统多页:一个链接 = 一次完整页面加载
// <a href="/about.html">关于</a>
// SPA:一个链接 = 只更新页面的一部分
// 用 Hash(#)实现路由,#后面的变化不会触发页面刷新
window.addEventListener('hashchange', () => {
const hash = window.location.hash; // 比如 #/topic/123
console.log('当前路由:', hash);
});
window.location.hash 就是 # 后面的部分。你改它,浏览器不会刷新,只会触发 hashchange 事件。这就是 SPA 路由的核心原理。
10.2.2 状态管理:数据放哪儿?
是什么:状态就是「页面当前的数据」——比如当前登录的用户是谁、帖子列表有哪些、选了哪个主题。
生活类比:你做饭时,灶台上摆着各种调料(盐、酱油、醋)。这些调料就是「状态」,你随时会用到它们来做菜。
为什么用:状态管理做得好,代码才不会乱成一锅粥。想象你做菜时调料乱放,找起来费劲死了。
怎么用:
// 最简单的方式:用一个对象存所有状态
const store = {
user: null, // 当前登录用户,null表示未登录
theme: 'light', // 主题:light白天 / dark黑夜
posts: [], // 帖子列表
currentPost: null, // 当前查看的帖子详情
};
// 读取状态
console.log(store.theme); // 'light'
// 修改状态(通过函数,统一管理)
function setTheme(newTheme) {
store.theme = newTheme;
render(); // 状态变了,重新渲染页面
}
10.2.3 数据请求:怎么从 API 拿数据?
是什么:前端不存数据(帖子、用户信息都存在后端服务器),需要「请求」才能拿到。
生活类比:你去餐厅吃饭,厨房(后端)准备食物,你(前端)要点单(发请求)才能吃到。外卖小哥就是网络请求,把食物从厨房送到你桌上。
为什么用:真实项目里,数据都在后端。前端必须学会「要数据」和「收数据」。
怎么用:
// 用 fetch API 发送请求(类比:打电话订货)
fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json()) // 把收到的货(JSON)转成对象
.then(data => {
console.log('拿到帖子数据:', data);
store.posts = data; // 存到状态里
})
.catch(error => {
console.error('请求失败了:', error);
});
// 更现代的写法:async/await(等货到了再继续)
async function loadPosts() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await response.json();
store.posts = data;
render();
} catch (error) {
console.error('加载失败:', error);
}
}
10.2.4 主题切换:白天/黑夜模式
是什么:用户可以切换页面配色,深色主题对眼睛友好。
生活类比:手机亮度调节——白天调亮,晚上调暗,保护眼睛。
为什么用:用户体验好、夜间阅读不刺眼、程序员社区很多都爱用深色主题(装逼)。
怎么用:
// 切换主题的函数
function toggleTheme() {
store.theme = store.theme === 'light' ? 'dark' : 'light';
applyTheme();
localStorage.setItem('theme', store.theme); // 存到本地,下次打开还是这个主题
}
// 应用主题到页面
function applyTheme() {
document.body.className = store.theme === 'dark' ? 'dark-mode' : '';
}
// 页面加载时,读取保存的主题
function initTheme() {
const saved = localStorage.getItem('theme');
if (saved) {
store.theme = saved;
applyTheme();
}
}
localStorage 是浏览器自带的小仓库,存进去后关掉浏览器再打开还在。用户选的主题,不会因为刷新页面就消失。
🔥 实战 35 分钟:三个递进项目
项目 1(5分钟):最简单的 SPA 路由
先做最小可运行版本,理解路由怎么工作的。
// 项目1:Hash路由演示
// 保存到 router.html,用浏览器直接打开
const routes = {
'/': '<h1>首页</h1><p>欢迎来到仿 V2EX</p>',
'/topic': '<h1>帖子列表</h1><p>这里展示所有帖子</p>',
'/about': '<h1>关于</h1><p>这是一个仿 V2EX 的论坛前端</p>',
};
function router() {
const hash = window.location.hash.slice(1) || '/'; // 去掉#号
const app = document.getElementById('app');
app.innerHTML = routes[hash] || '<h1>404</h1><p>页面不存在</p>';
}
// 监听 hash 变化
window.addEventListener('hashchange', router);
// 初始加载
router();
<!-- 完整的 HTML -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SPA 路由演示</title>
</head>
<body>
<nav>
<a href="#/">首页</a>
<a href="#/topic">帖子</a>
<a href="#/about">关于</a>
</nav>
<div id="app"></div>
<script src="router.js"></script>
</body>
</html>
预期输出:点击「首页」「帖子」「关于」链接,页面内容变化,但地址栏 URL 一直在变,页面却没刷新。
一句话解释:当 #/ 后面的内容变了,浏览器不刷新页面,只触发 hashchange 事件,我们趁机更新 #app 里的 HTML。
项目 2(15分钟):带数据请求的帖子列表
现在加点真实数据,从 JSONPlaceholder API 拿帖子列表来展示。
// 项目2:帖子列表页
// 保存到 posts.html
const store = {
posts: [],
loading: false,
error: null,
};
// 加载帖子
async function loadPosts() {
store.loading = true;
render();
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
store.posts = await response.json();
store.error = null;
} catch (e) {
store.error = '加载失败:' + e.message;
}
store.loading = false;
render();
}
// 渲染页面
function render() {
const app = document.getElementById('app');
if (store.loading) {
app.innerHTML = '<p>加载中...</p>';
return;
}
if (store.error) {
app.innerHTML = `<p style="color:red">${store.error}</p>`;
return;
}
const postsHtml = store.posts.map(post => `
<div class="post-item">
<h3>${post.title}</h3>
<p>${post.body}</p>
<small>帖子ID: ${post.id}</small>
</div>
`).join('<hr>');
app.innerHTML = `
<h1>帖子列表</h1>
<button onclick="loadPosts()">刷新</button>
<hr>
${postsHtml}
`;
}
// 初始化
loadPosts();
<!-- 完整的 HTML -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>帖子列表</title>
<style>
.post-item { padding: 10px; border: 1px solid #ddd; margin: 10px 0; }
.post-item h3 { margin: 0 0 10px; color: #333; }
.post-item small { color: #666; }
</style>
</head>
<body>
<div id="app"></div>
<script src="posts.js"></script>
</body>
</html>
预期输出:页面显示「加载中...」,2秒内变成10条帖子的列表,每条有标题和正文。
一句话解释:fetch 发请求拿到数据后,存到 store.posts,然后 render() 把数据变成 HTML 塞到页面里。
项目 3(15分钟):完整仿 V2EX 论坛(路由 + 状态 + 主题切换)
组合前两个项目的所有能力,做一个有点样子的小论坛。
// 项目3:完整仿 V2EX 论坛
// 保存到 v2ex.html
// ===== 状态管理 =====
const store = {
user: { name: '小明', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=xiaoming' },
theme: localStorage.getItem('theme') || 'light',
currentView: 'list', // list | detail
posts: [],
currentPost: null,
loading: false,
};
// ===== 工具函数 =====
function $(selector) {
return document.querySelector(selector);
}
// ===== 主题切换 =====
function toggleTheme() {
store.theme = store.theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', store.theme);
applyTheme();
}
function applyTheme() {
document.body.className = store.theme === 'dark' ? 'dark-mode' : '';
$('#themeBtn').textContent = store.theme === 'light' ? '🌙 深色' : '☀️ 浅色';
}
// ===== 数据加载 =====
async function loadPosts() {
store.loading = true;
render();
try {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=8');
store.posts = await res.json();
} catch (e) {
alert('加载失败');
}
store.loading = false;
render();
}
async function loadPostDetail(id) {
store.loading = true;
render();
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
store.currentPost = await res.json();
// 加载评论(用同一个 API 假装有评论)
const commentsRes = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}/comments`);
store.currentPost.comments = await commentsRes.json();
} catch (e) {
alert('加载失败');
}
store.loading = false;
store.currentView = 'detail';
render();
}
// ===== 路由(基于 hash)=====
function handleRoute() {
const hash = window.location.hash || '#/';
if (hash === '#/' || hash === '#/list') {
store.currentView = 'list';
loadPosts();
} else if (hash.startsWith('#/topic/')) {
const id = hash.split('/')[2];
loadPostDetail(id);
} else if (hash === '#/about') {
store.currentView = 'about';
render();
}
}
window.addEventListener('hashchange', handleRoute);
// ===== 渲染函数 =====
// 渲染头部
function renderHeader() {
$('#header').innerHTML = `
<div class="header-left">
<span class="logo">V2EX</span>
</div>
<div class="header-right">
<span class="user-info">
<img src="${store.user.avatar}" class="avatar" width="30" height="30">
${store.user.name}
</span>
<button id="themeBtn" onclick="toggleTheme()">
${store.theme === 'light' ? '🌙 深色' : '☀️ 浅色'}
</button>
</div>
`;
}
// 渲染导航
function renderNav() {
$('#nav').innerHTML = `
<a href="#/" class="${store.currentView === 'list' ? 'active' : ''}">帖子列表</a>
<a href="#/about" class="${store.currentView === 'about' ? 'active' : ''}">关于</a>
`;
}
// 渲染内容区
function renderContent() {
const content = $('#content');
if (store.loading) {
content.innerHTML = '<div class="loading">加载中...</div>';
return;
}
if (store.currentView === 'list') {
content.innerHTML = `
<h2>最新帖子</h2>
<div class="post-list">
${store.posts.map(post => `
<div class="post-card" onclick="location.hash='#/topic/${post.id}'">
<div class="post-title">${post.title}</div>
<div class="post-body">${post.body}</div>
<div class="post-meta">帖子 #${post.id} · 点击查看详情</div>
</div>
`).join('')}
</div>
`;
} else if (store.currentView === 'detail' && store.currentPost) {
const p = store.currentPost;
content.innerHTML = `
<button onclick="location.hash='/'">← 返回列表</button>
<h2>${p.title}</h2>
<p>${p.body}</p>
<hr>
<h3>评论</h3>
${(p.comments || []).map(c => `
<div class="comment">
<strong>${c.email}</strong>
<p>${c.body}</p>
</div>
`).join('')}
`;
} else if (store.currentView === 'about') {
content.innerHTML = `
<h2>关于这个项目</h2>
<p>这是一个仿 V2EX 的论坛前端演示项目。</p>
<p>本项目展示了:</p>
<ul>
<li>Hash 路由实现 SPA</li>
<li>状态集中管理</li>
<li>fetch API 请求数据</li>
<li>主题切换(白天/深色模式)</li>
</ul>
`;
}
}
function render() {
renderHeader();
renderNav();
renderContent();
applyTheme();
}
// ===== 初始化 =====
document.addEventListener('DOMContentLoaded', () => {
handleRoute();
});
<!-- 完整的 HTML -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>仿 V2EX 论坛</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; transition: all 0.3s; }
body.dark-mode { background: #1a1a1a; color: #e0e0e0; }
body.dark-mode .post-card { background: #2a2a2a; border-color: #444; }
body.dark-mode .post-card:hover { background: #333; }
body.dark-mode .comment { background: #2a2a2a; }
#header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; background: #fff; border-bottom: 1px solid #e0e0e0; }
body.dark-mode #header { background: #252525; border-color: #444; }
.logo { font-weight: bold; font-size: 1.5em; color: #007aff; }
.header-right { display: flex; align-items: center; gap: 15px; }
.avatar { border-radius: 50%; }
button { padding: 8px 15px; cursor: pointer; border: 1px solid #ddd; background: #fff; border-radius: 5px; }
body.dark-mode button { background: #333; color: #e0e0e0; border-color: #555; }
#nav { display: flex; gap: 20px; padding: 10px 20px; background: #fff; border-bottom: 1px solid #e0e0e0; }
body.dark-mode #nav { background: #252525; border-color: #444; }
#nav a { text-decoration: none; color: #666; padding: 5px 10px; border-radius: 3px; }
body.dark-mode #nav a { color: #aaa; }
#nav a.active { background: #007aff; color: #fff; }
#content { max-width: 800px; margin: 20px auto; padding: 0 20px; }
.post-card { background: #fff; padding: 15px; margin-bottom: 15px; border-radius: 8px; cursor: pointer; border: 1px solid #e0e0e0; transition: all 0.2s; }
.post-card:hover { background: #fafafa; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.post-title { font-size: 1.1em; font-weight: bold; margin-bottom: 8px; color: #007aff; }
body.dark-mode .post-title { color: #5ac8fa; }
.post-body { color: #555; margin-bottom: 10px; line-height: 1.5; }
body.dark-mode .post-body { color: #aaa; }
.post-meta { font-size: 0.85em; color: #999; }
.comment { background: #fff; padding: 12px; margin: 10px 0; border-radius: 5px; border: 1px solid #eee; }
body.dark-mode .comment { background: #2a2a2a; border-color: #444; }
.loading { text-align: center; padding: 50px; color: #999; }
</style>
</head>
<body>
<header id="header"></header>
<nav id="nav"></nav>
<main id="content"></main>
<script src="v2ex.js"></script>
</body>
</html>
预期输出:打开页面显示头部(Logo + 用户头像 + 主题按钮)+ 导航(帖子列表、关于)+ 8条帖子卡片。点击帖子跳转到详情页(有评论),点击「深色」按钮整个页面变深色且记住选择。
一句话解释:所有状态存在 store 里,所有渲染通过 render() 函数,路由变化触发 handleRoute() 更新视图。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:异步没等完就读数据
// ❌ 错误:没等数据回来就读
function load() {
fetch('/api/posts').then(res => res.json()).then(data => {
store.posts = data;
});
render(); // 此时 store.posts 还是空的!
}
// ✅ 正确:等数据回来再渲染
async function load() {
const res = await fetch('/api/posts');
store.posts = await res.json();
render(); // 此时数据已经拿到了
}
原因:fetch 是异步的,像点外卖——你不能等餐到了之前就开吃。
坑 2:直接在 state 上改数组(Vue/React 常见)
// ❌ 错误:直接 push,不触发响应式更新
store.posts.push(newPost);
// ✅ 正确:赋值新数组,触发响应式更新
store.posts = [...store.posts, newPost];
原因:有些框架(Vue2、React)监听的是「赋值操作」,不监听「数组内部变化」。
坑 3:忘了处理请求失败
// ❌ 错误:没写错误处理,网络断了就卡死
fetch('/api/posts')
.then(res => res.json())
.then(data => console.log(data));
// ✅ 正确:catch 捕获错误
fetch('/api/posts')
.then(res => {
if (!res.ok) throw new Error('请求失败');
return res.json();
})
.then(data => console.log(data))
.catch(err => console.error('出错了:', err));
原因:网络请求可能失败(断网、服务器崩),不处理的话用户看到页面卡住不知道为什么。
坑 4:localStorage 存对象要 JSON 序列化
// ❌ 错误:直接存对象
localStorage.setItem('user', { name: '小明' }); // 存的是 "[object Object]"
// ✅ 正确:转成 JSON 字符串
localStorage.setItem('user', JSON.stringify({ name: '小明' }));
// 读取时再转回来
const user = JSON.parse(localStorage.getItem('user'));
原因:localStorage 只能存字符串,对象要「序列化」成字符串才能存。
性能小优化:减少重复渲染
// ❌ 每次数据变化都重新渲染整个页面
function setPosts(posts) {
store.posts = posts;
render(); // 100个帖子全重新画一遍,浪费
}
// ✅ 只更新变化的部分
function setPosts(posts) {
store.posts = posts;
renderPostList(); // 只更新帖子列表那块 DOM
}
调试技巧:console.log 不是万能的
// 基础调试
console.log('变量值:', someVar);
// 对象打印更清晰
console.log('对象内容:', JSON.stringify(obj, null, 2));
// 看调用栈(谁调的我)
function trace() {
console.trace('trace here');
}
// 用 debugger 断点(在 Chrome DevTools 看)
function buggy() {
const x = 1;
debugger; // 代码停在这里,可以看此时所有变量
console.log(x);
}
✏️ 练习题
练习 1(2分钟):改主题初始值
- 输入:把
store.theme的初始值从'light'改成'dark' - 预期输出:页面首次加载就是深色主题
- 提示:直接在
store定义那里改就行
练习 2(2分钟):加一个「没有帖子」的判断
- 输入:在
renderContent的if (store.currentView === 'list')里,加一个判断,当store.posts.length === 0时显示「暂无帖子」 - 预期输出:帖子列表为空时显示「暂无帖子」,而不是空白
- 提示:用三元运算符或 if 判断
练习 3(3分钟):给帖子列表加序号
- 输入:把帖子卡片从
帖子 #${post.id}改成显示「第 X 条帖子」(X 从 1 开始) - 预期输出:显示「第 1 条帖子」「第 2 条帖子」...
- 提示:用
posts.map((post, index) => ...),index + 1就是序号
练习 4(5分钟):处理新 API 数据
- 输入:把
loadPosts改成请求https://jsonplaceholder.typicode.com/users,在列表里显示用户名而不是帖子标题 - 预期输出:页面显示用户名字列表
- 提示:API 返回的每个对象有
name字段
练习 5(5分钟):分析报错并修复
- 输入:下面代码运行后页面空白,console 有红字报错
const response = fetch('https://jsonplaceholder.typicode.com/posts');
const data = response.json();
console.log(data);
- 预期输出:
console.log打印出帖子数组 - 提示:
fetch返回的是 Promise,要用await或.then()等待
作业:做一个「仿 V2EX 帖子搜索小工具」
需求描述:在项目 3 的基础上,加一个搜索功能。用户输入关键词,筛选出标题或正文中包含该词的帖子。
功能点:
1. 导航栏右边加一个搜索框
2. 输入关键词后按回车,筛选显示匹配的帖子
3. 搜索框旁边加「清除」按钮,恢复显示全部帖子
加分项:
1. 搜索时显示「正在搜索...」
2. 没有匹配结果时显示「没有找到包含 XX 的帖子」
验收标准:
- 能跑起来(直接用浏览器打开 HTML 文件)
- 搜索功能正常工作
- 代码有适当注释
提交方式:把代码发到评论区,或者建个 GitHub Gist 把链接贴过来。
📚 总结 + 资源
本文学到的 3 个核心点:
1. SPA 路由:用 hashchange 事件监听 URL 变化,实现页面不刷新的切换
2. 状态管理:把所有数据放 store 对象里,通过统一函数修改和渲染
3. 数据请求:fetch + async/await 从 API 拿数据,记得错误处理
延伸学习资源:
- MDN Web Docs - Fetch API — 官方文档,有中文,例子很全
- 《JavaScript 高级程序设计》第 4 版 — 红宝书,第 24 章讲得很好
- Vue 官方教程 — 如果你想用框架实现同样功能,Vue 会让代码更简洁
互动钩子:你做这个仿 V2EX 项目时,遇到最大的坑是什么?是异步请求还是路由跳转?评论区聊聊,老粉优先回复!
🎉 恭喜你学完了「JavaScript 从入门到精通」全系列!
现在你手里有了:语法基础 + DOM 操作 + 事件处理 + 网络请求 + 测试 + 完整项目。接下来你可以:
- 继续深耕前端:学 React/Vue,把今天的项目用框架重写一遍
- 进军后端:Node.js 让你用 JS 写服务器,真正成为「全栈」
- 做自己的小工具:浏览器扩展、油猴脚本、自动化脚本,都是 JS 的战场
路还长,但最难的那段你已经走完了。加油!

评论(0)