1. 从一句话需求到可交付页面:整体设计思路
上周同事甩过来一句话:"能不能做个 HTML 选择题页面,给新来的实习生练手用?"需求就一行,但真做起来,能拆出的东西不少——题型怎么定、判分在哪算、答到一半刷新了要不要保住进度、手机上行不行。这篇文章就把我从零做这个页面的完整过程摊开讲,包括代码、参数选择和我自己踩过的坑。不管你是刚学 HTML 想找个练手项目,还是需要给团队做一个轻量随堂小测,这套方案都能直接拿去改。
先用一句话说清楚这东西是什么:它是一个跑在浏览器里的单页应用,用原生 HTML 搭结构、CSS 管样式、JavaScript 管题库渲染和判分,不依赖任何框架和构建工具,双击打开就能用。适合的场景很具体:内部培训随堂测、在线课程的小节测验、面试笔试题原型、给学生演示前端交互的教学素材。
1.1 先把需求拆成四件事
拿到"做个选择题页面"这种模糊需求,我习惯先把边界定死。第一步问题型:只有单选,还是单选多选混排?第二步问判分时机:边答边判,还是交卷后统一给结果?第三步问题库规模:十道题和三百道题的架构完全不同,前者可以直接内联在 JS 里,后者必须有独立数据文件甚至后端接口。第四步问使用环境:只在自己的电脑上演示,还是要发给别人用手机打开?
这四个问题的答案直接决定后面的技术走向。我这个项目最终定的是:单选为主、多选题混排、交卷后统一判分并展示解析、题库二十道左右用独立 JSON 存放、必须支持手机。定完边界,剩下的就是纯执行,不会再中途反复改结构。
提示:需求越模糊,越要在动手前把上面四个问题问清楚。我见过太多人闷头写完八十行 CSS,结果对方一句"我要能在手机上答题"就得重做布局。
1.2 技术选型:为什么是原生 HTML + CSS + JS
很多人第一反应是上框架,觉得用组件库搭表单更省事。但这类页面恰恰是原生方案的主场。原因有三:其一,一个选择题页面的核心交互只有"选中"和"提交"两个动作,状态量极小,用原生表单元素自带的选中状态就够了,引入状态管理反而是杀鸡用牛刀。其二,用框架就要考虑构建、依赖安装、打包产物,交付物从一个 HTML 文件变成一堆资源,发给不懂技术的人根本用不起来。其三,原生方案的文件体积通常在几十 KB 以内,弱网环境下也是瞬间打开。
反过来说,什么情况该上框架?当你要做的是一个题库后台——需要题目增删改查、分类管理、成绩统计分析、多角色权限——那原生就撑不住了,老老实实用成熟框架加服务端。选型判断的标准不是"哪个更先进",而是"这个项目的复杂度到底落在哪一层"。
1.3 文件结构和命名约定
我最终的文件长这样,只有五个文件,结构一目了然:
quiz/ ├── index.html # 页面骨架 ├── style.css # 全部样式 ├── quiz.js # 渲染 + 判分逻辑 ├── questions.json # 题库数据 └── README.md # 使用说明命名上我坚持几个习惯:类名用 BEM 风格的block__element--modifier,比如.question__stem、.option__key、.option--right,好处是半年后回来看代码,光看类名就知道这元素属于哪块。ID 只留给 JS 要抓的少数几个节点,比如#quizForm、#submitBtn,绝不拿 ID 当样式钩子。CSS 变量统一放在:root里,改主题色只需要动一处,不用全局搜索替换。
2. 页面骨架:从 DOCTYPE 开始的每个细节
HTML 骨架是这个项目里最容易被轻视的部分,但恰恰是出错最多的地方。乱码、样式在手机上比例不对、点击热区太小,这些问题八成能追溯到头部那几行元信息或者一个没写对的标签上。这一章把骨架逐块拆开说。
2.1 头部三行元信息,写错一行就出问题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>HTML 基础随堂小测</title> <link rel="stylesheet" href="style.css"> </head>第一行<!DOCTYPE html>必须写,而且建议就放在文件第一行。漏掉它会触发浏览器的怪异模式,盒模型按老式规则计算——你设的width会把padding和border一起算进去,明明写了width: 300px,实际内容区只剩两百多像素,怎么调都觉得别扭。规范上允许 DOCTYPE 前面有空白和注释,但养成第一行就写的习惯没坏处。
<meta charset="UTF-8">决定中文能不能正常显示。它必须出现在文档前 1024 字节以内,所以紧跟<head>是稳妥做法。中文页面用 UTF-8,别用 GBK,否则你本地看着正常,发给别人就全是问号。lang属性写zh-CN——BCP 47 标签大小写不敏感,zh-cn和zh-CN等价,写成前者也不会报错,只是规范写法是后者。这个属性影响屏幕阅读器的发音、字体回退策略和浏览器断词规则,别省。
viewport 那一行决定移动端表现。width=device-width让页面宽度等于设备逻辑宽度,initial-scale=1.0让初始缩放为 1。没有这行,手机上会按 980 像素的虚拟宽度渲染,字小得像蚂蚁,用户得双指放大才能看。
2.2 用 fieldset 和 legend 搭出语义化题目容器
题目的容器我用的不是div,而是fieldset配legend。原因在于:一组单选按钮天然就是一"组"表单控件,fieldset的语义就是"将相关控件分组",而legend是这组的标题。屏幕阅读器读到这一组时,会先念题干再念选项,体验完全不同。这是免费的可用性提升,不花一分力气。
<form id="quizForm" novalidate> <fieldset class="question">:root { --brand: #2f6fed; --right: #16a34a; --wrong: #dc2626; --line: #e5e7eb; --text: #1f2937; --muted: #6b7280; --radius: 10px; } .option { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; transition: background-color .15s, border-color .15s; } .option input:checked ~ .option__key { background: var(--brand); color: #fff; border-color: var(--brand); } .option:has(input:checked) { border-color: var(--brand); background: #f5f8ff; }这里有个值得说的取舍。上色我用的是相邻兄弟选择器input:checked ~ .option__key来控制选项序号小圆点的颜色,这个写法兼容性极好,从很老的浏览器到最新版本都能跑。而给整行加浅蓝底用了:has()——它让父元素能感知子元素状态,语义上更优雅。:has()在近几年的主流浏览器里已经可用,但如果你的用户里还有大量老旧设备,稳妥做法是额外给.option绑一个类名,或者干脆只保留小圆点变色,视觉反馈也够用。
另外,别把input用display: none藏起来。藏掉之后键盘用户没法聚焦,也没法用方向键切换选项,焦点环也一起消失了。正确做法是把它留在文档流里,用position: absolute; opacity: 0或者裁剪方式视觉隐藏。更好的方式是保留它,只是让它视觉上不抢戏。
3. 题库数据模型与动态渲染
结构搭好之后,接下来是"题从哪来"。这一步的设计直接决定了后续维护的难易程度——题库写在 JS 里还是单独存文件,字段怎么定,都会在后期加题、改题时体现出来。
3.1 题库用什么格式存:内联数组、JSON、还是接口
三种常见做法,各有各的适用面。内联在 JS 文件里的数组,优点是双击 HTML 就能用,没有任何加载问题;缺点是题目多了文件臃肿,而且改题要动 JS 代码。独立的 JSON 文件,优点是题库和代码分离,非技术人员也能照着格式加题;缺点是通过fetch加载时,如果直接用file://协议双击打开页面,会被浏览器的跨源策略拦住,控制台报错,页面一片空白。这几乎是所有人第一次做这个项目时都会撞上的墙。
提示:本地双击打开 HTML 时,
fetch('questions.json')会失败。解决办法有两个:一是起一个本地静态服务,比如用 Python 的http.server模块在项目目录跑一个;二是把题库直接内联成 JS 变量,牺牲一点可维护性换零配置。
我的做法是两套都留:questions.json作为标准题库,quiz.js里保留一段内联的后备数据。加载逻辑先尝试fetch,失败就退回内联数据,这样无论用户怎么打开都能跑起来。题库字段最后定成这样:
{ "version": "1.0", "questions": [ { "id": "q1", "type": "single", "stem": "下面哪个声明用于把文档标记为 HTML5?", "options": [ { "key": "A", "text": "<!DOCTYPE html>" }, { "key": "B", "text": "<meta charset=\"utf-8\">" }, { "key": "C", "text": "<html lang=\"zh-CN\">" }, { "key": "D", "text": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\">" } ], "answer": ["A"], "analysis": "HTML5 把文档类型声明简化为一种写法,不区分大小写。" } ] }version字段看着多余,等你要做断点续答时就知道它的价值了——题库一改,旧的作答记录可能对不上题目,靠版本号判断要不要丢弃旧数据。answer我统一用数组,哪怕单选题只有一个正确项,这样判分函数只需要写一套。
3.2 渲染逻辑一趟生成整份卷子
渲染我写成一个纯函数,输入题库和容器,输出完整 DOM。整个过程只操作一次真实 DOM,先把所有题目拼成文档片段再插入,避免在循环里反复触发重排。
function renderQuiz(questions, formEl) { const frag = document.createDocumentFragment(); questions.forEach((q, i) => { const fs = document.createElement('fieldset'); fs.className = 'question'; fs.dataset.qid = q.id; fs.dataset.type = q.type; const lg = document.createElement('legend'); lg.className = 'question__stem'; lg.innerHTML = `<span class="question__no">${i + 1}</span>${escapeHtml(q.stem)}`; const box = document.createElement('div'); box.className = 'question__options'; q.options.forEach(opt => { const label = document.createElement('label'); label.className = 'option'; label.innerHTML = ` <input type="${q.type === 'single' ? 'radio' : 'checkbox'}" name="${q.id}" value="${opt.key}"> <span class="option__key">${opt.key}</span> <span class="option__text">${escapeHtml(opt.text)}</span> `; box.appendChild(label); }); fs.appendChild(lg); fs.appendChild(box); frag.appendChild(fs); }); formEl.innerHTML = ''; formEl.appendChild(frag); } function escapeHtml(str) { return String(str).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }escapeHtml这个函数别省。你的题干和选项来自 JSON,里面大概率包含<、>这种字符——毕竟考的就是 HTML 标签。不转义的话,浏览器会把它当成真标签解析,要么内容凭空消失,要么页面结构被撕开一个口子。
3.3 只保留一份状态:FormData 是天然的状态容器
新手常见做法是额外维护一个对象记录每道题选了啥,然后在change事件里手动更新。问题是这个对象和 DOM 里的真实选中状态是两份数据,一旦哪次忘了同步,就会出现"界面上选了但判分说没选"的诡异 bug。
我的做法是彻底不维护额外状态,需要的时候直接从表单读:
function collectAnswers(formEl) { const data = new FormData(formEl); const result = {}; QUESTIONS.forEach(q => { result[q.id] = data.getAll(q.id).sort(); }); return result; }FormData有个非常适合这个场景的特性:没被选中的复选框不会出现在结果里,getAll返回空数组。这意味着"没答"和"答了但没选这项"天然区分开了,不需要额外判断。单选框因为是同name互斥,getAll也只会返回一个值,套用同一套逻辑毫无问题。这样一来,整个项目里关于"作答状态"就只有一个真理来源——DOM 本身。
4. 交互与判分:把逻辑写对比写炫更重要
前面的都是铺垫,这一章才是真正决定这个页面能不能用的部分。判分逻辑写错,界面再漂亮也是白搭。
4.1 单选与多选的判定差异
单选判定简单:选中值等于正确值就对。多选题麻烦一些,要处理"选少了"和"选多了"两种情况,而且顺序不能影响结果。
function isCorrect(q, picked) { const right = q.answer.slice().sort(); const mine = picked.slice().sort(); if (right.length !== mine.length) return false; return right.every((k, i) => k === mine[i]); }先比数量再比内容,三步下来逻辑是完备的。数量不等直接判错,覆盖了多选和少选;数量相等时逐项比对,因为两边都排过序,顺序不影响结果。这种"集合相等"的判定思路在很多场景都能复用,比如权限比对、标签匹配。
还有一个容易被忽略的规则问题:多选题少选算不算对?有些考试里少选给一半分,多选则零分。我在项目里加了个可配置项,strictMode: true表示必须完全匹配,false时少选给部分分。这个决定权应该交给用的人,而不是写死在代码里。
4.2 事件委托与作答进度统计
选项是动态生成的,如果给每个input都绑一个change监听,二十道题四五个选项就是上百个监听器,虽然现代浏览器扛得住,但这写法本身不优雅,而且重新渲染一次就得重新绑。
更好的做法是在form上绑一个委托监听:
formEl.addEventListener('change', e => { if (!e.target.matches('input[type=radio], input[type=checkbox]')) return; updateProgress(); }); function updateProgress() { const answers = collectAnswers(formEl); const done = Object.values(answers).filter(v => v.length > 0).length; document.getElementById('answered').textContent = done; document.getElementById('total').textContent = QUESTIONS.length; }事件冒泡到form才处理,一个监听器搞定全部。updateProgress顺便还能用来做顶部进度条——把done / total塞进 CSS 变量的--progress,宽度就跟着动,实现成本极低,但用户感知很强。
另外,表单右上角那个"已答 X 题"的提示不只是好看。实测下来,用户在二十道题的卷子里很容易漏答,有了这个计数,交卷前扫一眼就知道还差几道,减少了"交完才发现空了三题"的尴尬。
4.3 交卷、反馈与结果汇总
判分结果我分三层呈现,避免用户一次被塞太多信息。第一层是每题下方的小标记:答对的选项边框变绿并加一个小对勾,答错的变红,同时把正确选项也高亮出来。第二层是顶部的结果条:"得分 85 分,答对 17 题,用时 8 分 32 秒"。第三层是答案解析,默认收起,想看的人点开。
function grade(pickedMap) { let score = 0; QUESTIONS.forEach(q => { const fs = formEl.querySelector(`[data-qid="${q.id}"]`); const picked = pickedMap[q.id] || []; const ok = isCorrect(q, picked); fs.classList.add(ok ? 'question--right' : 'question--wrong'); fs.querySelectorAll('.option').forEach(label => { const key = label.querySelector('input').value; const hit = picked.includes(key); const truth = q.answer.includes(key); if (truth) label.classList.add('option--truth'); if (hit && !truth) label.classList.add('option--wrong'); }); if (q.analysis) { const tip = document.createElement('p'); tip.className = 'question__analysis'; tip.textContent = '解析:' + q.analysis; fs.appendChild(tip); } if (ok) score++; }); return score; }判分完后要禁用表单,防止用户发现自己答错后偷偷改答案——虽然这是个随堂小测没人较真,但从交互设计上说,提交后进入只读状态是必要的。做法很简单,遍历所有input设disabled = true,同时把下方按钮从"交卷"切换成"重做"。
5. 值得加的四个进阶功能
基础版本做完,功能上其实是够用的。但有几个小功能加进来的性价比特别高,几乎不增加复杂度,体验提升却很直观。
5.1 倒计时与超时自动交卷
计时器有个经典坑:用setInterval每秒把剩余秒数减一。这个写法在页面切到后台时会被浏览器节流——切回来发现计时器慢了十几秒,时间就不准了。稳妥做法是记下截止时间戳,每次只算和当前时间的差值:
const deadline = Date.now() + 15 * 60 * 1000; function tick() { const left = Math.max(0, deadline - Date.now()); const m = String(Math.floor(left / 60000)).padStart(2, '0'); const s = String(Math.floor(left % 60000 / 1000)).padStart(2, '0'); timerEl.textContent = `${m}:${s}`; timerEl.classList.toggle('is-warning', left <= 60000); if (left <= 0) { clearInterval(timerId); doSubmit(true); return; } } const timerId = setInterval(tick, 250);250 毫秒刷新一次,界面上秒数跳动看起来是连续的,不会出现跳过一秒的视觉抖动。切后台再回来,因为每次都是用时间戳算,误差立刻被吸收。最后一分钟给倒计时加上红色闪烁提醒,这个细节在实际使用中很有用——我就见过有人在最后十秒还在纠结,然后被迫自动交卷。
5.2 题目和选项乱序(以及乱序后的判分陷阱)
同一套题连续刷两遍,第二遍基本靠记忆。把选项顺序打乱能有效缓解这个问题,实现也很简单:
function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }但这里有个必须提醒的陷阱:乱序的只是选项的显示顺序,判定依据仍然是每个选项的 key。如果你的判分逻辑不小心改成了按索引比对,比如判断"第一个选项是否是正确答案",那么一旦乱序,判分就会全盘错乱——答对的显示错了,答错的显示对了,而且你很难第一眼看出问题在哪。
所以我在渲染时始终把value="${opt.key}"写死为原始 key,判分时也永远比对 key。展示顺序和逻辑标识彻底解耦,这是这类功能不出错的关键。
题目顺序乱序要谨慎些。如果题库本身有难度递进的设计,比如前五题是基础概念、后五题是综合应用,那打乱之后学习曲线就断了。我在项目里默认只乱序选项,题目乱序做成一个开关。
5.3 localStorage 断点续答
这个功能解决的是真实痛点:答到第十五题,手机来条消息切出去,回来浏览器把页面回收了,前面的答案全没了。用localStorage存一份作答快照就够:
const STORAGE_KEY = 'quiz-progress-v1'; function saveProgress() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: QUESTIONS_VERSION, answers: collectAnswers(formEl), deadline, savedAt: Date.now() })); } catch (e) { console.warn('本地存储不可用', e); } }几个细节要处理。第一,包一层try/catch——无痕模式下localStorage会直接抛异常,不处理的话整个页面脚本崩掉。第二,存进去的数据带version,恢复时和当前题库版本对不上就丢弃,避免旧作答配上新题目的错位。第三,设置有效期,超过两小时的记录直接无视,不然用户三个月后打开页面发现还留着上次的答案,反而困惑。
恢复逻辑放在渲染之后、事件绑定之前:读数据,遍历答案对象,找到对应的input设置checked = true,然后刷新一次进度计数就行。
5.4 一键返回顶部与移动端细节
卷子长了之后,交卷按钮在页面底部,用户答完最后一题想交卷得往下滚,或者干脆滚回顶部看倒计时。加个返回顶部的小圆钮,成本几行代码:
window.scrollTo({ top: 0, behavior: 'smooth' }); // 滚动超过一屏才显示按钮 new IntersectionObserver( ([entry]) => btn.classList.toggle('is-visible', !entry.isIntersecting), { threshold: 0 } ).observe(document.querySelector('.quiz__head'));用IntersectionObserver监听顶部那个标题块是否离开视口,比监听scroll事件再计算scrollTop性能好得多,不会在滚动时频繁触发计算。
移动端还有两个细节值得处理。一是所有可点元素的可点区域不低于 44×44 像素,这基本是行业共识,选项行加了内边距之后轻松达标。二是禁用双击缩放带来的干扰,用touch-action: manipulation能去掉部分浏览器上点击的延迟感,让选中反馈更跟手。
如果题目是"请给下面这项打个分"这种场景,用下拉选择框<select>会更省空间,但要注意它的样式定制能力有限,在部分移动端浏览器上会以系统原生控件弹出,跟页面的视觉风格对不上。我的经验是:选项文本短、数量少于六个,用单选按钮;选项文本长或者数量多,才考虑下拉,并且要接受它样式上不那么听话。
6. 常见问题与排查实录
功能都实现对之后,真正的磨人环节才开始。下面这几个问题我几乎每次做类似页面都会遇到至少一个。
6.1 中文乱码、样式不生效、按钮点不动
先列三个最高频的。中文乱码——八成的锅在<meta charset>上:要么漏写,要么位置太靠后(超出文档前 1024 字节),要么文件本身被编辑器存成了 GBK 编码。判断方法很直接:打开开发者工具看 Network 面板的响应头,或者直接看文件保存格式。
样式完全不生效——按这个顺序排查:路径对不对(href="style.css"还是根目录下的./css/style.css);文件名大小写对不对,本地文件系统不区分大小写,但部署到 Linux 服务器就区分了,Style.css和style.css是两个文件;控制台有没有 404。
按钮点不动——最常见的原因是元素被别的元素盖住了,通常是某个绝对定位的装饰层或者遮罩没有设置pointer-events: none。其次是 JS 报错导致监听器根本没绑上。打开控制台看有没有红色报错,这一步能解决一半以上的"点不动"问题。
还有一个隐蔽的:给label包了input之后,又给label绑了click事件去手动切换checked,结果浏览器默认行为和你的代码各切一次,看起来就像"点了没反应"。记住:label包input已经有免费的点击联动,别重复造轮子。
6.2 问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 页面中文显示为乱码 | charset 缺失或位置靠后,或文件编码不是 UTF-8 | <meta charset="UTF-8">紧跟<head>,编辑器另存为 UTF-8 |
| 盒模型宽度对不上 | 漏写 DOCTYPE,触发怪异模式 | 第一行补上<!DOCTYPE html> |
| 手机上字太小 | 缺少 viewport 元信息 | 补width=device-width, initial-scale=1.0 |
| 双击打开页面空白 | fetch读取本地 JSON 被跨源策略拦截 | 起本地静态服务,或把题库内联成 JS 变量 |
| 选项文本显示不全 | 内容含<、>被当成标签解析 | 渲染前统一做 HTML 转义 |
| 多选题乱序后判分错乱 | 判分按显示索引而不是选项 key | 判分永远比对 key,展示顺序与逻辑标识解耦 |
| 切后台后倒计时变慢 | setInterval累减被浏览器节流 | 改用截止时间戳计算剩余时间 |
| 无痕模式下脚本崩溃 | localStorage写入抛异常 | 存储操作全部包try/catch |
| 交卷后还能改答案 | 未禁用表单控件 | 提交后遍历input设disabled |
6.3 我踩过的三个坑
第一个坑是空格和换行被算进答案。有次从文档里复制题干和选项,尾随了几个全角空格,题目在页面上看着一模一样,但字符串比对永远不相等,判分全错。后来我在数据清洗阶段统一做了trim(),并且在选项文本首尾加了不可见标记做过对比,才定位到问题。从外部文档往题库里粘贴内容时,这个问题出现的概率非常高。
第二个坑是事件绑定时机。一开始我把事件绑定的代码写在渲染函数调用之前,结果form元素还是空的,委托监听虽然绑上了没问题,但另一段直接querySelector取某个选项的代码拿到了null,抛异常之后后面的脚本全停了。教训是:所有依赖 DOM 结构的操作,要么放在渲染之后,要么放进DOMContentLoaded回调里。
第三个坑是移动端横竖屏切换后布局错乱。原因是我用 JS 读取了初始视口高度算容器高度,旋转屏幕后没有重算。后来把这类尺寸交给 CSS 处理,用min-height: 100vh加dvh单位,彻底不碰 JS 计算,问题自然消失。凡是能用 CSS 表达的布局,都别用 JS 去算,这算是我做前端这些年最省心的一条原则。
题库的维护也是个体力活。二十道题的时候手动写 JSON 完全没问题,超过五十道就该考虑写个简单的录入脚本了,读 CSV 转 JSON,或者做个表单页面自己生成。我现在的习惯是把题目先在一个表格里维护好,导出一份 CSV,然后用一段十几行的转换脚本生成 JSON,避免手写的时候漏逗号、漏引号——这种语法错误在编辑器里看着是红色波浪线,但如果编辑器没开语法检查,浏览器给报的错会含糊得让人抓狂。
如果后面还要继续扩展,我打算加两个东西:一是成绩导出,把作答结果拼成一段文本,配一个复制按钮,方便发到群里;二是错题重练,把判错的题筛出来重新组一份小卷子。这两个功能都不复杂,本质上是在现有的数据结构上再套一层筛选逻辑。真做起来最花时间的往往不是代码,而是想清楚交互上要不要给一个"重练错题"的引导页,以及答完之后怎么回到完整卷子——这类小决定堆积起来,才是一个页面好不好用的分水岭。