尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3+Vuex4+Vant搭建H5在线考试系统前端实战

Vue3+Vuex4+Vant搭建H5在线考试系统前端实战 简介这套源码基于Vue3.x、Vue-router4.x、Vux4.x与vant构建H5在线考试系统前端面向希望掌握现代前端工程化与移动端组件化开发的中高级学习者。项目采用组件化与模块化设计将页面拆分为26个Vue组件并用15个JavaScript脚本负责路由、状态管理、接口请求与工具函数等逻辑从预览目录可以看到源码覆盖登录、考试、课程、订单、学习资料、会员等业务场景已实现章节测试、在线视频播放、微信支付等核心功能。包内共69个文件除Vue/JS外还包含PNG/JPG图标与图片、JSON配置文件、开发与生产环境配置、字体及ico图标等整体约4.83MB目录结构清晰便于按模块对照学习。目前已有556人学习下载后端暂未开源但前端源码完整、可独立运行并预留接口联调位置适合作为H5考试类项目的仿写或二次开发蓝本。1. 用 Vue3.xVue-router4.xVuex4.xVant 搭 H5 在线考试系统,这套代码怎么落标题里的Vux4.x与vant并列,在多数 Vue3 工程里实际对应的是 Vuex 4.x 状态管理库;如果项目里出现的是基于 WeUI 的 Vux 组件库,组件层替换为 Vant 的 import 即可,路由与 store 两层代码完全不受影响。在线考试系统的技术难点不在页面数量,而在“考试中”这一状态必须稳:刷新不掉已答题目、倒计时不归零、切后台再回来还能接着答。本文按 Vue3.x Vue-router4.x Vuex4.x Vant 的组合,从 Vite 工程初始化写到路由守卫、答题状态与试卷渲染,所有代码可直接贴进新工程里跑通。读完你能得到一套可以继续扩展的 H5 考试前端骨架,而不是只能看不能用的原型。2. Vue3 工程初始化与版本锁定:Vite 搭骨架、Vant4 按需加载、Vuex4 接入H5 在线考试系统不需要服务端渲染,Vite 是 Vue3 生态里启动最快、配置最少的构建工具。初始化工程这一步用create-vite的vue-ts模板,因为考试系统涉及题目类型、答案结构、路由元信息这些有明确形状的数据,TypeScript 能帮你把“多选题答案是数组还是字符串”这类问题在编译期暴露出来。npm create vitelatest exam-h5 -- --template vue-ts cd exam-h5 npm install vue-router4 vuex4 vant4 npm install -D unplugin-vue-components unplugin-auto-importvue-router4是 Vue3 专用版本,如果你误装成vue-router3,路由实例化方式完全不同,控制台会报createRouter is not a function;Vuex 同理,4.x 才支持createStore。Vant 这边要装vant4,Vue2 时代的 Vant2 和 Vue3 的 Vant4 API 不兼容。执行完这四条命令后,建议把 package.json 里的依赖版本范围锁成下表,避免某次npm install把主版本带偏:依赖建议版本范围作用与注意点vue^3.4.0组合式 API 基础,低于 3.3 泛型组件支持不完整vue-router^4.3.0提供 createRouter、createWebHistoryvuex^4.1.0提供 createStore,配合 app.use(store) 注册vant^4.8.0表单、弹层、倒计时组件,样式走 CSS 变量如果你本机 Node 版本低于 18,Vite 5 的依赖 esbuild 会报平台二进制不兼容,先升级 Node 再继续。版本锁定的意义在于考试项目的迭代周期长,半年后回来维护时,npm install不会因为 Vue 出了 3.5 或 Vant 出了 5.x 静默破坏你的页面。2.1 Vite 工程里接入 Vant4:用 unplugin-vue-components 做自动按需导入Vant4 的官方推荐方式是组件自动按需导入,核心插件是unplugin-vue-components。它的作用是扫描模板里出现的van-button、van-dialog这类标签,自动把对应的组件和样式引入到当前文件,不需要你在script里手动写import { Button } from vant。这样做的直接收益是首屏只加载用到的组件,考试详情页用到的 Radio、Checkbox、Dialog 等十几个组件不会把整个 Vant 包塞进 bundle。// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import Components from unplugin-vue-components/vite import { VantResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), Components({ resolvers: [VantResolver()], dts: src/components.d.ts, dirs: [src/components] }) ], server: { host: 0.0.0.0, port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })resolvers数组里只放VantResolver(),组件和样式都会按需处理;dts会生成components.d.ts,让 IDE 对自动导入的组件有类型提示;dirs指定你自己写的业务组件目录,插件会连业务组件一起自动导入。server.proxy用于本地联调,把/api开头的请求转发到后端 Java 或 Node 服务,changeOrigin: true是为了让后端拿到正确的 Host 头。注意这一步不用手动引入vant/lib/index.css,按需导入方案会精确到组件级样式,手动全量引样式反而会把没用的组件样式也打进产物。2.2 把 Vuex4 按考试模块拆分:目录结构这样摆Vuex4 的 API 和 Vuex3 差异不大,但考试系统里用户信息、答题记录、倒计时三个维度会同时变化,不拆 module 的话,state 会变成一大坨互相引用的大对象,排查困难。常见做法是按业务域拆目录:src/ store/ index.ts modules/ exam.ts user.ts router/ index.ts views/ exam/ ExamList.vue ExamDetail.vue ExamResult.vue// store/index.ts import { createStore } from vuex import { exam } from ./modules/exam import { user } from ./modules/user export default createStore({ modules: { exam, user } })createStore返回的 store 实例要在main.ts里通过app.use(store)注册。把 store 和 router 分开放,是因为路由守卫里也要读答题状态,如果把状态都放在组件里,守卫那层根本拿不到数据。模块拆分后,exam模块只关心考试进行中的状态,user模块只关心 token 和用户信息,将来加一个“错题本”模块也只需要在modules下新增文件。3. 考试流程路由与答题状态:Vue-router4 的守卫和 Vuex4 的暂存策略H5 在线考试系统的路由设计有一个容易被忽略的点:考试详情页不能因为用户切到后台就销毁。Vue 组件实例在路由离开时会被卸载,如果答题数据只存在组件data里,用户不小心按了浏览器的前进后退键,整张卷子就白做了。所以路由负责“我能到哪个页面”,Vuex 负责“我答到哪一步”,两者通过路由守卫衔接。3.1 用 meta 区分考试页和普通页,给路由加登录校验// router/index.ts import { createRouter, createWebHistory } from vue-router import store from /store const routes [ { path: /login, component: () import(/views/Login.vue), meta: { title: 登录 } }, { path: /exam/list, component: () import(/views/exam/ExamList.vue), meta: { title: 考试列表, requiresAuth: true } }, { path: /exam/:id, component: () import(/views/exam/ExamDetail.vue), meta: { title: 考试中, requiresAuth: true, keepAlive: true } }, { path: /exam/result/:id, component: () import(/views/exam/ExamResult.vue), meta: { title: 考试结果, requiresAuth: true } } ]requiresAuth用于登录校验,keepAlive是给后面接KeepAlive用的。这里没有把试卷数据写进路由 query,原因是 URL 里塞一长串答案既不安全,也超出部分浏览器对 URL 长度的限制。路由参数只保留id,试卷内容通过接口拉取后交给 Vuex 管理。3.2 beforeEach 守卫里做登录跳转和续考恢复// router/index.ts router.beforeEach((to, from, next) { const token store.state.user.token if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.path /exam/list) { store.dispatch(exam/tryRestore) } next() })tryRestore是 store 里的一个 action,它检查 localStorage 里有没有未交卷的考试记录,如果有,把考试 ID、剩余时间、已答内容恢复到 Vuex 中。这里把恢复动作放在进入考试列表时触发,而不是放在详情页,是因为用户刷新后第一个进入的页面可能是任意路由,把恢复逻辑收敛到一个入口便于调试。路由守卫里不建议直接弹 Vant 的 Dialog,弹窗属于 UI 交互,应该由页面组件监听 store 里的activeExam状态后自行弹出,否则路由文件和 UI 耦合在一起,后面做自动化测试会很痛苦。3.3 Vuex4 exam 模块:答案、倒计时、持久化一个都不能少// store/modules/exam.ts import type { Module } from vuex interface ExamState { examId: string | null answers: Recordstring, string | string[] remainingSeconds: number startedAt: number durationMinutes: number } const EXAM_KEY exam_session_v1 export const exam: ModuleExamState, unknown { namespaced: true, state: () ({ examId: null, answers: {}, remainingSeconds: 0, startedAt: 0, durationMinutes: 60 }), mutations: { SET_ACTIVE(state, payload: { examId: string; durationMinutes: number }) { state.examId payload.examId state.durationMinutes payload.durationMinutes state.startedAt Date.now() state.remainingSeconds payload.durationMinutes * 60 }, SET_ANSWER(state, payload: { questionId: string; value: string | string[] }) { state.answers[payload.questionId] payload.value }, SET_REMAINING(state, seconds: number) { state.remainingSeconds seconds }, CLEAR(state) { state.examId null state.answers {} state.remainingSeconds 0 state.startedAt 0 } }, actions: { persist({ state }) { localStorage.setItem(EXAM_KEY, JSON.stringify({ examId: state.examId, answers: state.answers, remainingSeconds: state.remainingSeconds, startedAt: state.startedAt, durationMinutes: state.durationMinutes })) }, tryRestore({ commit }) { const raw localStorage.getItem(EXAM_KEY) if (!raw) return const saved JSON.parse(raw) commit(SET_ACTIVE, { examId: saved.examId, durationMinutes: saved.durationMinutes }) const elapsed Math.floor((Date.now() - saved.startedAt) / 1000) const remaining Math.max(0, saved.durationMinutes * 60 - elapsed) commit(SET_REMAINING, remaining) // 答案不用 SET_ANSWER 逐条提交,直接整体回写 commit(SET_ANSWERS, saved.answers) } }, getters: { activeExam: (state) state.examId, answeredCount: (state) Object.keys(state.answers).length } }answers用Recordstring, string | string[]是因为单选题答案是字符串,多选题答案是数组,判断题也可以用字符串存true/false。倒计时不直接存“剩余秒数”,而是用startedAt加durationMinutes重新计算,原因很简单:浏览器里的setInterval在页面切后台时会挂起,直接递减秒数会越走越慢。恢复时用Date.now() - saved.startedAt计算真实经过的时间,误差只在秒级。持久化用的localStorage,刷新页面后数据还在,考试系统里不建议用sessionStorage,因为部分安卓 WebView 在内存不足时会连会话存储一起清掉。提示:tryRestore解析localStorage里的 JSON 时要用 try-catch 包裹,线上真实环境里 localStorage 可能被用户手动清理到只剩半个字符串,JSON.parse 抛异常会导致整个页面白屏。4. 用 Vant 渲染 H5 试卷:Radio/Checkbox 绑定、倒计时交卷和缓存边界试卷渲染是 H5 考试系统里最直观的一层,也是新手最容易把 Vuex、组件数据、表单校验搅在一起的地方。设计原则是:Vant 组件只负责把题目展示出来并收集用户操作,所有变更立刻 commit 进 Vuex,组件自身不持有答案副本。这样切题、刷新、回退都不会丢状态。4.1 题目类型定义与 Vant 组件映射interface Question { id: string type: single | multiple | judge stem: string options: Array{ key: string; text: string } answer: string | string[] score: number }单个题目用type区分题型,options里的key是答案选项的稳定标识,不建议用 index 作为存储值,因为后端批改时按 key 匹配,index 一旦因题目编辑而改变,历史成绩就对不上了。模板里按类型分发到 Vant 的van-radio-group和van-checkbox-group:template van-form submitonSubmit van-cell-group v-forq in questions :keyq.id van-cell :title${q.score}分 ${q.stem} / van-radio-group v-ifq.type single || q.type judge v-modelanswers[q.id] van-radio v-foropt in q.options :keyopt.key :nameopt.key checked-color#1989fa {{ opt.text }} /van-radio /van-radio-group van-checkbox-group v-else-ifq.type multiple v-modelanswers[q.id] van-checkbox v-foropt in q.options :keyopt.key :nameopt.key checked-color#1989fa {{ opt.text }} /van-checkbox /van-checkbox-group /van-cell-group /van-form /templateanswers是 computed,getter 返回store.state.exam.answers,setter 里调用store.commit(exam/SET_ANSWER)。van-radio-group的 v-model 会直接把选中的name值写到 answers 对应字段,van-checkbox-group则会维护一个数组,多选题没选中的处理天然就比 jQuery 时代简单。judge题型的选项直接给key: true和key: false就行,不用单独写模板。4.2 倒计时自动交卷的三个可调参数倒计时的实现不要用“每秒减一”的死循环,前面说过浏览器后台挂起会让 setInterval 失准。正确做法是把结束时间点算出来,每次 tick 用当前时间反推剩余秒数。三个参数在项目里会经常按不同考试调整:参数作用推荐值说明durationMinutes考试总时长由试卷接口下发写入 Vuex,组件不写死warningThreshold剩余多少分钟时弹窗提醒5用 van-dialog 提示,只提醒一次autoSubmitOnTimeout到点自动交卷true前端防重复提交,真正判定在后端const examEndAt computed(() { const started store.state.exam.startedAt const duration store.state.exam.durationMinutes * 60 * 1000 return started duration }) const tick () { const diff Math.max(0, Math.floor((examEndAt.value - Date.now()) / 1000)) store.commit(exam/SET_REMAINING, diff) if (diff 0) { handleAutoSubmit() } } const timer setInterval(tick, 1000) document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { tick() } })examEndAt用 startedAt 加时长算出,不依赖本地倒计时的累积次数。visibilitychange事件在用户从后台切回页面时立即校正一次,这才是 H5 页面在手机浏览器里的真实可靠方案。handleAutoSubmit里要加一个submitted标志位,防止 setInterval 和手动交卷同时触发,把交卷请求发两次。4.3 H5 被嵌入微信或 App 时,页面缓存和授权处理怎么影响考试在线考试系统经常以 H5 页面形式嵌到微信公众号菜单、企业微信工作台或原生 App 的 WebView 里。 用户在微信里访问 H5 考试系统时,第一步是静默授权获取 openid,window.location.href跳转微信授权链接后微信会回调你的 redirect_uri,注意这个跳转会刷新页面,所以授权逻辑要放在入口页的 beforeMount 里,并且用 localStorage 记住已经授权的状态,避免每次进入都跳一次微信服务器。请求后端接口时带上 code 换取的 openid,不要把它拼在 URL query 上,否则会落到日志里。页面缓存这边,考试接口的响应头里如果带了Cache-Control: no-store,WebView 就不会把接口结果缓存下来,否则用户做了新的操作后,后退时看到的还是旧页面数据。前端侧可以在路由切换时调用window.history.replaceState清理多余的 history 条目,减少误触返回键跳出考试的概率。对于系统已存在 WebView 内部 caches 的情况,常见做法是在加载页面 URL 后追加一个版本号参数(如?v20250101),同时在退出考试时主动调caches.keys()清理当前域名下的缓存条目:async function clearPageCache() { if (caches in window) { const keys await caches.keys() await Promise.all(keys.map((key) caches.delete(key))) } }这段代码在beforeUnmount里调用,主要针对 Android WebView 的缓存残留。iOS 的 WKWebView 对 Cache-Control 的遵守更好,不建议用上面的方式清 iOS 缓存,只需要无痕加载即可。5. 继续考试与防作弊:H5 在线考试系统的会话恢复验证技巧最后这一步,把刷新恢复和交卷完整性做一套可验证的检查清单,而不是拿手机点点就完事。5.1 用“进入页面四次”验证恢复逻辑模拟真实考试场景:进入考试页答 5 题,此时 store 里的remainingSeconds应该是duration*60 - 5左右。按手机 Home 键退出,过 2 分钟再切回,页面剩余秒数必须比之前少约 120 秒,而不是维持原值。把页面整个杀掉,重新打开应用进入考试列表,点击“继续考试”进入详情页,已答的 5 个选项仍然处于勾选状态。全程打开控制台的 Application 面板,观察 localStorage 里exam_session_v1这个 key 的startedAt字段,确认它没有被刷新重置。5.2 用断言脚本防回归在项目里加一个简单的session.spec.ts跑验收,核心断言就三条:// 伪代码,实际用 Vitest happy-dom 跑 test(刷新后答案保留, () { store.commit(exam/SET_ANSWER, { questionId: q1, value: A }) store.dispatch(exam/persist) store.dispatch(exam/tryRestore) expect(store.state.exam.answers).toEqual({ q1: A }) }) test(离线倒计时不归零, () { vi.advanceTimersByTime(1000) const remaining store.state.exam.remainingSeconds expect(remaining).toBeGreaterThan(0) }) test(到点自动提交只触发一次, () { const submit vi.fn() // 模拟 autoSubmit 分支 expect(submit).toHaveBeenCalledTimes(1) })5.3 交卷接口的前后端双端校验前端提交答案时,要把answers对象、remainingSeconds、以及startedAt一起发给后端。后端不要信前端的“我交卷了”,而是根据startedAt durationMinutes自己判断是否超时。前端把startedAt传过来是为了让后端复核,同时后端还需要对比服务器时间与考试开始时间的差距,防止用户改手机系统时间规避倒计时。交卷按钮在点击后立即置为loading状态并禁用,用 Vant 的 van-button 的loading属性即可。最后落库时,后端要校验answers里每个题目 key 是否都在试卷 ID 对应的试题库里,避免有用户手动往请求体里加隐形的额外答案字段。本文还有配套的精品资源点击获取
返回列表