纯前端实现软考中级·软件设计师备考学习网站(知识点 / 训练 / 模考 / 错题本 / 进度追踪,附完整源码)
> 一篇实战向的项目复盘:用纯 HTML + CSS + 原生 JS,零依赖、零构建、双击即可运行,实现一个功能完整的软考备考学习平台。包含六大模块、用户登录与 localStorage 数据持久化。文末附完整源码结构与关键代码。
一、前言
准备软考中级·软件设计师考试时,市面上的刷题工具要么需要联网注册,要么广告多、错题本不好用,知识点与题目往往割裂。于是萌生自己做一个的想法:
把知识点梳理、考点训练、模拟考试、错题巩固、进度追踪整合到一个页面;
纯前端、零依赖,双击 `index.html` 就能跑,离线可用;
用 `localStorage` 实现**多账户数据隔离,每个人的错题本、进度互不干扰;
题目基于公开大纲与高频考点原创编写,规避真题版权问题,同时保留备考训练价值。
本文记录整个实现过程与踩过的坑,适合想做练手项目的前端同学参考。

二、技术选型与设计思路

核心设计原则:数据与逻辑分离。题目、知识点、组卷配置作为独立数据文件挂到 `window`,业务逻辑层(store/ui/auth/app)只消费数据,便于后续扩充题库而不动逻辑代码。
三、项目结构
clin/
├── index.html # 入口,双击即可在浏览器打开
├── css/
│ └── style.css # 完整响应式样式(桌面/平板/手机)
└── js/
├── data/
│ ├── knowledge.js # 12章知识点数据(约110个考点要点)
│ ├── questions.js # 55道原创模拟题(分章节/考点/难度)
│ └── examPapers.js # 3套模拟卷配置 + 高频考点 + 组卷函数
├── store.js # localStorage数据层(账户/进度/错题/模考)
├── ui.js # 通用UI(toast/modal/题卡渲染/计时器)
├── auth.js # 注册/登录视图与校验
└── app.js # hash路由 + 六大模块视图渲染
`index.html` 用最朴素的方式按依赖顺序引入脚本:
html
```html
<script src="js/data/knowledge.js"></script>
<script src="js/data/questions.js"></script>
<script src="js/data/examPapers.js"></script>
<script src="js/store.js"></script>
<script src="js/ui.js"></script>
<script src="js/auth.js"></script>
<script src="js/app.js"></script>
<script>document.addEventListener('DOMContentLoaded', function(){ App.start(); });</script>
```
四、数据层设计:localStorage 多账户持久化
备考数据天然按用户隔离------张三的错题本不能混进李四的进度。我用 键名加账户后缀 的方式分区存储:
sde_users:所有账户 `{ 用户名: { pwdHash, displayName, createdAt } }`
sde_session:当前登录用户名
sde_data_<用户名>:该用户的全部学习数据(progress / practiced / wrongBook / examRecords)
每个用户的数据结构:
javascript
```js
function emptyData() {
return {
progress: {}, // { chapterId: { read, mastered, lastVisit } }
practiced: {}, // { qid: { count, correct, level, lastTs } }
wrongBook: [], // [{ qid, chapter, topic, wrongCount, lastWrong }]
examRecords: [], // [{ paperId, title, total, correct, score, pass, details }]
createdAt: Date.now(),
lastActive: Date.now()
};
}
```
注册逻辑(含用户名格式与密码长度校验):
javascript
```js
register: function (username, password, displayName) {
username = (username || '').trim();
if (!username || !password) return { ok: false, msg: '用户名和密码不能为空' };
if (!/^[\w@.-]{3,20}$/.test(username)) return { ok: false, msg: '用户名仅允许字母数字下划线及.@-,3-20位' };
if (password.length < 4) return { ok: false, msg: '密码至少4位' };
var users = getUsers();
if (users[username]) return { ok: false, msg: '该用户名已存在' };
users[username] = { pwdHash: hash(password), displayName: displayName || username, createdAt: Date.now() };
setUsers(users);
localStorage.setItem(K_SESSION, username);
writeJSON(dataKey(username), emptyData());
return { ok: true, msg: '注册成功' };
}
```
> 说明:密码用了一个简单的字符串哈希(非加密),仅适用于单机学习场景,生产环境务必换用真正的加密与服务端存储。
五、核心模块实现
5.1 知识点系统梳理(按大纲章节分类)
数据按官方大纲组织成 12 章,每章含若干考点要点:

javascript
```js
window.KNOWLEDGE = [
{
id: 'ch01',
title: '第1章 计算机组成与体系结构',
summary: '数据表示、运算器与控制器、存储体系、总线与I/O、指令系统与流水线。',
points: [
{ k: '数据的表示', v: '原码/反码/补码/移码...计算机中整数一律用补码表示...' },
{ k: '流水线技术', v: '流水线周期=最长子段时间。n条指令k级流水线时间=(k+n-1)×周期...' },
// ...
]
},
// ch02 ~ ch12 ...
];
```
进入章节详情时自动标记"已读",并可拖动滑块设置掌握度,数据实时回写:

javascript
```js
markChapterRead: function (chapterId) {
var d = getData();
d.progress[chapterId] = d.progress[chapterId] || { read: false, mastered: 0 };
d.progress[chapterId].read = true;
d.progress[chapterId].lastVisit = Date.now();
saveData(null, d);
}
```
5.2 考点强化训练(难度分级 + 错题自动入库)
支持按章节 / 难度(★入门 / ★★中等 / ★★★进阶)/ 题型 / 题量筛选,洗牌后逐题作答。关键是**答错自动入错题本、答对自动移出**:
javascript
```js
recordPractice: function (qid, chapter, topic, isCorrect, level) {
var d = getData();
var p = d.practiced[qid] || { count: 0, correct: 0, level: level };
p.count++; if (isCorrect) p.correct++; p.level = level; p.lastTs = Date.now();
d.practiced[qid] = p;
if (!isCorrect) {
var found = d.wrongBook.filter(function (w) { return w.qid === qid; })[0];
if (found) { found.wrongCount++; found.lastWrong = Date.now(); }
else d.wrongBook.push({ qid: qid, chapter: chapter, topic: topic, wrongCount: 1, lastWrong: Date.now() });
} else {
d.wrongBook = d.wrongBook.filter(function (w) { return w.qid !== qid; }); // 答对移出
}
saveData(null, d);
}
```
判分兼容单选 / 多选(多选需选项集合完全一致才算对):
javascript
```js
checkAnswer: function (q, chosen) {
var ans = q.answer instanceof Array ? q.answer.slice().sort() : [q.answer];
var c = chosen.slice().sort();
if (c.length !== ans.length) return false;
for (var i = 0; i < c.length; i++) if (c[i] !== ans[i]) return false;
return true;
}
```
5.3 真题模拟考试(计时 + 评分 + 题号跳转)
模考是最复杂的模块。组卷在运行时按配置从题库抽取并洗牌,避免数据冗余:

javascript
```js
window.buildExamQuestions = function (paper) {
var pool = (window.QUESTIONS || []).slice();
var qs;
if (paper.mode === 'all') qs = pool.filter(function (q) { return q.type === 'single'; });
else if (paper.mode === 'levels') qs = pool.filter(function (q) { return paper.levels.indexOf(q.level) >= 0; });
else if (paper.mode === 'chapters') qs = pool.filter(function (q) { return paper.chapters.indexOf(q.chapter) >= 0; });
// 洗牌
for (var i = qs.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = qs[i]; qs[i] = qs[j]; qs[j] = t;
}
return qs;
};
```
计时器是一个独立小工具,倒计时到 0 自动交卷:
javascript
```js
Timer: function (seconds, onTick, onEnd) {
var left = seconds, t = null, running = false;
function fmt(s) { var m = Math.floor(s / 60), r = s % 60; return (m<10?'0':'')+m+':'+(r<10?'0':'')+r; }
return {
start: function () {
if (running) return; running = true;
t = setInterval(function () {
left--; onTick && onTick(left, fmt(left));
if (left <= 0) { clearInterval(t); running = false; onEnd && onEnd(); }
}, 1000);
},
stop: function () { clearInterval(t); running = false; },
fmt: fmt
};
}
```
交卷判分并生成成绩记录(含每题作答明细,供后续回顾):
javascript
```js
function submitExam(timeup) {
var s = examState;
s.timer && s.timer.stop();
var correct = 0, details = [];
s.qs.forEach(function (q) {
var chosen = s.answers[q.id] || [];
var ok = UI.checkAnswer(q, chosen);
if (ok) correct++;
details.push({ qid: q.id, chosen: chosen, correct: ok });
});
var total = s.qs.length;
var score = Math.round(correct * 100 / total);
var pass = (score / 100) >= s.paper.passLine;
SDEStore.recordExam({
id: 'exam_' + Date.now(), paperId: s.paper.id, title: s.paper.title,
total: total, correct: correct, score: score, pass: pass,
date: Date.now(), durationSec: Math.floor((Date.now() - s.start) / 1000), details: details
});
showExamResult(rec);
}
```
模考界面还做了**题号网格跳转面板**(已作答高亮、当前题高亮),方便快速定位未作答题目。
5.4 错题本管理
错题本读取时把题目数据与错题记录合并渲染:
javascript
```js
getWrongQuestions: function () {
var d = getData();
var map = {};
(window.QUESTIONS || []).forEach(function (q) { map[q.id] = q; });
return d.wrongBook.map(function (w) {
var q = map[w.qid];
return q ? Object.assign({}, q, { _wrong: w }) : null;
}).filter(Boolean);
}
```
重做答对后自动移出错题本,支持单题移出与一键清空。
5.5 学习进度追踪
进度页聚合多维度数据:章节阅读进度、掌握度、各章节练习正确率、模考成绩趋势。趋势用纯 CSS 柱状图呈现:
javascript
```html
<div class="trend-chart">
<div class="trend-col"><div class="trend-bar" style="height:80%"></div><span>80</span></div>
...
</div>
```
5.6 用户注册登录
登录卡片用 tab 切换登录/注册,表单校验失败用 toast 提示,成功后顶栏显示 `Hi, 昵称`。游客可浏览知识点,访问训练/模考/错题/进度时自动弹出登录卡片。

六、UI/UX 与响应式
配色用 CSS 变量统一管理,蓝色系主色符合学习平台信任感:
javascript
```css
:root{
--pri:#2563eb; --pri-d:#1d4ed8; --pri-l:#dbeafe;
--suc:#16a34a; --dgr:#dc2626; --warn:#f59e0b;
--bg:#f4f6fb; --card:#fff; --ink:#1f2937; --mut:#6b7280;
--bd:#e5e7eb; --r:10px;
}
```
响应式断点适配三档:
javascript
```css
@media (max-width:960px){ .stats-grid{grid-template-columns:repeat(2,1fr)} .dashboard-grid{grid-template-columns:1fr} }
@media (max-width:720px){
.topbar-inner{flex-wrap:wrap;height:auto}
.nav{order:3;width:100%;overflow-x:auto;flex-wrap:nowrap} /* 手机导航横向滚动 */
.nav-link{white-space:nowrap;padding:6px 10px}
}
```
题卡交互细节:选项点击高亮、提交后正确答案绿色、错误选项红色、解析可折叠展开,难度用星级 + 章节/题型/考点三标签直观区分。
七、关键踩坑:跨视图事件监听冲突
开发中遇到一个隐蔽 bug:**从"训练"页跳到"模考"页后,模考的"下一题"按钮点不动了。排查过程
最初各模块用 `document.addEventListener('click', function nav(){...})` 绑定翻页逻辑,并在命中后 `removeEventListener`。但问题是:
-
练习页绑了 `document` 级 `nav`,匹配 `data-act="next"`;
-
用户没走"下一题"正常出口,而是点了顶部导航直接跳到模考页;
-
旧的练习 `nav` 监听器**没被清除**,仍挂在 `document` 上;
-
在模考点"下一题",事件先命中旧练习 `nav`,它去操作 `practiceSession`(此时已为旧数据或 null),**抛异常**;
-
异常中断了事件传播,新的模考 `nav` 监听器收不到事件 → 按钮看起来"没反应"。
修复方案
引入**视图级监听器管理**:所有模块的临时点击监听改绑到主内容容器 `main` 上,并在路由切换时统一清理:
javascript
```js
var _viewListeners = [];
function onMainClick(handler) {
var fn = function (e) { try { handler(e); } catch (err) { console.error(err); UI.toast('操作异常: ' + err.message, 'error'); } };
main.addEventListener('click', fn);
_viewListeners.push(fn);
return fn;
}
function clearViewListeners() {
_viewListeners.forEach(function (fn) { main.removeEventListener('click', fn); });
_viewListeners = [];
}
function route() {
clearViewListeners(); // 切视图前先清理,避免跨视图残留
// ... 解析 hash、渲染对应视图
}
```
各模块把原来的 `document.addEventListener('click', function nav(){...})` 统一替换为 `onMainClick(function nav(){...})`,并去掉手动的 `removeEventListener`(切视图时统一清理)。`onMainClick` 还包了 `try/catch`,即使某个处理器抛错也不会中断后续逻辑。
修复后重新用浏览器自动化回归验证,"下一题 / 上一题 / 题号跳转"全部正常。
> 经验:**全局事件委托一定要有清晰的生命周期管理**。用容器级监听 + 切视图清理,比满屏 `removeEventListener` 省心且不易漏。
八、验证效果
用本地静态服务器 + 浏览器自动化做了两轮回归,验证结果:

7 个 JS 文件全部通过 `node --check` 语法校验。
九、总结与扩展
本项目用纯前端技术栈实现了一个功能闭合的备考学习平台,核心收获:
- 零依赖的工程组织:数据/逻辑/视图分层,`<script src>` 顺序加载,file 协议直接跑,适合做学习类小工具的分发;
2.localStorage 多账户隔离:键名加后缀分区,简单有效;
-
hash 路由 + 视图级事件管理:解决了多视图监听器残留的隐蔽 bug;
-
原创题库的备考价值:基于公开大纲与高频考点编写,既合规又能复现考点、题型、难度。
可继续扩展的方向
接入 IndexedDB 支持更大题库与图片题;
题库改为 JSON 异步加载(需 http 服务,或用 `fetch` + blob);
增加每日打卡、学习日历、薄弱章节智能推荐;
后端化:用 Node + 数据库替换 localStorage,实现真正的多端同步。