第23次:面试题库功能
本文讲解题库系统的主干实现。它对应的不是单个文件,而是一条完整链路:InterviewQuizService.ets、QuizBankPage.ets、QuizPracticePage.ets,以及与错题本的联动。阅读时请把它当作一个完整的刷题系统来理解。
题库系统在项目里的真实定位
课程内测验强调“跟课学完顺手测一下”,而面试题库强调的是“体系化刷题”。因此它的设计比每日一题和普通课程测验都更完整:
- 有模块分类
- 有统计卡片
- 有模块练习
- 有错题本入口
- 有练习总结页
先看整条链路:
#mermaid-svg-VHWIm1YvGnKT9TcH{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VHWIm1YvGnKT9TcH .error-icon{fill:#552222;}#mermaid-svg-VHWIm1YvGnKT9TcH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VHWIm1YvGnKT9TcH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VHWIm1YvGnKT9TcH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH .marker.cross{stroke:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VHWIm1YvGnKT9TcH p{margin:0;}#mermaid-svg-VHWIm1YvGnKT9TcH .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster-label text{fill:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster-label span{color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster-label span p{background-color:transparent;}#mermaid-svg-VHWIm1YvGnKT9TcH .label text,#mermaid-svg-VHWIm1YvGnKT9TcH span{fill:#333;color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .node rect,#mermaid-svg-VHWIm1YvGnKT9TcH .node circle,#mermaid-svg-VHWIm1YvGnKT9TcH .node ellipse,#mermaid-svg-VHWIm1YvGnKT9TcH .node polygon,#mermaid-svg-VHWIm1YvGnKT9TcH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .rough-node .label text,#mermaid-svg-VHWIm1YvGnKT9TcH .node .label text,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape .label,#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape .label{text-anchor:middle;}#mermaid-svg-VHWIm1YvGnKT9TcH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .rough-node .label,#mermaid-svg-VHWIm1YvGnKT9TcH .node .label,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape .label,#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape .label{text-align:center;}#mermaid-svg-VHWIm1YvGnKT9TcH .node.clickable{cursor:pointer;}#mermaid-svg-VHWIm1YvGnKT9TcH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH .arrowheadPath{fill:#333333;}#mermaid-svg-VHWIm1YvGnKT9TcH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VHWIm1YvGnKT9TcH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VHWIm1YvGnKT9TcH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VHWIm1YvGnKT9TcH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VHWIm1YvGnKT9TcH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VHWIm1YvGnKT9TcH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster text{fill:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH .cluster span{color:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VHWIm1YvGnKT9TcH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VHWIm1YvGnKT9TcH rect.text{fill:none;stroke-width:0;}#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape p,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VHWIm1YvGnKT9TcH .icon-shape .label rect,#mermaid-svg-VHWIm1YvGnKT9TcH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VHWIm1YvGnKT9TcH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VHWIm1YvGnKT9TcH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VHWIm1YvGnKT9TcH :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
QuizBankPage
InterviewQuizService.init
WrongAnswerService.init
加载所有题目与统计
显示模块列表与正确率
进入 QuizPracticePage
recordAnswer 更新统计
addWrongAnswer / markAsMastered
WrongAnswerBookPage
一、为什么题库首页要先显示统计再显示模块
QuizBankPage 的内容顺序非常有讲究:
这个顺序不是为了好看,而是为了建立学习反馈感。用户进入题库首页时,先看到的是:
- 我一共答了多少题
- 我答对了多少题
- 我的正确率是多少
然后再看到模块列表,才更容易知道下一步去哪里练。这个设计非常符合学习产品的逻辑,因为用户不是来“浏览信息”的,而是来“持续进步”的。
二、InterviewQuizService 的职责比你想象的大
当前题库服务做的事情非常完整:
- 初始化全部题目
- 读取整体统计数据
- 记录每题答题结果
- 维护模块级统计
- 维护整体正确率
- 记录每道题最后一次答题状态
- 支持重置统计
这里最值得学习的是它不仅存“总答题数”,还存“每个模块的统计”。这意味着用户后续可以看到自己是 Hooks 模块弱,还是组件模块弱,而不是只知道一个笼统的全局正确率。
这类“全局统计 + 局部统计”并存的设计,在题库类应用中非常常见,也非常实用。
三、答题记录为什么不是简单累加
recordAnswer(questionId, answer, isCorrect) 是这套系统最关键的方法之一。它的设计并不是“每答一次就无脑加一”,而是更细致地处理了同一道题的重复作答:
- 第一次作答:计入总答题数和正确/错误数。
- 如果之前答错,后来答对:要修正统计。
- 如果之前答过且结果没变:不重复累计。
这说明当前统计系统更接近“题目掌握情况”,而不是“机械刷题次数”。这种设计对学习产品更友好,因为它鼓励用户修正错误,而不是把统计做得越来越失真。
四、题库首页为什么还要初始化错题本服务
QuizBankPage 在加载时不仅调用 InterviewQuizService.init(),还调用 WrongAnswerService.init()。这是因为题库首页需要同时展示:
- 我的答题统计
- 错题本入口
- 当前未掌握错题数量
也就是说,题库首页本质上已经是“练习中心”,而不是单纯的目录页。它既展示当前能力,也展示后续复习入口。
这一层入口在 QuizBankPage.ets 里对应的是下面这段真实加载逻辑:
private async loadData(): Promise<void> {
this.isLoading = true;
await InterviewQuizService.init();
await WrongAnswerService.init();
this.statistics = InterviewQuizService.getOverallStatistics();
this.wrongAnswerCount = WrongAnswerService.getUnmasteredCount();
const moduleList = InterviewQuizService.getAllModules();
const tempModules: ModuleInfo[] = [];
for (const m of moduleList) {
const questions = InterviewQuizService.getQuestionsByModule(m.moduleId);
const moduleInfo: ModuleInfo = {
moduleId: m.moduleId,
moduleName: m.moduleName,
icon: m.icon,
questionCount: questions.length,
stats: InterviewQuizService.getModuleStatistics(m.moduleId)
};
tempModules.push(moduleInfo);
}
this.modules = tempModules;
this.isLoading = false;
}
UserWrongAnswerServiceInterviewQuizServiceQuizBankPageUserWrongAnswerServiceInterviewQuizServiceQuizBankPage#mermaid-svg-PetfBSXRiUDWCmRe{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PetfBSXRiUDWCmRe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PetfBSXRiUDWCmRe .error-icon{fill:#552222;}#mermaid-svg-PetfBSXRiUDWCmRe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PetfBSXRiUDWCmRe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PetfBSXRiUDWCmRe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PetfBSXRiUDWCmRe .marker.cross{stroke:#333333;}#mermaid-svg-PetfBSXRiUDWCmRe svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PetfBSXRiUDWCmRe p{margin:0;}#mermaid-svg-PetfBSXRiUDWCmRe .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PetfBSXRiUDWCmRe text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PetfBSXRiUDWCmRe .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-PetfBSXRiUDWCmRe .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe .sequenceNumber{fill:white;}#mermaid-svg-PetfBSXRiUDWCmRe #sequencenumber{fill:#333;}#mermaid-svg-PetfBSXRiUDWCmRe #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-PetfBSXRiUDWCmRe .messageText{fill:#333;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PetfBSXRiUDWCmRe .labelText,#mermaid-svg-PetfBSXRiUDWCmRe .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .loopText,#mermaid-svg-PetfBSXRiUDWCmRe .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PetfBSXRiUDWCmRe .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-PetfBSXRiUDWCmRe .noteText,#mermaid-svg-PetfBSXRiUDWCmRe .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-PetfBSXRiUDWCmRe .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PetfBSXRiUDWCmRe .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PetfBSXRiUDWCmRe .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PetfBSXRiUDWCmRe .actorPopupMenu{position:absolute;}#mermaid-svg-PetfBSXRiUDWCmRe .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-PetfBSXRiUDWCmRe .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PetfBSXRiUDWCmRe .actor-man circle,#mermaid-svg-PetfBSXRiUDWCmRe line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-PetfBSXRiUDWCmRe :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}init()init()getOverallStatistics()getUnmasteredCount()展示统计卡片、错题入口、模块列表
五、模块列表为什么要显示每个模块的正确率
当前 ModuleItem 中显示了:
- 模块图标
- 模块名称
- 题目总数
- 当前模块正确率
这不是简单的信息堆砌,而是给用户非常明确的练习反馈:
- 题量决定刷题成本
- 正确率反映掌握程度
当用户看到某个模块题目不多但正确率很低时,就知道那里是弱项;如果某个模块正确率已经很高,可能就可以暂时放一放。这就是“数据驱动学习路径”的基本思路。
六、QuizPracticePage 为什么支持两种模式
这一页非常值得学,因为它没有被写成“只能练模块题”。
当前它支持两种进入方式:
- 通过 moduleId 进入模块练习模式
- 通过 isFromWrongBook 进入错题练习模式
这种设计很聪明,因为:
- 页面 UI 基本一样
- 差异主要体现在题目来源
- 复用一个页面就能覆盖两种练习场景
也就是说,页面层的复用不是靠硬拷贝,而是靠“参数化驱动不同模式”。
七、练习页里的答题闭环是怎样形成的
QuizPracticePage 的答题流程非常完整:
这说明题库系统真正形成了:
- 练习
- 统计
- 错题
- 复习
四段闭环,而不是只有“答题”这一个动作。
把它放回真实源码里看,会更容易理解这条链路是如何闭合的:
private async handleSubmit(): Promise<void> {
const question = this.getCurrentQuestion();
if (!question || this.selectedAnswer < 0) return;
this.isCorrect = InterviewQuizService.validateAnswer(question.id, this.selectedAnswer);
this.isSubmitted = true;
this.answers.push(this.selectedAnswer);
this.results.push(this.isCorrect);
await InterviewQuizService.recordAnswer(question.id, this.selectedAnswer, this.isCorrect);
if (!this.isCorrect) {
await WrongAnswerService.addWrongAnswer(question, this.selectedAnswer);
} else if (this.isFromWrongBook) {
await WrongAnswerService.markAsMastered(question.id);
}
}
注意这里除了更新统计,还把用户本次选择和结果顺手记录进了 answers、results,这样页面后续才能继续切题并生成总结页。
八、总结页为什么非常重要
练习页最后的 SummaryView() 展示了:
- 总题数
- 正确题数
- 正确率
- 用时
这个总结页的意义在于,它给用户一次完整练习画上了一个可感知的句号。没有总结页,用户只会觉得“题做完了”;有总结页,用户会觉得“我完成了一次训练”。
对于学习产品来说,这种仪式感其实很重要。它能帮助用户形成持续刷题的动力。
九、自己实操时最推荐的顺序
这一套走完后,你对题库链路的理解会非常扎实。
十、本篇常见坑
1. 只做题目列表,不做统计
没有统计的题库很难形成成长反馈。
2. 同一道题重复作答时直接无脑累加
这样会让统计越来越失真。
3. 错题本和练习页分家过度
当前项目的优点恰恰在于两者通过参数模式复用了同一个练习页。
4. 练习完成后不做总结页
会大大削弱一次练习的完成感。
本篇小结
面试题库功能并不是一个单独页面,而是一整套学习闭环:
- QuizBankPage 负责入口和总览
- InterviewQuizService 负责统计与题库规则
- QuizPracticePage 负责实际练习
- WrongAnswerService 负责错题沉淀与复习
如果你把这一套结构真正理解透了,后面看错题本功能就会非常自然,因为它本来就是题库系统的一部分。






