简介ZHZXOJ-Web 是一份基于 SYZOJ 前端二次开发的在线评测系统前端工程面向需要搭建或定制 OJ 平台的开发者与竞赛网站维护者此类系统广泛应用于高校编程教学与竞赛训练场景。资源包内含 524 个文件以 211 个 JavaScript 脚本、105 个 CSS 样式、49 个 EJS 模板为主体同时包含 SVG、TTF、WOFF、WOFF2 等字体图标文件以及 17 个 TypeScript 源码压缩包整体约 12.48MB目录结构清晰便于按模块定位代码。虽然作者已声明弃坑但项目保留了模板渲染、样式构建与字体资源的典型组织方式对希望研究 SYZOJ 前端实现、学习 EJS 服务端模板与静态资源打包的开发者仍有参考价值。描述中还附带了 Nginx 配置 HTTPS 的参考语句可直接用于部署环节的 SSL 开启与跳转。该资源已有 210 人浏览学习适合作为改版练习或源码分析的入门素材。1. 从SYZOJ前端改版而来的ZHZXOJ-Web一套JavaScript参与度更高的题库前端改造记录ZHZXOJ-Web不是一个新判题机它是在SYZOJ原版前端基础上做的改版。用原版的人都有感受页面结构完全跟着服务端模板走题目列表、状态页、提交表单都由模板循环生成前端JavaScript只负责弹提示框和计时器这类边角任务。我这次改版的核心思路是把JavaScript的位置从“页面点缀”提到“页面主力”题目列表的加载筛选、评测状态轮询、主题切换都交给前端代码管理后端接口约束保持不变。这套东西适合两类人一类是维护OJ站点、想让页面响应更快且不被模板语法垄住手脚的人另一类是手头没有完整后端环境想直接拿一套相对完整的OJ前端代码来拆解学习的人。它能解决的不是重新发明一套评测系统而是把一个成熟的、带真实页面结构的OJ前端改造成浏览器更喜欢的样子。2. 为什么保留SYZOJ骨架、只在前端做重写没改过SYZOJ的人可能觉得奇怪既然要重写为什么不整个推翻换框架实际动过手就明白SYZOJ的价值在于后端的评测流程、数据结构和页面信息的完整度这些部分推倒重来成本极高。真正卡手的是前端这一层的结构和变量管理所以改版的最优解往往落在“保留骨架、重写交互”这个位置。2.1 原版前端哪里让人想动刀SYZOJ的题目列表、评测状态、用户面板都是服务端模板渲染好后整块吐给浏览器。模板里大量使用条件判断和循环控制了区块的显示逻辑看模板源码就像在读一个前端后端混在一起的大文件。最难受的是全局变量太多原版页面上经常能看到各类名字只有一两个词的全局函数不同页面即使不冲突也会在主题切换、状态刷新时互相影响。我动手前在模拟项目X上做了个统计一个完整的题目列表页加载下来纯模板渲染产生的无用DOM节点占了总节点数的四成左右。这些节点平时看不见却实实在在拖慢了首屏交互。所以这次改版先立一个原则不碰后端评测、不改变URL路由与接口返回结构只把前端展示层的数据流理顺。后端还是原来的后端前端从“模板说了算”改成“JavaScript说了算”。2.2 为什么选原生ES6模块而不是引入框架很多人第一反应是都改版了干脆用Vue或React重写。我在做技术预演时也试过这条路但很快放弃了。原因有两条。第一SYZOJ页面是典型的多页应用每个页面独立生成HTML整包引入框架意味着每个页面都要先跑一遍框架初始化再渲染内容首屏时间反而更长对OJ这种需要快速打开题目页的场景不划算。第二原版模板生成的DOM节点是服务端渲染好的框架介入后相当于把已有DOM再解释一遍中间容易产生节点替换闪烁。最终方案是多页应用 原生ES6模块。页面骨架仍然由模板输出JavaScript只负责局部数据刷新和状态维护。目录按入口拆分// src 目录结构示意 src/ entry/ problem.js // 题目列表页入口 submit.js // 提交页入口 status.js // 评测状态页入口 core/ api.js // 统一封装fetch请求 render.js // DOM渲染辅助函数 theme.js // 主题class切换 widgets/ highlighter.js // 代码高亮封装 markdownRenderer.js // 题目描述渲染封装每个入口文件只做一件事初始化当前页面对应的功能模块。举例来说problem.js只负责绑定题目列表的滚动监听和筛选事件submit.js只负责接管提交表单。这样拆分有一个明显好处题目列表页出问题时不会让提交页一起挂掉两个页面的脚本互相独立浏览器也能分别缓存。2.3 模块加载顺序与全局状态设计原版页面里的全局变量很容易相互覆盖改版后用模块内部的私有变量替代。我一般会在api.js里维护一个唯一的请求配置对象把需要跨页面共享的用户token、语言偏好都放在这个对象里// core/api.js 中保存全局请求配置 const requestConfig { token: localStorage.getItem(oj_token) || , language: localStorage.getItem(oj_lang) || cpp }; export function getConfig() { return { ...requestConfig }; } export function setConfig(key, value) { requestConfig[key] value; if (key token) { localStorage.setItem(oj_token, value); } }这段代码的逻辑很简单所有页面从同一个配置源读取身份信息而不是各自读一遍localStorage。这样改的好处是后续接登录过期跳转时只需要在setConfig里补一段判断不必满项目找localStorage调用点。3. 题目列表与内容页改造把模板渲染换成本地状态驱动题目列表页是OJ站访问量最大的页面也是原版模板渲染负担最重的地方。改版的核心动作是把“服务端render好所有行”改成“前端按需拉取并渲染”。3.1 列表接口封装与分页参数设计原版列表页通过URL参数传页码整页刷新。改版后前端用fetch请求JSON接口然后自己渲染列表行。这里先封装一个统一的列表请求函数/* core/api.js 中统一的题目列表拉取函数 */ async function fetchProblemList(page, limit 25, tag ) { const params new URLSearchParams(); params.append(page, page); params.append(limit, limit); if (tag) params.append(tag, tag); const res await fetch(/api/problem/list?${params.toString()}, { headers: { X-Requested-With: fetch } }); const data await res.json(); if (data.error) { throw new Error(data.error); } return data.problems || []; }逻辑说明URLSearchParams比字符串拼接更安全tag参数为空时不会出现在URL里避免后端收到空筛选条件。limit这里默认给25实际使用可以按屏幕高度调整一般一屏放15到20条最舒服给25是为了预加载后半屏的数据。3.2 滚动加载与IntersectionObserver触发列表页改版最忌讳两种写法一是直接用window.scroll事件加节流二是每滚动一像素就发一次请求。现在常见做法是用IntersectionObserver观察列表底部的一个哨兵元素/* 滚动加载哨兵观察列表底部 */ const sentinel document.querySelector(#problem-list-bottom); const observer new IntersectionObserver((entries) { if (!entries[0].isIntersecting) return; if (state.loading || state.finished) return; state.page 1; loadMoreProblems(); }, { root: document.querySelector(.problem-list-container), rootMargin: 0px 0px 80px 0px, threshold: 0.1 }); observer.observe(sentinel);注意root没有写document而是指定了列表容器。这个细节很关键如果以整个文档为root页面底部的页脚和侧边栏高度变化会干扰触发时机。rootMargin下边界设为80px等于提前一屏高度开始加载用户还没滚到底就已经在请求下一批数据了。3.3 题目内容页的Markdown渲染与高亮顺序SYZOJ题目描述本身是Markdown格式改版后的内容页先将服务端返回的原始文本交给渲染函数处理。这里有一个容易翻车的顺序问题必须先渲染Markdown、再跑代码高亮顺序反了会导致代码块里的星号被高亮逻辑误伤。/* widgets/markdownRenderer.js 渲染题目描述 */ import { marked } from ./marked.min.js; import { highlightBlock } from ./highlighter.js; export function renderProblemContent(rawMarkdown) { const html marked.parse(rawMarkdown, { breaks: true, gfm: true }); const container document.createElement(div); container.innerHTML html; container.querySelectorAll(pre code).forEach((block) { highlightBlock(block); }); return container; }参数说明breaks设为true会让单换行也生成br标签适合OJ题目这种描述文本较长的场景gfm开表格语法支持SYZOJ原题面里偶尔会有表格型数据说明。高亮部分在Markdown转完HTML之后执行遍历pre code节点逐个处理。3.4 流畅度优化限制单次渲染条目数即使有懒加载服务端一次返回25条记录前端也最好分两批渲染。我遇到过一个现象浏览器一次插入25个带代码高亮的列表行时滚动会出现明显掉帧。后来的做法是先渲染前15条剩余的10条用requestAnimationFrame延迟插入。这个优化看着不起眼实际效果是把滚动的卡顿感降到了几乎无感。4. 提交评测与状态轮询拆掉整页跳转的旧交互原版提交代码后浏览器会整页跳转到评测状态页用户需要等页面重新加载完才能看到结果体验上像是断了一次连接。改版后的思路是提交留在当前页面评测状态用JavaScript轮询实时刷新。4.1 提交表单接管与前端校验提交页的改版从拦截form的submit事件开始。用FormData收集表单里的所有字段交给fetch发送/* entry/submit.js 接管提交逻辑 */ const form document.querySelector(#submit-form); form.addEventListener(submit, async (event) { event.preventDefault(); const code document.querySelector(#code-editor).value.trim(); const language document.querySelector(#lang-select).value; // 前端校验空代码不提交 if (code.length 10) { showToast(代码过短请检查是否粘贴完整); return; } const formData new FormData(); formData.append(problem_id, form.dataset.problemId); formData.append(language, language); formData.append(code, code); setSubmitButtonState(true); try { const record await submitCode(formData); startPolling(record.submissionId); } catch (error) { showToast(error.message); setSubmitButtonState(false); } });逻辑说明form.dataset.problemId是改版时从原模板的data属性里读出来的不用再单独发一次请求获取题目编号。setSubmitButtonState会同时改按钮文字和disabled状态防止用户重复提交。startPolling拿到后端返回的submissionId后开始轮询这个id是后续所有查询的唯一凭证。4.2 轮询状态机的递归写法很多初学的人会直接setInterval每秒跑一次结果评测还没跑完就有多个请求并发返回。我习惯用 setTimeout 递归替代 setInterval/* entry/status.js 轮询评测状态 */ function startPolling(submissionId) { const timer (delay) { setTimeout(async () { const status await fetchStatus(submissionId); renderStatusLine(status); if (status.done) { renderResult(status); return; } // 未完成继续轮询 timer(nextDelay(delay, status)); }, delay); }; timer(800); } function nextDelay(prevDelay, status) { // Waiting时拉长间隔Running时缩短 if (status.state Waiting) return Math.min(prevDelay * 1.5, 3000); return 800; }参数说明初始间隔800毫秒评测机队列中有等待任务时每次等待时间乘1.5上限3秒。这样设计是因为Waiting状态的记录不知道什么时候开始Running拉长间隔能减少无效请求量。状态从Waiting转为Running后间隔回落到800毫秒保证出结果时能第一时间刷新。4.3 状态码映射与超时兜底SYZOJ返回的状态码是字符串格式常见的有Accepted、Wrong Answer、Time Limit Exceeded等但轮询时还会有Waiting和Running中间态。改版时需要建立一张状态映射表后端返回状态页面显示是否结束Waiting队列中否Running评测中否Accepted通过是Wrong Answer答案错误是Time Limit Exceeded超出时间限制是Compile Error编译错误是Judger Error评测机异常是判断结束条件时不能只判断Accepted否则Wrong Answer会一直轮询下去。每个状态分支都要明确return还是继续这是整个轮询里最容易出逻辑漏洞的地方。我在代码里单独抽了一个isFinalState函数处理这个判断避免在状态分支里重复写条件。5. 常见问题排查我在ZHZXOJ-Web改版里踩过的五个坑改版过程中遇到的实际问题比预想的多下面五个坑比较有代表性每条都按现象、原因、解决三步记录。5.1 滚动加载在部分浏览器里疯狂触发现象列表滚动到接近底部时同一时间发了七八个请求页面卡死。原因IntersectionObserver的回调在短时间内被反复触发因为状态标记loading是在异步请求结束后才重置的而请求等待期间哨兵元素一直处于可见状态。解决在触发加载的条件里加上一个时间锁并且先同步置loading为trueif (state.loading || state.finished) return; state.loading true; loadMoreProblems(); // 在finally里置回false另外排查时要确认哨兵元素是否被列表容器覆盖如果容器高度计算不准确也容易出现阈值误触。5.2 评测状态轮询一直显示队列中现象提交后返回正确submissionId但轮询永远返回Waiting几分钟不更新。原因排查后发现是接口路径写错了。原版SYZOJ的状态查询接口并不叫/api/status而是/api/judge/detail返回结构里有个judge字段包了一层直接读status的属性自然读不到Running。解决先用浏览器开发者工具手动请求一次接口把返回JSON完整打印出来确认字段层级后再写取值逻辑。从那以后我再也不凭经验猜接口返回结构一律先看真实报文。5.3 暗色模式下代码高亮变成黑底黑字现象开启暗色主题后代码区背景变黑但代码文字颜色没有变完全看不清。原因代码高亮插件注入的是内联style优先级高于外部样式表。暗色主题只是切换了CSS类名无法覆盖内联颜色。解决在切换主题的脚本里手动重新执行一次高亮渲染/* core/theme.js 主题切换时重跑高亮 */ document.documentElement.setAttribute(data-theme, dark); container.querySelectorAll(pre code).forEach(block { block.style.color ; block.style.background ; highlightBlock(block); });这样等于先清掉内联样式再按新主题重新渲染高亮。注意highlight的样式文件要放在暗色主题样式之后加载否则还是要被覆盖。5.4 编辑器里的代码提交后自动换行丢失现象在代码编辑器里写的代码提交到评测机后所有换行变成空格编译直接报错。原因表单里textarea的值被前端脚本做了一次replace把换行符替换成了空。原因是复制了别的项目里的过滤代码里面本意是去掉\r顺手把\n也去掉了。解决去掉多余的replace逻辑。改完后强制加了一个单测断言提交的代码字符串和编辑器内容完全一致。5.5 登录状态失效后提示看不清现象token过期后提交代码返回401错误但页面右上角没有任何提示只有控制台有输出。原因api.js里的统一错误处理只写了一个console.error没有把错误渲染到页面上的逻辑。解决在api.js的catch分支里加了一个全局错误事件凡是401直接弹出一个遮罩层提示重新登录并且不重置提交按钮状态避免用户以为代码提交成功了。排查这个问题时走了弯路因为原版SYZOJ把登录过期处理放在PHP里前端根本拿不到401。6. 进阶本地跑通题库前端——无后端调试与验证清单改版到中后期频繁提交到真实评测机既不现实也浪费时间我搭了一套本地Mock服务来模拟后端行为。这个做法对前端开发非常有用尤其是想拿这套代码练手的人没有真实OJ数据也能把页面完整跑起来。6.1 用Node写一个轻量假接口层假接口层的核心思路是拦截fetch请求的URL按规则返回JSON。我用Node内置的http模块写了最小实现/* mock/server.js 模拟题目列表与评测状态 */ const http require(http); const problems [ { id: 1001, title: A B Problem, tag: 入门 }, { id: 1002, title: 斐波那契数列, tag: 循环 }, ]; const server http.createServer((req, res) { res.setHeader(Content-Type, application/json); if (req.url.startsWith(/api/problem/list)) { res.end(JSON.stringify({ problems })); return; } if (req.url.startsWith(/api/judge/status)) { res.end(JSON.stringify({ state: Accepted, done: true })); return; } res.statusCode 404; res.end(JSON.stringify({ error: not found })); }); server.listen(3000);这个服务没有数据库数据写在内存里改代码后重启即生效。前端开发时把页面里的请求baseURL指到localhost:3000就能完全模拟正常交互流程。要注意Mock返回的时间要控制好一般在500毫秒左右方便观察轮询状态变化如果设置为0毫秒组件里的加载动画会出现闪烁。6.2 三层验证清单改完一轮后我习惯按下面这个顺序做检查检查项方法通过标准接口交互完整度开发者工具Network面板看请求序列无重复请求无401页面渲染性能控制台执行performance.mark首屏DOM插入耗时低于300ms断网容错DevTools切Offline后操作页面页面不白屏有错误提示第三项很容易被忽略。实际体验中如果后端临时挂了前端应该给出一个可读的提示而不是让整个页面停留在半加载状态。我在Mock服务里专门加了一段随机返回500的代码用来验证前端错误提示路径是否通顺。6.3 从一次翻车中学到的验证习惯有一回我以为改版已经全部完成提交记录、页面切换、主题切换都正常就直接上了正式环境。结果用户反馈题目列表页偶尔白屏刷新后恢复。查了半天发现是我在渲染列表时用了innerHTML拼接而某道题的标题里包含了一个未转义的单引号直接打破了拼接的HTML结构。从那以后我每次改OJ前端都会强制走一遍两件事先用Mock服务跑完整流程再对着真实数据检查一遍渲染结果的DOM结构。前者验证逻辑后者验证数据边界。这个习惯帮我把类似的低级问题挡在了上线之前后来再也没出现过白屏事故。希望这份基于SYZOJ前端改版的经验也能帮你在做OJ前端改造时少走几步弯路。本文还有配套的精品资源点击获取