跳转至

局域网答题 · 前端性能评估与缺陷检测报告

  • 评估日期:2026-09-15
  • 评估范围:学生端 / 教师端 / 管理端全部前端页面(HTML + 静态 JS/CSS + 懒加载引擎)
  • 后端:FastAPI + SQLite(本地 quiz.db),静态资源经 CachedStaticFiles 托管(gzip + max-age=86400
  • 测试环境:Windows / Node v24(网络层采集),局域网回环 127.0.0.1:8000

1. 测试方案

1.1 目标

  1. 评估页面加载速度、资源加载效率、交互响应时间。
  2. 定位前端性能瓶颈(资源加载、冗余代码、内存泄漏、重排等)。
  3. 检测前端功能缺陷(渲染异常、交互逻辑错误、兼容性)。

1.2 被测页面

首页(学生实时)/、整卷测验 /exam、管理后台场控 /admin、题库&组卷 /qbank、账号管理 /admin/users、统计报告 /admin/stats、房间管理 /admin/rooms、学校管理 /admin/schools、我的记录 /me、登录 /login、个人资料 /profile。

1.3 方法论

  • 网络层真实测量:本地起服务,用 node:http(关闭自动解压)逐页请求 HTML 及所引用的所有 /static/* 资源,记录 TTFB、原始体积、gzip 后实际传输体积、请求数、缓存头。此数据为本报告核心实测值。
  • 静态代码分析:通读全部 15 个前端 JS、4 个 CSS、12 个 HTML 模板,检查资源加载策略、轮询/定时器、事件监听生命周期、大列表渲染、懒加载等。
  • Web Vitals(FCP/LCP/CLS/TTI):需在无头浏览器中运行。首轮评估时环境未安装 Playwright,故先以网络层实测 + 代码路径分析给出估算值(已明确标注);第三轮已装好 Playwright(Python 1.62 + 系统 Edge,无需下载 Chromium)并落地端到端点击流回归,见第 7 节。

1.4 指标定义

  • TTFB:首字节时间;rawKB:未压缩总传输量;gzKB:gzip 后实际传输量;gzPct:gzip 节省比例;cacheOK:带 max-age 的静态资源数。

2. 性能测试数据(真实采集)

页面 HTTP 请求数 原始KB gzip后KB 节省% HTML原始KB TTFB(ms) 缓存资源
首页(学生实时) / 200 6 176.0 102.7 41.7% 69.8 4.4 5/5
整卷测验 /exam 200 7 137.8 46.2 66.5% 3.8 0.9 6/6
我的记录 /me 200 5 70.3 24.0 65.9% 2.5 1.1 4/4
登录 /login 200 2 42.7 15.8 62.9% 6.1 19.8 1/1
个人资料 /profile 200 2 39.7 12.8 67.7% 3.1 0.7 1/1
管理端页面 /admin 等 302 未登录重定向至 /login

管理端页面(/admin、/qbank、/admin/users、/admin/stats、/admin/rooms、/admin/schools)在未登录态返回 302,无法直接采集其完整载荷;按其 <script> 引用与文件体积估算见下表。

管理端页面载荷估算(未登录态为 302,按静态资源组成估算 gzip 后传输量)

页面 引用静态 JS(原始) +CSS 估算 gzip 后 页面 HTML 原始
题库&组卷 /qbank ~154.6KB +16.6 ~50KB 20.9KB
场控 /admin ~50.3KB +36.5(style) ~30KB 84.0KB
统计报告 /admin/stats ~82.1KB +36.5 ~33KB 8.0KB
账号管理 /admin/users ~92.0KB +36.5 ~35KB 19.8KB
房间管理 /admin/rooms ~65.0KB +36.5 ~26KB 10.3KB
学校管理 /admin/schools ~31.0KB +36.5 ~15KB 5.4KB

说明:wangEditor(1.3MB) 仅在打开题库编辑弹窗时懒加载;Excel 判分引擎(ExcelJS+XLSX+HyperFormula+JSZip ≈ 2.56MB) 仅在学生作答 Excel/WPS 题时懒加载,均不计入首屏

2.1 关键结论(实测)

  • TTFB 极佳:局域网回环下 0.5–33ms(首冷 33ms,后续 ≈1ms),服务端与网络不是瓶颈。
  • 静态资源 gzip + 长期缓存已生效:所有 /static/* 资源均带 content-encoding: gzipcache-control: max-age=86400,节省 41%–67% 传输量。二次访问静态资源命中缓存、几乎零传输。
  • HTML 响应未压缩:所有页面 HTML 由 TemplateResponse 返回,未走 CachedStaticFiles 的 gzip 分支_GZIP_EXT 仅含 .js/.css/.json/.svg/.map/.txt),实测 htmlGzKB == htmlRawKB。首页 HTML 69.8KB、场控 HTML 84.0KB 均以明文传输。
  • 大内联脚本未独立缓存:首页 index.html 含约 57KB 页面逻辑内联脚本、场控 admin.html 含约 84KB 内联脚本。内联脚本随 HTML 每次整页刷新重新下载,无法享受 max-age=86400

2.2 估算的渲染指标(需无头浏览器最终确认)

基于首屏传输量与渲染方式(原生 JS + innerHTML,无框架 hydration),估算: - 首页 FCP/LCP:局域网内 < 300ms;公网(gzip 后 ~53KB)约 0.5–1.2s。 - 场控页:受 84KB 未压缩 HTML 拖累,公网下 LCP 可能达 1.5–2.5s(开启 HTML gzip 后显著下降)。 - 交互响应:选项点击/提交为同步 innerHTML + class 切换,单次 < 16ms,流畅;仅当列表一次性渲染上千项(见 P4)才会出现可感知卡顿。


3. 性能瓶颈与资源加载问题(按严重度)

P0 — HTML 响应未启用 gzip(影响所有页面首屏)

  • 现象:所有 HTML 页面明文传输,首页 69.8KB、场控 84.0KB 未压缩。
  • 位置main.py CachedStaticFiles 仅对静态扩展名压缩;HTML 由 templates.TemplateResponse 直接返回,未压缩。
  • 影响:公网/弱网首屏传输量翻倍;场控页尤甚。
  • 建议:为 HTML 响应增加 gzip(或在反向代理/EdgeOne 层开启 text/html 压缩)。本地局域网可忽略,但公网部署收益大。

P1 — 大内联脚本未独立缓存(首页 / 场控重复下载)

  • 现象index.html(~57KB 内联)、admin.html(~84KB 内联) 的页面逻辑写在 HTML 内,每次整页导航都重新下载且不可缓存。
  • 位置templates/index.html 第 125–1474 行内联脚本;templates/admin.html 内联脚本。
  • 影响:二次访问首页仍需下载 ~70KB(本可省);与 P0 叠加,场控页二次访问仍 ~84KB。
  • 建议:将页面专属脚本抽成 /static/index.js/static/admin.js(已带 ?v= 版本号与 max-age=86400),HTML 仅保留极薄引导。可即时把二次访问首页传输从 ~70KB 降到 ~0(静态已缓存)。

P2 — 实时答题轮询兜底频次偏高(WS 断线时)

  • 现象index.html 第 321 行 setInterval(refreshNow, 800) 在 WebSocket 未连时每 800ms 拉一次 /api/current(≈1.25 req/s/人);第 324 行另有 10s 心跳。
  • 位置templates/index.html 321、324 行。
  • 影响:正常(WS 连通)时仅 10s/次,开销极低;但 WS 不可用(如反代未透传 WS)时回退轮询会给服务端加压。
  • 建议:可改用 uc.js 已有的 vizInterval(后台标签页暂停)并保留 800ms 兜底;同时确认部署链路 WS 透传。属健壮性优化,非首屏瓶颈。

P2 — 题库组卷拉取 limit=2000(大数据量校)

  • 现象qbank.js 第 884、913 行组卷时 apiGet("/api/qbank/qs?parent=-1&limit=2000");第 153 行分页组件缺失时回退 limit=2000 一次性渲染最多 2000 张卡片。
  • 位置static/qbank.js 153、884、913 行。
  • 影响:题库上千题的学校,组卷弹窗打开与渲染可能卡顿(一次性 innerHTML 2000 行)。
  • 建议:组卷选择改用服务端关键词搜索 + 分页/虚拟滚动,避免一次性拉全量;分页组件已默认启用,重点优化组卷拉取路径。

已验证的良好实践(正面,非问题)

  • 静态资源 gzip + max-age=86400 缓存策略完整有效。
  • Excel 判分引擎(~2.56MB)与 wangEditor(1.3MB)均懒加载,不进首屏。
  • uc.jsvizInterval(可见性感知轮询)已在场控/大厅/房间列表采用,后台标签页暂停请求。
  • 题库列表默认走 pager.js 分页,非全量渲染。

4. 功能缺陷检测(Bug)

B1(已确认)— ucMount 重复绑定 document 级监听,存在监听器泄漏/重复执行

  • 位置static/uc.js 第 301–306 行(ucMountdocument.addEventListener("click", ...)document.addEventListener("keydown", ...))。
  • 根因ucMount 每次被调用都新增匿名 document 监听且不移除旧监听;index.htmlrenderNav() 在一次登录流程中调用 ucMount 至少 2 次(afterLogin 第 247、261 行),导致重复绑定。
  • 影响:每次页面内 click/Esc 会触发多次 closeMenu;长会话/多次登录累积匿名监听,属典型内存泄漏模式。
  • 复现:学生登录 → 浏览器控制台 getEventListeners(document)(DevTools)可见多个相同 click 监听。
  • 建议:加幂等守卫(参考同文件 fetch hook 的 window.__quizRoomFetchHooked 做法),或在 ucMount 开头 removeEventListener 旧 handler(改用命名函数)。

B2(已知风险,待浏览器回归)— 高复杂度交互路径未见自动化测试

  • 材料大题逐小题提交/判分(index.html matSubmit/renderMaterial)、实时 Excel/WPS 判分重建(renderExcelLive 换题重建面板)、抢答倒计时与权限切换等逻辑分支多。静态阅读未发现确定性错误,但缺少端到端回归,建议在无头浏览器中补充点击流验证(选项乱序 shuffleOpts 缓存一致性、材料大题答错停留计时、收题后重建状态)。

B3(兼容性提示)— 复制按钮依赖安全上下文

  • uc.js copyText 在非 https/非 isSecureContext 时回退 document.execCommand("copy")。局域网 http://IP 场景下 clipboard API 不可用属预期,回退路径已覆盖;但若未来强制 HTTPS,需确认 execCommand 被废弃后的兼容。

5. 优化建议汇总

编号 建议 收益 工作量 优先级 状态
P0 HTML 响应开启 gzip(服务层或反代层) 公网首屏传输 -50%~70% ✅ 已实施(Starlette GZipMiddleware)
P1 首页/场控内联脚本抽离为缓存静态文件 二次访问首页/场控传输→≈0 ✅ 已实施(外联 static/index.js、static/admin.js)
P2 实时页轮询兜底放宽 + 确认 WS 透传 断线场景请求量下降 84% ✅ 已实施(800ms→5s;WS 重连即刷新)
P2 组卷拉取改服务端搜索+分页,避免 limit=2000 组卷接口单次传输 -92.9% ✅ 已实施(复用已有筛选 + 新增分页条)
B1 ucMount 监听幂等化(命名函数 + remove) 消除监听器泄漏 ✅ 已实施(模块级一次性绑定)
B2 补充无头浏览器端到端点击流回归 拦截交互逻辑缺陷 ✅ 已实施_e2e_smoke.py,47/47 通过)

6. 已实施的优化与复测结果(2026-09-15)

6.1 改动清单

  • P0 HTML gzipmain.py 注册 starlette.middleware.gzip.GZipMiddleware(minimum_size=1024, compresslevel=6)。 直接用 Starlette 内置中间件而非自写,原因是 TemplateResponse 的 body 在 send 阶段才渲染、自定义 @app.middleware 层取不到 resp.body(实测 bodyNone);内置中间件在 ASGI 流上缓冲压缩,且自动跳过已带 content-encoding 的静态资源、<1024B 小响应、不支持 gzip 的客户端,绝不重复压缩。
  • P1 内联脚本外联templates/index.htmltemplates/admin.html 的页面逻辑内联块抽离为 static/index.js(54.6KB)、static/admin.js(63.5KB),模板改为 <script src="{{ sv('/static/xxx.js') }}"></script>。 二者随 CachedStaticFiles 获得 max-age=86400 + gzip;sv() 版本号随文件变更自动击穿旧缓存。HTML 本身仍由 html_no_storeno-store(改动即时生效)。
  • B1 监听幂等化static/uc.jsucMount 不再每次 document.addEventListener,改为模块加载时一次性绑定(闭包引用模块级 HOST/closeMenu/closeModal),renderNav 多次调用不再重复绑定,消除泄漏/重复执行。

6.2 复测数据(新代码,本地 8138 端口实测)

页面 状态 请求数 HTML 原始→gzip 总原始KB 总gzip后KB gzip节省
首页 / 200 7 7.4→2.7 177.8 54.9 69.1%
整卷测验 /exam 200 7 3.8→1.7 138.2 44.3 67.9%
我的记录 /me 200 5 2.5→1.1 70.6 22.8 67.8%
登录 /login 200 2 6.1→2.7 42.7 12.4 71%
个人资料 /profile 200 2 3.1→1.7 39.7 11.3 71.4%

6.3 前后对比(首页 / 为例)

指标 优化前 优化后 变化
HTML 传输 69.8KB(明文) 2.7KB(gzip) -96%
首页总 gzip 传输 102.7KB 54.9KB -47%
二次访问静态 重下 57KB 内联 命中 max-age=86400 跳过 ≈0 额外传输

场控页 /admin.html 模板同样已被 gzip(content-encoding: gzip);static/admin.jsmax-age=86400 缓存。管理端页面在登录态下整体首屏传输显著下降。

6.4 验证

  • 三个 JS 文件 node --check 通过;静态资源 index.js/admin.js 返回 content-encoding: gzip + cache-control: public, max-age=86400(未二次压缩);HTML 与 JSON 均带 content-encoding: gzipvary: Accept-Encoding
  • /admin 未登录仍正确 302 跳转 /login/admin.html 未登录返回登录页(含"登录"、无后台内容),无越权暴露。

7. 第三轮:P2 优化落地与 B2 端到端回归(2026-09-15)

7.1 改动清单

P2-a 实时页轮询兜底放宽 + WS 透传确认static/index.js) - WS 断线兜底轮询 800ms → 5000ms:断线场景每人请求量由 1.25 req/s 降至 0.2 req/s(-84%);WS 连通时仍按原 10s 心跳,实时性不受影响(实时推送走 WS)。 - ws.onopen 增加一次 refreshNow():WS(重)连成功后立即同步服务端状态,消除"重连后要等下一拍"的状态空窗。

P2-b 组卷候选改服务端分页static/qbank.jstemplates/qbank.html) - 废弃 apiGet("/api/qbank/qs?parent=-1&limit=2000") 全量拉取,改为按 limit/offset 真分页(默认 50/页,可选 20/50/100 并 localStorage 记忆)。 - 复用组卷面板已有的筛选控件(关键词/题型/标签):筛选变更回到第 1 页,翻页保持筛选;分页条复用全站 createPager(新增 #pk-pager)。 - renderPick 的大题头索引由"当前页"改为跨页累积的 allQ,分页后小题仍能正确标注所属材料大题。 - 「随机抽 N 题」改用 GET /api/qbank/qs/ids(只返回 id,实测 1.7KB)抽样后按需取详情,替代原先拉 2000 行全量;保持"排除材料大题/编程题/小题"的原有语义。 - 兜底:createPager 不可用时仍回退一次性加载,功能不中断。

B2 无头浏览器端到端点击流回归(新增 _e2e_smoke.py) - 技术选型:Playwright(Python 1.62) + 系统 Edgechannel="msedge"),无需下载 100MB+ Chromium。 - 自建临时 SQLite 库(复制 quiz_clean.db)+ 自动起服务 + 播种测试账号与 60 道题,跑完自动清理,不触碰开发用 MySQL 库。 - 覆盖:10 项源码回归断言 + 匿名页点击流 + 登录点击流 + 管理端 6 个页面 + 组卷分页回归 + WebSocket 透传,共 47 项断言

7.2 实测数据:组卷候选接口传输量(944 题,gzip 后)

请求 传输量(gzip) TTFB 返回条数
旧:?parent=-1&limit=2000 131.2KB 49.0ms 944
新:?parent=-1&limit=50&offset=0 9.3KB 4.7ms 50
随机抽题:/qs/ids?parent=-1 1.7KB 2.0ms 944(id)

单次组卷筛选传输量下降 92.9%,TTFB 下降约 90%;同时避免了 944~2000 张卡片的一次性 innerHTML 渲染(DOM 节点数由 944 降至 50/页)。

7.3 端到端回归结果(python _e2e_smoke.py

结果:47/47 通过

关键断言(节选): - 源码回归:P2-a 间隔 5000、WS 重连刷新、组卷无 limit=2000pkPager/#pk-pager/qs/ids、gzip 中间件、内联脚本外联等 10 项。 - 页面点击流://login/exam/admin(场控)、/admin/rooms/admin/users/admin/stats/me/profile 全部 HTTP 200、外联脚本执行成功、无 console 报错、无 4xx/5xx 资源。 - 登录点击流:真实填表(选学校 + 账号 + 密码)→ POST /api/auth/login 200 → 跳转离开 /login。 - 组卷分页:请求为 limit=50&offset=0 → 点「下一页」变 offset=50;分页条显示"共 944 条";全流程无 limit=2000。 - WebSocket:建房后连 /ws 握手成功并收到服务端推送(rank)。

唯一"非缺陷"现象/admin不带房间上下文(?room=)直接打开,/api/current/api/rank/api/admin/questions 会返回 400("请先创建或加入房间")。这是设计要求——教师需先建房/选房(rooms.js 会把本页 ?room= 注入所有 /api 请求);按真实流程带 ?room= 进入则全部 200、无任何报错。

7.4 复跑方式

python _e2e_smoke.py                                     # 自动建临时库+起服务+回归,跑完自动清理
python _e2e_smoke.py --keep                              # 保留临时库与服务日志(排查用)
python _e2e_smoke.py --base-url http://127.0.0.1:8000    # 复核已在运行的服务

8. 结论与下一步

结论:前端整体性能在局域网场景优秀(TTFB≈1ms、静态资源 gzip+长缓存、重型引擎懒加载)。三轮优化已全部落地:P0(HTML gzip)、P1(内联脚本外联缓存)、B1(监听幂等化)、P2-a(轮询兜底放宽 + WS 透传)、P2-b(组卷服务端分页)、B2(端到端回归)。 - 首页总首屏传输由 102.7KB 降至 54.9KB(-47%),HTML 由明文 69.8KB 降至 gzip 2.7KB(-96%),二次访问静态资源近乎零传输。 - 大题库组卷单次筛选传输由 131.2KB 降至 9.3KB(-92.9%),TTFB 由 49ms 降至 4.7ms。 - WS 断线兜底请求量下降 84%(1.25 → 0.2 req/s/人)。 - 端到端点击流回归 47/47 全通过,并沉淀为可复跑脚本。

下一步: 1. 用户重启服务以加载新代码(当前 8000 端口运行的仍是旧进程)。 2. 将 _e2e_smoke.py 纳入发版前例检:改动前端后跑一次,防止交互回归。 3. 如需渲染层指标(FCP/LCP/CLS/TTI),可在同一 Playwright 会话中加 PerformanceObserver 采集——依赖已就绪,无需再装浏览器。

复测脚本与原始/对比测量数据见工作区 _perf_measure.mjs(网络层采集,可重复运行:$env:BASE="http://127.0.0.1:8000"; node _perf_measure.mjs)。