接手OJ这类在线评测系统的前端页面时,很多人第一反应是“这不就是一个带代码编辑器的后台管理系统吗”。真做起来才发现,题目列表的筛选跳转、编辑器与评测结果的联动、竞赛模式下的锁定状态,每一个环节都有专门的玩法。我把最近一期OJ前端完整开发中踩过的坑、验证过可行的方法和最终敲定的技术选型整理出来,给准备入坑或者正在挣扎的同学一个参照。
1. 为什么OJ前端不能照搬“后台管理系统”的经验
1.1 在线评测:从提交到判定的完整链路里,前端到底参与了多少
OJ(Online Judge,在线评测系统)的核心链路是这样的:用户在前端页面选择题目、编写代码、点击提交;代码被送往评测机,经过编译、运行、比对输入输出后,产生一个判定结果(AC、WA、TLE、MLE、RE、CE等);最后这个结果回到前端展示给用户。
在这个过程中,很多人以为前端只是“展示题目 + 收代码 + 轮询结果”的壳子。但真去把页面做细,你会发现前端承担的不只是展示,而是整个评测体验的第一道关口和最后一公里。
以最基础的“代码提交”为例,前端需要处理:编辑器内容获取、语言选择与模板填充、输入法组合态与快捷键冲突、提交前代码体积校验、重复提交的防抖限制、提交后评测状态的有序切换。任何一个环节处理不好,用户在评测高峰期的体验都会直线下降。
OJ前端区别于普通后台的另一点在于:后台系统的用户是运营或管理员,操作频率低,页面设计偏功能堆砌;而OJ的用户是学生或参赛者,他们会在比赛期间连续高频操作,翻题、写码、提交、看结果,重复循环几个小时。这种情况下,“响应速度”和“状态一致性”远比“功能数量”重要。
1.2 OJ前端与普通CRUD页面的三点本质差异
第一,OJ前端有一个核心状态机:从Waiting到Judging再到Accepted/Compile Error等。这个状态机贯穿整个提交记录、题目状态、个人状态的面板展示。普通CRUD页面是“提交表单 -> 后端返回 -> 展示结果”,而OJ前端需要处理异步评测可能跨越数秒甚至数十秒的长时间等待,并且要在等待期间允许用户继续做其他操作,比如切到下一题继续写。这意味着全局状态管理不能只用一个弹窗把用户“堵住”。
第二,OJ前端对编辑器体验的要求远高于普通页面。普通后台的代码块可能只需要一个只读展示,但OJ要的是一个完整的IDE编辑区:语法高亮、行号、代码折叠、自动缩进、括号匹配、多光标编辑、甚至代码补全。这类需求直接把编辑器的技术选型从textarea提升到了Monaco Editor或CodeMirror 6这个级别。
第三,OJ前端在比赛/考试场景下有大量“反人类”的锁定需求:禁止复制粘贴、禁止切出页面、禁止开发者工具、倒计时严格同步等。这不是普通业务系统会遇到的交互限制,而是一个独立的规则层,前端必须在架构上留好空间。
所以,我的结论是:OJ前端可以借用后台管理系统的基础组件库,但在编辑器、状态管理、评测展示、比赛规则这几块必须专门设计,不能指望一套通用模板打天下。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 编辑器选型:Monaco与CodeMirror 6的博弈
2.1 第一轮筛选:两个候选的实际表现差异
代码编辑器是整个OJ前端的灵魂组件。我在这期项目里主要对比了两大阵营:VS Code同源的Monaco Editor和新兴的CodeMirror 6。
先列几个真实体感上的差异。
Monaco Editor的优势非常明显:开箱即用的丰富功能,语法高亮、智能提示、代码折叠、多光标、Diff编辑器、断点调试样样都有,而且有VS Code的生态背书,几乎不用怎么配置就能获得接近IDE的体验。缺点也很明显:体积大(gzip后仍接近1MB),初始化有一定耗时,依赖Web Worker做语言服务,对低端机器不友好。
CodeMirror 6的优势是模块化极强、体积小(按需引入后一般几十KB)、性能好,而且它把“扩展机制”设计得极其优雅,基本所有能力都是通过Extension组合出来的。代价是学习曲线陡峭,多数能力需要自己组合,不像Monaco那样开箱即用。
我做了一个快速比对表:
| 维度 | Monaco Editor | CodeMirror 6 |
|---|---|---|
| 内置功能完整度 | 高,接近IDE | 中等,需自行组合 |
| 包体体积 | 大(约1MB+ gzip) | 小(约80KB gzip,按需) |
| 对Web Worker的依赖 | 强依赖,配置不当易白屏 | 可选,纯前端模式也能用 |
| 中文输入法兼容性 | 需处理composition事件 | 内置处理较好 |
| 主题与定制灵活度 | 中等,需覆盖CSS变量 | 高,extends机制灵活 |
| 社区资料量 | 多 | 相对少但官方文档质量高 |
这期项目最终选择了Monaco,主要原因是团队需要快速交付,而Monaco能直接提供完整IDE体验。如果你是要长期打磨、追求极致性能和包体可控,且愿意投入时间在扩展开发上,CodeMirror 6是更优的选择。
2.2 Monaco接入时的Worker配置与中文输入法问题
Monaco接入过程中,最容易被绊倒的就是Web Worker配置。
如果用Vite构建,需要在vite.config里显式配置worker格式,否则Monaco会尝试从错误路径加载worker,导致编辑器白屏或语言服务失效。推荐的做法是使用monaco-editor官方提供的loader或Vite插件vite-plugin-monaco-editor,把worker文件正确打包。我用的是手动配置方案:
javascript复制// vite.config.js
import { defineConfig } from 'vite';
import monacoEditorPlugin from 'vite-plugin-monaco-editor';
export default defineConfig({
plugins: [
monacoEditorPlugin({
languageWorkers: ['editorWorkerService', 'cpp', 'java', 'python'],
}),
],
});
这里有个关键细节:不要把所有语言的worker都打进来。OJ系统支持的语言不会太多(一般C/C++、Java、Python三件套),手动指定languageWorkers可以把体积控制在合理范围。如果图省事不指定,Monaco会加载全部语言服务,首屏资源体积直接翻倍。
另一个容易踩的坑是中文输入法。在Monaco里输入中文时,如果composition事件处理不当,会出现拼音字母残留、光标跳动、候选框错位等问题。Monaco官方对中文输入法的支持在新版本里已经好很多,但老版本仍有残影问题。稳妥的做法是在编辑器创建时挂上composition事件处理,在拼音组合期间禁用自动补全和格式调整:
javascript复制editor.onDidCompositionStart(() => {
isComposing = true;
});
editor.onDidCompositionEnd(() => {
isComposing = false;
});
然后在提交、快捷键、补全等逻辑里判断isComposing状态,避免在用户还在打字时触发意外操作。
2.3 针对竞赛场景的编辑器定制
OJ的用户提交代码场景和普通开发者写代码不太一样:用户在OJ页面里写代码通常是一次性的、短时的、单文件的,不需要打开工程、不需要调试器(评测机负责运行)、也不需要复杂的重构工具。所以编辑器定制不需要完全照搬IDE,重点放在快速上手、减少干扰、适配提交流程上。
我做了一个轻量定制方案:
- 默认语言模板填充:用户切换语言时,自动填充对应的main函数模板(C的
#include <stdio.h>、Java的public class Main、Python的def main():),让用户不用费时间敲样板代码。 - Tab键行为:默认Tab插入空格而非切换焦点,且缩进宽度根据语言默认值配置(C++为4空格,Python为4空格)。
- Ctrl+Enter提交:监听编辑器快捷键,用户按下Ctrl+Enter直接触发提交,减少从编辑器切到提交按钮的操作成本。
- 只读模式与锁定:比赛结束后或比赛进行中管理员锁定题目时,将编辑器置为readOnly并显示锁定提示。
这些定制功能不复杂,但实测能显著降低用户在比赛中的操作摩擦。
3. 题目页与提交评测链路的设计
3.1 题目描述、数据范围、样例展示的渲染方案
题目详情页是OJ最高频的页面,它的核心任务是把一道算法题完整地传递给用户:题干描述、输入输出格式、数据范围限制、样例输入输出、提示信息。这些内容通常以Markdown或富文本形式存储。
渲染方案上,主流的做法是:后端将题目描述以Markdown格式存储,前端用Markdown解析器渲染成HTML。这里有个关键点:题目描述中会包含大量代码块和数学公式,特别是数据范围里的10^9、2^{31}-1这类表达,最好用LaTeX公式渲染,否则直接渲染成纯文本可读性会很差。
我最终用的方案是:React + react-markdown + remark-math + rehype-katex。react-markdown负责解析Markdown,remark-math解析数学公式语法,rehype-katex用KaTeX渲染公式。选择KaTeX而不是MathJax,是因为KaTeX渲染速度更快,体积更小,对前端性能友好。
jsx复制import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
function ProblemDescription({ content }) {
return (
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
>
{content}
</ReactMarkdown>
);
}
这里有一个安全要点:Markdown渲染必须做XSS过滤。题目描述是管理员上传的,但如果OJ开放了用户讨论区,或者题目描述支持外部链接、HTML混排,就存在注入风险。react-markdown默认不渲染原始HTML,可以视为一层保护;如果还需要支持特定HTML,务必使用sanitize-html或DOMPurify做白名单过滤。
3.2 代码提交与状态机的设计
用户点击提交后,前端要做的事情比表面看起来多得多。我把提交逻辑拆分成了三个层次:
第一层是提交前校验。主要是前端侧的快校验:代码是否为空(或只含空白字符)、代码长度是否超出限制(一般限制64KB到256KB不等)、语言是否选择完整。这些校验极快,不用走后端请求,能拦截掉80%的无效提交。
第二层是提交节流与防抖。评测机资源是有限的,一个用户在短时间内频繁提交(比如反复试错同一题),会浪费评测资源,也可能触发评测队列拥堵。前端需要做节流:同一用户同一道题,两次提交之间至少间隔1-2秒;连续多次错误提交时,提示用户再等等。这个策略对比赛场景尤其重要,能有效防止评测队列被刷爆。
第三层是提交状态机。提交后,代码会进入评测队列,状态依次经历:
code复制Pending -> Judging -> Accepted
-> Wrong Answer
-> Time Limit Exceeded
-> Memory Limit Exceeded
-> Runtime Error
-> Compile Error
前端需要维护一个全局的“当前提交状态”,在页面顶部或侧边固定展示,并且允许用户提交后继续做其他事,不阻塞用户切题。状态变化时通过全局事件或状态管理通知相关组件更新。
我在这里选择了使用Zustand做全局状态管理,而不是Redux,因为Zustand的写法更简洁,且对高频状态更新更友好。提交状态被设计成独立store,与题目列表store、用户store解耦,避免一个状态变化牵动全页面重渲染。
3.3 提交结果面板的细节:错误提示的友好化改造
提交结果面板是用户最在意的输出区域。一个合格的OJ前端,不能只把后端的judgeResult字符串原样展示出来,要做分层处理。
AC(Accepted)时,展示通过信息和耗时、内存占用,字号大一些,颜色友好一些,最好带一个简洁的动画反馈,让用户获得正向激励。
非AC时,展示要有信息层次:
- CE(Compile Error):直接把编译错误日志展示出来。这里有个细节:编译日志通常是多行的,且可能包含非UTF-8编码的字符(比如中文编译器输出的GBK日志),前端要对转码做兜底,否则会出现乱码。
- WA(Wrong Answer):展示出错的测试点编号(如果有)、输入、期望输出、实际输出。前端要处理大文本样本的截断显示,不能一股脑把几十KB的log塞到页面上。
- TLE/MLE:展示运行时间和内存峰值,并明确标出超过限制的数值。
- RE(Runtime Error):展示运行时错误信息。C++的RE可能是段错误、浮点异常等,Java的RE则可能是异常堆栈。这里要注意日志中可能包含绝对路径,出于安全考虑最好由后端在返回前过滤,而不是前端做字符串替换。
我做了一个“错误信息折叠面板”的组件:默认只展示一行摘要,比如“Wrong Answer on test 5”,用户点击展开后才看完整对比。这样既保证了信息完整,又不会一上来就吓到用户。
4. 评测状态实时反馈:轮询还是WebSocket
4.1 为什么先做的轮询方案
评测状态从“提交成功”到“最终结果”之间,有一段等待时间。这个等待时间里,前端必须持续获取最新状态。实现方式无非是轮询或WebSocket推送。
我在项目一期用的是轮询方案,原因很朴素:评测机的数量有限,评测时长一般在1-5秒之间,判断频率不需要特别高;相比WebSocket的长连接维护成本,轮询的实现更简单,出问题时也更容易定位。
轮询的关键在于间隔策略,不能固定一个频率傻等。我的做法是基于“评测阶段”动态调整:
- 提交成功后0-2秒内,轮询间隔设为1秒。因为正常编译阶段的耗时很短,状态可能每分钟都在变。
- 如果状态一直是Pending,说明评测队列拥堵,轮询间隔拉到2秒。
- 状态切到Judging后,间隔降到1秒,因为即将出结果。
- 超过15秒状态仍未终态,间隔拉到3秒,同时前端提示“评测排队中,请耐心等待”。
这个动态间隔策略在实际运行中效果不错,既保证了状态反馈及时,又没有对评测机造成过大的查询压力。
4.2 从轮询切到WebSocket的取舍
项目后期,提交量上来了,轮询的弊端开始暴露:每1秒一次的查询请求,在几百人同时在线打比赛时,会给后端和数据库带来不必要的负载,而且评测机一旦扩容,轮询的间隔调整就跟不上节奏了。
这时候我切到了WebSocket方案。评测服务推送状态消息,前端只负责订阅和解析,消息一到就更新界面,几乎零延迟。
WebSocket方案的关键是消息协议设计。我定义了一个简单的JSON协议:
json复制{
"type": "judge_update",
"data": {
"submissionId": "123456",
"status": "Judging",
"timeUsed": 0.123,
"memoryUsed": 20480,
"detail": {}
}
}
前端根据submissionId定位到当前正在查看的提交记录或提交结果面板,更新对应状态。需要注意的是,WebSocket推送本质上仍可能丢消息(网络抖动、服务端重启),所以前端仍要保留一个兜底轮询低频拉取最终结果,比如每10秒拉一次当前提交状态。两个机制互补,既保证实时性,又保证最终一致性。
4.3 请求合并、竞态与断线重连的处理
WebSocket场景下最容易出问题的不是“收不到消息”,而是“消息乱序”和“重复推送”。评测服务在高并发下,可能先推送Judging再推送Pending,也可能同一状态推送多次。前端必须做幂等处理:记录每个submissionId的当前状态,只有当新状态能“推进”旧状态时才更新界面,否则忽略。
断线重连也是个坑。评测服务重启、网络切换、浏览器后台休眠,都会导致WebSocket连接断开。断线期间提交的状态更新会丢失,重连后必须做一次状态全量同步:前端在onclose时标记连接异常,onopen时向后端发送sync_all请求,拉取所有未完成提交的最新状态。
我最后总结出的经验是:不要把WebSocket当成终态方案,而是当成实时反馈的加速器;终态一致性的兜底,永远要交给轮询或页面刷新时的服务端渲染。
5. 列表页性能:题目列表与提交记录的双重挑战
5.1 题目列表的分页与状态位管理
OJ的题目列表页有两个核心需求:一是分页浏览所有题目,二是展示每道题目的用户状态(已通过/已尝试/未做过)。分页本身不难,难的是“状态位”的实时更新。
用户在列表页看到“题目3 已通过”,然后点进去重做了一遍,提交得到AC,回到列表页时,题目3的状态必须立刻从“已尝试”变为“已通过”。这个联动不能依赖整页刷新,而要通过提交结果事件驱动局部更新。
我的做法是:题目列表store维护一个problemId -> status的映射,status取值为untouched | tried | solved。提交结果推送更新到这个store时,对应题目的状态同步改变,列表行自动重渲染。因为store是响应式的,所以列表页即使处在分页的某一页,状态也会正确更新。
5.2 提交记录表格:虚拟滚动与单元格渲染优化
提交记录页面是另一个性能重灾区。一场比赛下来,几千条提交记录很正常,如果使用普通Table组件一次性渲染,页面会直接卡成PPT。
我最终选了虚拟滚动方案:只渲染可视区域内的行,滚动时动态替换行内容。表格组件上,我用的是@tanstack/react-table配合react-virtual实现,前者负责列定义和数据源管理,后者负责滚动窗口渲染。
jsx复制const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 10,
});
这里有一个经验之谈:提交记录表格不建议把详情内联展开在表格里。比如要看某条记录的详细log,我倾向于跳转到独立的提交详情页或者打开抽屉,而不是在表格行内展开。原因是详情数据往往很大(编译日志、错误样例可能几十KB),表格行内展示会造成严重的重渲染卡顿。
单元格渲染也有一些细节。语言类型可以用标签展示(C++/Java/Python),但标签样式要固定宽度,避免语言名变化导致列宽抖动。结果状态要有统一配色,比如AC绿色、WA红色、CE灰色,这个配色规范要贯穿题目列表、提交记录、个人状态多个页面,让用户建立条件反射。
5.3 本地缓存在用户状态展示中的作用
题目列表的状态位、每个用户自己的已通过题目数,这些数据可以从前端拿到后就做本地缓存,减少不必要的请求。我在项目里用localStorage缓存了用户已通过题目的id集合,用户再次打开列表页时,未登录或登录态过期时仍能展示上一次的通过状态(尽管不是最新)。
缓存更新时机有两个:一是用户提交得到最终结果后,如果为AC,立即更新缓存;二是页面加载时若用户登录态有效,走后端接口拉取最新状态覆盖缓存。这种“本地先行、服务端校正”的策略体验很好,尤其是在弱网环境下,列表页几乎没有加载等待。
6. 开发中绕不开的坑位清单
6.1 编辑器与文本域的空白字符陷阱
OJ的坑,很多是“隐性”的。一个最典型的例子是:用户从其他地方复制代码粘贴进编辑器,代码里包含了不可见字符(如全角空格、零宽空格、不换行空格)。这些字符不会显示在编辑器里,但提交到评测机后,编译器会报错或者产生非预期行为。
解决办法不是在前端强行清洗——因为代码是用户自己的,我们不能擅自修改其内容——而是要在提交前做一个“代码安全提示”:检测到常驻不可见字符时,弹窗提示用户“代码中包含非ASCII空白字符,是否继续提交?”。这样既保证了用户控制权,又减少了因意外字符导致的编译失败。
6.2 测评结果中多行输出与特殊字符的展示
评测结果是逐字符比对的,但展示给用户时不能把原始字符串直接怼到一个<pre>里。实际比赛中,WA结果的期望输出和实际输出经常是几十行的数组,每一行都很长。前端需要做“按行对比”:把两组输出按行拆开,逐行比对,不同之处用高亮标出。这个“行级diff”功能看起来不大,但在调试WA时极其有效。
特殊字符处理也容易踩坑:输出中可能包含\r、\t、不可见字符、超长单行。渲染时要做转移和截断,防止布局错乱。比如单行超过2000字符时,统一折叠为“该行过长已折叠,点击展开”。
6.3 考试模式下的锁定策略
如果是带考试性质的OJ(比如学校内部测验、编程竞赛),前端需要实现“锁定”策略:考试开始后,禁止用户离开考试页面、禁止复制粘贴、倒计时严格同步。这里有一个很现实的边界:纯前端无法真正防止用户作弊,因为用户完全可以关掉浏览器换一个环境。前端能做的只是提高作弊门槛,并对“可疑行为”做记录,所以不要把考试锁定的安全性寄托在前端。
实际落地时,我做了三类限制:
- 页面切换提示:监听
visibilitychange事件,用户切出页面时记录切出时间,累计超过阈值后上报后端。 - 全屏模式:进入考试时请求全屏,退出全屏时弹窗告警并记录。
- 禁用开发者工具:通过检测
devtools打开状态,进行提醒。这本质上是可被绕过的,所以只作为提醒手段,不作为安全依据。
这些锁定策略会让前端代码变得比较复杂,建议在架构上独立成模块,与正常浏览模式解耦,避免影响常规用户的体验。
6.4 代码提交时长的混淆
代码提交到页面显示最终结果,中间经历了很多环节:网络传输、排队、编译、运行、比对、回传。前端展示“评测时长”时,必须区分“运行时长”和“编译+运行+传输的总时长”。很多新人在页面上看到一个提交显示耗时5秒,就觉得是代码运行了5秒,其实是评测链路总耗时。正确做法是:页面上只展示评测机返回的timeUsed(纯运行时长),链路总耗时只放在后台或日志里,不要混着展示。
7. 画一下重点:OJ前端开发的最小知识清单
如果让我把OJ前端开发的核心经验浓缩成一份清单,大概是这几条:
第一,编辑器选型决定了开发节奏。快速交付选Monaco,长期打磨选CodeMirror 6,但无论如何要提前把Worker配置和中文输入法问题排进排期。
第二,状态管理要独立解耦。提交状态、题目状态、用户状态、比赛状态要分store管理,避免一个状态变化引发全页面重渲染。评测过程的异步性决定了全局状态必须是响应式且可组合的。
第三,评测反馈必须有兜底链条。WebSocket负责实时,轮询负责兜底,页面刷新时由服务端负责终态一致性。只用一种方案,早晚会出问题。
第四,列表页性能就是OJ前端的生命线。虚拟滚动必须用,分页状态位联动必须做,详情展示必须跳转而不是内联。
第五,安全从输入到输出全程把关。Markdown渲染要过滤XSS,代码上传要控制体积,评测日志要过滤敏感信息。
第六,考试锁定能力本质是“流程管理”而不是“安全防线”。前端能做的是记录和提醒,真正的约束要靠监考流程和线下制度。
OJ前端开发难不难?说实话,代码量不算大,但每一块拼图都有它特殊的规则和细节。只要把上述几个核心链路吃透,后续无论是做企业内训OJ、高校教学OJ,还是类似“华为OJ”“东华OJ”这类特定组织的刷题平台,都只是在同一套底层逻辑之上换皮和加业务功能而已。我现在的习惯是每做一个OJ项目,先画一遍“提交后发生了什么”的全链路图,再动工写页面,这个习惯救了我很多次。
