1. 为什么我要在编辑器里写拼题A的题
先说清楚这个插件是干什么的。拼题A(Pintia)是很多高校程序设计与数据结构课程在用的在线判题平台,题目质量不错,但它的使用体验有一个绕不开的痛点:你必须在浏览器和编辑器之间来回切。看题在网页,写代码在本地,交题又要复制粘贴回网页,中间还夹着编译器报错、样例对不上、格式错一行就WA的情况。我上学期带实验课的时候,一个班四十多人在机房同时刷题,光"把代码从本地粘到判题页面"这个动作就浪费了大量时间,更别说题目里的样例输入得手动敲进去测试。
所以我花了大概两周的业余时间,写了这么个 VS Code 插件,名字就叫 Pintia Helper。核心目标很明确:把"读题—写码—运行样例—提交—看结果"这一整条链路收进 VS Code 里,让写题这件事像写普通项目一样自然。你不需要记网页的快捷键,不需要复制粘贴,甚至不需要离开编辑器就能看到判题结果和错误提示。
这篇文章写给两类人。一类是想直接装来用的同学,我会把安装、配置、快捷键讲透;另一类是想自己动手写一个类似插件的开发者,我会把核心架构、接口调用、调试踩坑全部分享出来。整个过程涉及 VS Code 插件开发、TypeScript、Webview 通信、本地进程调用这些技术点,但我会尽量用大白话解释,基础薄弱的也能看下去。
需要提前说明的一点:插件的实现基于拼题A公开的网页接口和页面结构,所有请求都走你本人的登录态,不涉及任何绕过平台机制的行为。如果你打算长期使用,建议关注官方是否有开放API的计划,这样插件的稳定性会更好。
2. 插件整体设计与技术选型拆解
2.1 为什么选 VS Code 插件而不是别的形态
一开始我考虑过三个方向:做一个独立桌面应用、做一个浏览器油猴脚本、做 VS Code 插件。
独立桌面应用的问题在于,你写代码的环境本来就是编辑器,再开一个应用等于又多了一个窗口,还是没有解决"切来切去"的核心问题。而且跨平台打包 Electron 那一套,体积动辄上百兆,为了刷题装个这玩意儿不划算。
油猴脚本的方案我实际试过一版。好处是能直接操作拼题A的页面DOM,抓取题目信息很方便,但坏处也很明显:读题还是在浏览器里,代码还是在编辑器里写,本质上没跳出原来的框。而且浏览器沙箱对本地文件读写、子进程调用限制太多,想直接编译运行你的C++代码基本不可能。
VS Code 插件就刚好卡在中间。它本身就跑在编辑器里,能直接操作工作区文件,能调用终端,能通过child_process启动编译器,还能用 Webview 渲染题面。最关键是,VS Code 的扩展生态成熟,调试插件有内置的 Extension Host 调试器,开发体验很顺。这也是我最终选它的决定性理由。
提示:如果你从来没有开发过 VS Code 插件,别被"插件开发"这个词吓到。它的本质就是一个 Node.js 程序,用一个
package.json声明能力,再用activate函数注册命令,入门门槛比想象中低。
2.2 插件的三段式架构
整个插件我拆成三层,各层职责分得很清楚,后面维护起来才不至于一团乱麻。
第一层是命令与生命周期层,也就是extension.ts里注册的那些命令,比如"登录拼题A""打开题目列表""提交当前文件"。这一层只做参数校验和入口分发,不写业务逻辑。
第二层是服务层,包括 HTTP 请求封装、题目数据解析、会话状态管理、编译器调用。这层是纯 TypeScript 逻辑,不依赖 VS Code API,好处是可以单独写单元测试,不用启动整个编辑器实例。
第三层是视图层,也就是 Webview 面板和 TreeView 侧边栏。题面用 Webview 渲染,因为要保留 HTML 格式和公式;题目列表用 TreeView,因为它能天然支持树状分组和图标。
这种分层的直接收益是:后来拼题A改了一次题面DOM结构,我只改了服务层的解析函数,视图层和命令层一行没动,二十分钟就修好了。如果当初图省事把所有逻辑塞在一个文件里,那次改动至少要花半天。
2.3 用本地文件系统做数据缓存
题目信息、登录Cookie、提交记录这些数据,我全部存在 VS Code 的globalStorageUri目录下,而不是塞进globalState。原因是globalState适合存小型的键值对,题面HTML动辄几十KB,提交记录会越积越多,全塞进去会让状态文件变得臃肿,还可能触发大小限制。
缓存策略上我做了两级:内存里用 Map 存最近打开的题目,避免短时间内反复请求;磁盘上按题目ID存 JSON,带时间戳。默认缓存有效期设成24小时,超过就重新拉取,这样既能减少请求次数,又不至于题目更新了你还看到旧题面。
interface CachedProblem { id: string; title: string; html: string; samples: Array<{ input: string; output: string }>; fetchedAt: number; } const CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时这个CACHE_TTL后来被我加进了配置项,因为有些同学刷的是老师临时改过的题,缓存太久反而误事。
2.4 编译器路径为什么不写死
最初版本我图快,直接把g++写死在配置里,结果在 Windows 上翻车了。很多同学的 MinGW-w64 装在C:\mingw64\bin或者C:\Program Files\mingw-w64\...,路径五花八门,硬编码必然失败。
后来改成从配置读取,默认值给g++(走 PATH),同时提供一个"自动探测"按钮,扫描常见安装位置。实测下来这个设计救了不少人——尤其是用 Scoop 或 MSYS2 装环境的同学,他们的编译器路径和典型安装完全不一样。
{ "pintia.compiler.cpp": "g++", "pintia.compiler.c": "gcc", "pintia.compiler.python": "python", "pintia.compiler.java": "javac", "pintia.timeoutMs": 5000 }timeoutMs这个参数也不是拍脑袋定的。在线判题一般给单题1到2秒,本地跑样例用5秒足够覆盖绝大多数情况,同时能防止死循环把你的机器拖垮。
3. 核心功能的实现细节与关键代码
3.1 登录态维持:Cookie 到底怎么存
拼题A的登录流程是标准的表单提交,成功后服务端下发会话Cookie。插件要能免登录访问题目和提交,就必须把这个Cookie持久化下来。
我的做法是在globalStorageUri下建一个session.json,只存必要的Cookie字段,并做基础的混淆处理。这里要说清楚:任何本地存储都不是绝对安全的,所以我在文档里明确写了"请不要在公用电脑上保存登录态",并且加了一个"退出登录"命令,一键清空。
async function saveSession(cookies: string) { const storagePath = context.globalStorageUri.fsPath; await fs.ensureDir(storagePath); const file = path.join(storagePath, 'session.json'); await fs.writeFile(file, JSON.stringify({ cookies, savedAt: Date.now() }), 'utf8'); }请求时把Cookie塞进headers的Cookie字段。这里有个坑:axios默认不自动带上Cookie,必须手动设置,而且要注意withCredentials在 Node 环境下不生效,得靠手动拼headers。
3.2 题面解析:DOM 结构变了怎么办
题面解析是最容易碎的环节。拼题A的题目页面用的是服务端渲染的HTML,我原本用 CSS 选择器精确取值,比如.problem-content .description。结果平台前端改版一次,选择器全失效。
后来我改成三层容错策略:第一层尝试精确选择器;第二层退化到按标题文本定位(找包含"题目描述"字样的节点,取它的同级内容);第三层如果还是拿不到,就把整段正文HTML原样丢给Webview,让用户自己看。
function extractProblemContent(doc: Document): string { // 第一层:精确选择器 let node = doc.querySelector('.problem-description .content'); if (node) return node.innerHTML; // 第二层:按标题文本定位 const headings = Array.from(doc.querySelectorAll('h1,h2,h3,h4')); const target = headings.find(h => /题目描述|问题描述/.test(h.textContent || '')); if (target?.nextElementSibling) { return target.nextElementSibling.innerHTML; } // 第三层:兜底 return doc.body.innerHTML; }这个思路值得所有做网页抓取的同行参考:不要把解析逻辑写成全有或全无,多留几条退路,线上稳定性会好很多。
3.3 样例提取与自动生成测试文件
样例对不上是刷题最头疼的事之一。我做了自动提取,把题面里的"样例输入/样例输出"抓出来,然后在工作区生成对应的.in和.out文件。
命名规则是{题目ID}_sample_{序号}.in。为什么要带题目ID?因为一个工作区里可能同时开着好几道题的文件,不带前缀会互相覆盖。序号则是为了支持多组样例的情况。
function writeSamples(dir: string, problemId: string, samples: Sample[]) { samples.forEach((s, idx) => { fs.writeFileSync(path.join(dir, `${problemId}_sample_${idx}.in`), s.input); fs.writeFileSync(path.join(dir, `${problemId}_sample_${idx}.out`), s.output); }); }提取的时候要注意一个问题:网页里的样例通常包在<pre>标签里,得到的文本会带前后空白和制表符。提交给判题系统时,行尾多余空格可能导致WA,所以我在写入文件前做了一次规范化:统一换行符为\n,去掉每行尾部空白,但保留行首缩进(因为有些题目对格式敏感)。
3.4 本地运行与结果比对
运行逻辑是:把当前文件编译成可执行文件,用样例.in作为标准输入跑一遍,把实际输出和.out逐行比对。
编译产物放在工作区的.pintia/build目录,加进.gitignore模板里。编译命令根据不同语言分派,C++ 用g++ -O2 -std=c++17,Python 直接python,Java 先javac再java。
比对逻辑我没有用简单的字符串全等,而是做了尾部空白容忍和行尾换行容忍,因为不同IDE保存文件时的换行处理不一样。
function compareOutput(actual: string, expected: string, strict: boolean): boolean { const norm = (s: string) => strict ? s.replace(/\r\n/g, '\n') : s.replace(/\r\n/g, '\n').split('\n').map(l => l.trimEnd()).join('\n').trimEnd(); return norm(actual) === norm(expected); }strict模式默认关闭,提供配置开关。我实测发现,绝大多数题目在非严格模式下都能正确判断,只有极少数格式题需要打开严格模式。
注意:本地跑通不代表判题通过。本地样例往往只是题面给出的少数几组,隐藏测试点可能覆盖边界情况。跑通样例只是及格线,不是满分保证。
3.5 提交与结果回显
提交是把代码POST到拼题A的提交接口,然后轮询结果。这里有两个技术点值得说。
第一是提交频率限制。判题平台普遍有提交间隔限制,短时间高频提交会触发限制。所以我在提交命令里加了节流:同一题目30秒内只允许提交一次,界面上给倒计时提示。
第二是结果轮询。提交后返回的是一个提交ID,需要按这个ID去查结果。我用了指数退避的轮询策略,先隔1秒查一次,没结果就2秒、4秒,最多查到15秒。这样既不会空耗请求,又能及时拿到结果。
async function pollResult(submissionId: string) { const delays = [1000, 2000, 4000, 8000]; for (const d of delays) { await sleep(d); const res = await fetchResult(submissionId); if (res.status !== 'PENDING' && res.status !== 'JUDGING') return res; } throw new Error('判题超时,请手动到网页查看结果'); }结果回显用 Webview 面板,把每个测试点的状态、耗时、内存列成表格。这个表格的视觉效果我调了好几版,最后定成:绿色通过、红色失败、黄色超时,一眼就能看出问题出在哪个测试点。
4. 从零搭建这个插件的完整实操流程
4.1 开发环境准备与项目初始化
先决条件:Node.js 18以上、VS Code 1.80以上、一个全局安装的yo和generator-code。
npm install -g yo generator-code yo code生成向导里选 "New Extension (TypeScript)",填好插件名和标识符,它会生成标准目录结构。关键文件是package.json(声明贡献点)和src/extension.ts(入口)。
npm install npm run compile编译通过后按 F5,会弹出一个新的"扩展开发宿主"窗口,里面就能调试你的插件了。这里我第一次踩坑:F5 启动的窗口里,你的插件是加载的,但工作区是你当前打开的目录,如果你想测试题目文件生成功能,得先在宿主窗口里打开一个真实的文件夹。
4.2 声明贡献点与命令注册
package.json里的contributes是插件的"门面",所有命令、配置、菜单都要在这里声明,否则用户根本找不到入口。
{ "contributes": { "commands": [ { "command": "pintia.login", "title": "拼题A: 登录" }, { "command": "pintia.openProblem", "title": "拼题A: 打开题目" }, { "command": "pintia.runSample", "title": "拼题A: 运行样例" }, { "command": "pintia.submit", "title": "拼题A: 提交当前文件" } ], "keybindings": [ { "command": "pintia.runSample", "key": "ctrl+alt+r", "when": "editorTextFocus" } ] } }命令注册在activate函数里,用vscode.commands.registerCommand把命令ID和实际处理函数绑定。这里有个容易忽略的点:注册命令时一定要push到context.subscriptions,否则插件停用时会残留,调试时反复激活可能导致命令重复注册。
4.3 本地编译运行的进程调用实现
调用编译器的核心是child_process.execFile,它比exec更安全,因为不会经过shell解析,参数里的特殊字符不会被误解释。
import { execFile } from 'child_process'; function runProgram(exePath: string, inputPath: string, timeout: number) { return new Promise<string>((resolve, reject) => { const child = execFile(exePath, [], { timeout, maxBuffer: 10 * 1024 * 1024, cwd: path.dirname(exePath) }, (err, stdout, stderr) => { if (err) reject(stderr || err.message); else resolve(stdout); }); const input = fs.readFileSync(inputPath, 'utf8'); child.stdin?.end(input); }); }几个参数我都是斟酌过的:maxBuffer设10MB,因为有些题的输出量很大,默认的1MB会直接截断;timeout用配置项,默认5秒;输入直接stdin.end(input)写入,比 spawn 再手动写流简单。
Windows 上还有个特殊处理:可执行文件要加.exe后缀,且路径里的空格要正确处理。execFile传数组参数能避开空格问题,比手动拼字符串靠谱。
4.4 Webview 题面渲染与消息通信
Webview 是个隔离环境,和扩展主进程之间靠postMessage通信。题面渲染时我会注入一段CSS,保证代码块、公式、表格在深色主题下也能看清。
const panel = vscode.window.createWebviewPanel( 'pintiaProblem', `题目 ${problem.id}`, vscode.ViewColumn.Beside, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [context.extensionUri] } ); panel.webview.html = renderProblemHtml(problem);retainContextWhenHidden这个选项建议打开,否则切走再切回来时,你在题面里做的滚动、折叠状态全丢了,体验很差。代价是内存占用高一些,但对刷题场景完全可以接受。
通信方向有两个:Webview 往扩展发"用户点了提交按钮",扩展往 Webview 发"判题结果来了,请刷新表格"。都要用panel.webview.onDidReceiveMessage和panel.webview.postMessage配对处理。
4.5 打包发布 vsix
开发完成后用vsce打包:
npm install -g @vscode/vsce vsce package会生成一个.vsix文件。安装方式有两种:命令行code --install-extension pintia-helper-0.1.0.vsix,或者在 VS Code 扩展面板右上角菜单里选"从 VSIX 安装"。
打包时经常报两个错:一是README.md不存在,二是repository字段缺失。前者写一个说明文件即可,后者在package.json里补上即可。还有engines.vscode版本别填太高,否则低版本 VS Code 的用户装不上,我设的是^1.80.0,覆盖了绝大多数还在维护的版本。
5. 开发与使用中的踩坑记录
5.1 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 登录后仍然提示未登录 | Cookie 未正确写入请求头 | 检查session.json是否存在,重新登录 |
| 题面显示为空白 | DOM 选择器失效 | 检查插件版本,更新解析规则 |
| 编译报"找不到 g++" | 编译器不在 PATH | 在设置里填绝对路径 |
| 运行样例卡住不动 | 程序死循环 | 检查 timeout 配置,确认输入文件正确 |
| 提交提示频率限制 | 短时间重复提交 | 等待30秒后重试 |
| 结果一直 PENDING | 平台判题队列繁忙 | 稍等手动刷新,或到网页查看 |
| 输出比对总是失败 | 行尾空白差异 | 关闭严格模式,检查换行符 |
| Webview 题面样式错乱 | 深色主题冲突 | 更新插件,已内置主题适配 |
这张表是我自己遇到过的和收集用户反馈整理出来的,实际覆盖了九成以上的问题。建议先对着表自查,绝大多数情况不用找人问。
5.2 几个让我印象深刻的坑
第一个坑:Extension Host 的内存泄漏。我早期版本每次打开题目都新建一个 WebviewPanel,但没在关闭时清理事件监听器。结果连续打开二十几道题后,宿主进程内存飙到1G以上,编辑器开始卡顿。解决方法是把panel.onDidDispose里的监听器全部释放,并且复用同一个面板而不是反复创建。
第二个坑:异步任务没被取消。用户点了"运行样例",程序在跑,这时他又点了一次,两个进程同时跑,输出混在一起。修复的方式是给每个运行任务分配一个唯一ID,新任务启动前先杀掉旧的子进程。这提醒我:任何用户可重复触发的操作,都要考虑重入问题。
第三个坑:跨平台的路径分隔符。我本地是 Mac,写死了/,结果 Windows 用户反馈生成的样例文件路径不对。后来统一改用path.join,这个问题再没出现过。跨平台开发中,永远不要手动拼路径字符串,这是我付出代价换来的经验。
第四个坑:配置文件热更新。用户改了编译器路径,插件却还在用旧值。原因是配置是在激活时读一次就缓存了。后来我监听onDidChangeConfiguration,配置一变就重新读取,体验立刻顺了。
5.3 一些提升体验的小细节
代码片段(Snippet)功能值得单独说。我在插件里内置了一套常用模板,比如C++的快速输入输出、并查集、二分查找,通过CompletionItemProvider注入。刷题时敲pintia-main就能展开完整的主函数框架,敲pintia-dsu展开并查集模板。这个功能用户反馈极好,实际上是投入产出比最高的一块。
另一个细节是状态栏。我在状态栏放了一个小图标,显示当前登录状态和当前题目的ID,点击能快速打开题目面板。不占地方,但能让用户随时知道自己在哪道题上。
还有就是错误提示的措辞。早期编译失败我直接抛原始stderr,用户看一堆英文报错完全懵。后来改成:如果是常见的语法错误,给一句中文提示加原始报错;如果是找不到编译器,直接告诉用户去哪个设置项改。报错信息是产品体验的一部分,这句话我深有体会。
6. 后续可以怎么扩展
这个插件目前只覆盖了刷题主流程,但可扩展的方向不少。
一是代码模板库的社区化。现在模板是我一个人写的,如果做成可配置的远程模板仓库,用户可以自己贡献,覆盖面会广很多。
二是题解笔记联动。做完一道题,把思路记录和代码存在同一个目录,插件自动生成一个 Markdown 笔记骨架。这个功能对复习特别有用,我自己现在就是这么做笔记的。
三是多平台适配。如果后续有类似平台开放接口,可以在服务层做抽象,一套界面适配多个后端,架构上我当初分层就是为这个留的口子。
四是离线题库。把做过的题目和他的提交记录存成本地数据库,支持全文搜索。这个需求在学生复习阶段特别强烈。
我个人在实际维护中的体会是:插件类项目的核心竞争力不在功能有多少,而在主流程是否足够顺滑。用户能忍功能少,忍不了每天都要为同一个卡点浪费时间。所以与其铺功能,不如把"打开题目到看到结果"这条路径打磨到三秒内完成,这才是真正留住人的地方。