作为一名经常在 ACMOJ (我们班级的 OnlineJudge) 上刷题、参与课程和比赛的学生,在编辑器(VS Code)和浏览器之间频繁切换实在繁琐。在浏览器查看题目描述,样例,输出后再比对结果,复制代码到 VS Code,编写调试,再复制回浏览器提交,最后又切回浏览器查看结果... 尽管有分屏,但是我平时用 macOS 的台前调度分屏体验不好。这个过程不仅打断思路,效率也不高。
能不能在 VSCode 里一站式完成这些操作呢?看到班级里有同学也在开发插件,似乎也不是这么难?况且前阵子学习了golang,感觉typescript也不是这么难学了😋怀着这个想法,我踏上了我的第一个 VSCode 插件开发之旅,目标是为 ACMOJ 打造一个便捷的助手。这篇文章记录了从构思到实现,再到踩坑和最终成型的过程。
一、启程:工具与蓝图
VS Code 插件主要使用 TypeScript (或 JavaScript) 编写,运行在 Node.js 环境中。开始之前,必要的工具是:
- Node.js & npm/yarn: 基础运行环境和包管理器。
Yeoman & generator-code: VS Code 官方推荐的脚手架工具,快速生成项目结构。
npm install -g yo generator-code yo code # 选择 TypeScript Extension
- VS Code: 开发和调试插件本身。
生成的项目结构清晰明了:
src/extension.ts
: 插件的入口文件,包含activate
(激活时调用) 和deactivate
(停用时调用) 函数。package.json
: 核心清单文件,定义插件的元数据、贡献点 (Contributions)(如命令、视图、配置)和激活事件 (Activation Events)(决定何时加载插件)。tsconfig.json
: TypeScript 配置文件。
我的初步蓝图是实现以下核心功能:
- 认证: 连接到 ACMOJ API。
- 题目/作业浏览: 在 VS Code 侧边栏查看题目列表。
- 题目详情: 在 Webview 中展示题目描述、样例等。
- 代码提交: 从当前编辑器快速提交代码。
- 结果查看: 在侧边栏或 Webview 中查看提交状态和结果。
二、API 交互与认证
ACMOJ 提供了一套 OpenAPI 规范的 API,这是实现功能的基础。
1. API 客户端:
我选择了 axios
作为 HTTP 请求库,并封装了一个 ApiClient
类来统一处理请求发送、Base URL 配置和错误处理。关键在于设置请求拦截器,自动在 Authorization
Header 中附加 Bearer <token>
。
2. 认证的“小插曲” - OAuth vs PAT:
API 文档同时提到了 OAuth2 (Authorization Code Flow) 和个人访问令牌 (Personal Access Token, PAT) 两种认证方式。
- 初试 OAuth2: 我最初尝试实现 OAuth2 流程。这涉及到引导用户到浏览器授权,然后在本地启动一个临时 HTTP 服务器监听回调 URI 以获取
code
,再用code
和client_secret
去换取access_token
。这个流程对于需要多用户授权的应用是标准的,但对于个人使用的插件来说,实现起来相当复杂,尤其是在 VS Code 插件这种环境中安全地处理client_secret
和本地回调比较棘手。(当然事实上阻止我第一步是我需要有一个client secret
要向管理组索取。在当时我并不认识管理组的人员,虽然开发了这个插件后他们貌似认识我了XD) 转向 PAT: 考虑到目标用户(主要是自己和同学)可以方便地在 ACMOJ 网站生成 PAT,我决定转向更简单的 PAT 认证。这大大简化了流程:
- 创建一个
AuthService
(或TokenManager
)。 - 提供一个
acmoj.setToken
命令,使用vscode.window.showInputBox({ password: true })
提示用户输入 PAT。 - 使用 VS Code 的
SecretStorage
API (context.secrets.store
/context.secrets.get
) 安全地存储和读取 PAT。 - 提供
acmoj.clearToken
命令清除存储的 PAT。 - 在
ApiClient
的请求拦截器中,直接从AuthService
获取存储的 PAT 添加到请求头。 - 在响应拦截器中,如果遇到 401 Unauthorized 错误,则调用
AuthService
的方法清除无效 token 并提示用户重新设置。
- 创建一个
三、构建用户界面使用 TreeView 与 Webview
要在 VS Code 中展示信息和提供交互,主要使用了 TreeView 和 Webview。
1. TreeView (侧边栏):
使用 vscode.TreeDataProvider
接口为活动栏(Activity Bar)创建了两个视图:
Problemsets (比赛/作业):
- 最初简单地列出所有题目,但很快发现信息量太大。
- 改进: 改为显示用户加入的 Problemsets。
- 再改进: 根据 Problemset 的开始/结束时间,将其分类为 "Ongoing", "Upcoming", "Passed" 三个顶级节点。这需要获取所有 Problemsets,然后在
getChildren
方法中根据当前时间和分类节点进行过滤和排序。使用了CategoryTreeItem
和ProblemsetTreeItem
两种自定义TreeItem
。 - 每个 Problemset 节点设置为可展开 (
vscode.TreeItemCollapsibleState.Collapsed
),点击后加载其包含的题目列表 (ProblemBriefTreeItem
)。
Submissions (提交记录):
- 显示用户的提交列表,包含 ID、题目、状态、语言、时间等。
- 为不同的提交状态(AC, WA, TLE, RE...)设置了不同的图标 (
ThemeIcon
),使其更直观。
实现 TreeView 的关键在于 getChildren
(获取子节点) 和 getTreeItem
(定义节点外观和行为) 两个方法。通过 EventEmitter
和 onDidChangeTreeData
事件可以通知 VS Code 刷新视图。
2. Webview (详情展示):
当用户点击 TreeView 中的题目或提交记录时,使用 vscode.window.createWebviewPanel
创建一个 Webview 来展示详细信息。为什么要用 webview
? 因为要渲染 tex 公式,并且JSON请求回来的是Markdown结果。
- 内容渲染: Webview 本质上是一个嵌入的浏览器环境,其内容是 HTML。我使用
markdown-it
库来将从 API 获取的 Markdown 格式的题目描述、输入输出格式等转换为 HTML。 挑战:数学公式渲染: OJ 题目描述经常包含 LaTeX 公式。
- 尝试一 (失败): 最初尝试在 Webview 的 HTML 中包含 KaTeX 的 JS 库和 auto-render 脚本,让客户端渲染。但这导致了公式被渲染两次的奇怪问题(一次是原始文本,一次是 KaTeX 渲染结果)。
- 尝试二 (成功): 意识到问题在于渲染流程重复。最终方案是使用
markdown-it
的 KaTeX 插件 (@vscode/markdown-it-katex
(这玩意使用npm装的时候是另外一个开发者的,然而已经年久失修并且存在跨域危险,然而好消息是vscode官方注意到了这个项目并且做了后续的修补,所以我用这个玩意))。在扩展端(Node.js 环境)使用md.render()
时,这个插件直接将 Markdown 中的 LaTeX ($...$
,$$...$$
) 转换为最终的 KaTeX HTML 结构。这样,发送到 Webview 的 HTML 就已经是预渲染好的,Webview 端只需要包含 KaTeX 的 CSS (katex.min.css
) 来正确显示样式即可,不再需要 KaTeX JS 和 auto-render 脚本。
3. 命令与状态栏:
- 使用
vscode.commands.registerCommand
注册各种用户操作(设置 Token、刷新视图、提交代码、按 ID 查看题目等)。 - 使用
vscode.window.createStatusBarItem
在状态栏左侧显示当前登录状态和用户名,点击时可以触发相应命令(如显示用户信息或设置 Token)。
四、最“坑”的打包与发布
开发和调试 (F5
) 时一切顺利,但当我使用 vsce package
打包成 VSIX 文件,安装到另一台电脑上时,遇到了经典问题:Command 'acmoj.setToken' not found
或 Cannot find module 'axios'
。
调试过程:
- 检查开发者工具: 在测试电脑上打开 VS Code 开发者工具 (
Developer: Toggle Developer Tools
) 的 Console。发现激活扩展时直接报错Cannot find module 'axios'
。 - 检查 VSIX 内容: 使用
vsce ls
命令(或将.vsix
重命名为.zip
并解压)查看包内容。发现node_modules
文件夹根本没有被打包进去!
根本原因:
我错误地将运行时必需的库(如 axios
, markdown-it
, katex
, @vscode/markdown-it-katex
)放在了 package.json
的 devDependencies
下,而不是 dependencies
下。
dependencies
: 扩展运行时必需的库,会被vsce package
打包。devDependencies
: 开发时使用的库(编译器、类型定义、linter、打包工具等),不会被打包。
解决方案:
仔细检查 package.json
,将所有运行时依赖项(axios
等)移到 dependencies
部分,而开发工具(typescript
, @types/*
, eslint
, @vscode/vsce
等)保留在 devDependencies
。
{
"dependencies": {
"@vscode/markdown-it-katex": "...",
"axios": "...",
"katex": "...",
"markdown-it": "..."
},
"devDependencies": {
"@types/vscode": "...",
"@types/node": "...",
"@types/markdown-it": "...",
"@vscode/vsce": "...", // The packaging tool itself is a dev dependency
"typescript": "...",
"eslint": "..."
}
}
关键步骤: 修改 package.json
后,务必执行 “净空 & 重装”,一开始继续报错就是没有清空 node_modules 以及 package-lock.json:
这次生成的 VSIX 文件终于包含了正确的 node_modules
,安装后命令可以正常找到,扩展也成功激活了。
五、TypeScript 小插曲
作为 TypeScript 项目,也遇到了一些典型的类型问题:
- 找不到模块/类型:
Cannot find module 'vscode'
或其他@types
包,通常通过npm install --save-dev @types/vscode @types/node ...
解决。 - 隐式
any
: 开启strict
模式后,需要为回调函数参数(如progress
inwithProgress
,text
invalidateInput
)显式添加类型。 - API 签名不匹配: 调用
vscode.window.showQuickPick
时,如果提供选项对象,则需要传入QuickPickItem[]
而不是string[]
,需要进行映射。
六、告一段落了吗?
最终诞生的 ACMOJ 助手虽然还有很多可以改进的地方(例如:支持更多 API 功能、更完善的错误处理、UI 优化、测试覆盖),但它已经能满足我最初设定的核心需求,显著提升了在 VS Code 中处理 ACMOJ 相关任务的效率。
虽然 acmoj-helper 已经能够跑起来,甚至在日常使用中帮了我不少忙,但在开发过程中,我逐渐感受到了一些“成长的烦恼”。随着功能的迭代(哪怕只是微小的调整),我发现代码开始变得有些混乱:
- 职责不清:
commands.ts
文件不仅负责注册命令,还包含了大量像submitCurrentFile
这样的复杂业务逻辑实现。这使得这个文件异常臃肿,修改起来牵一发而动全身。 - 高度耦合: 修改一个模块(比如处理 API 缓存的
cache.ts
)可能会意外地影响到视图(submissionProvider.ts
)或者命令处理。我之前提到修改submissionProvider
时几乎重写了它,就是一个典型的例子——视图层和数据获取、业务逻辑耦合太紧了。 - 注册混乱: 命令的注册散落在
extension.ts
和commands.ts
中,不够集中和清晰。 - 扩展困难: 如果我想添加新的功能,比如“比赛(Contest)”视图,或者更复杂的题目筛选逻辑,在现有结构下会非常痛苦,需要小心翼翼地在各个文件中穿梭,确保不会破坏现有功能。
- 测试障碍: 混合了 UI 逻辑、API 调用和业务处理的代码,非常难以进行单元测试。
这些问题让我意识到,当前的架构虽然能工作,但它并不“优雅”,也缺乏长期的生命力。为了让这个项目能够健康地发展下去,也为了提升我自己的代码设计能力,我决定进行一次彻底的重构。
重构目标:解耦、分层、职责单一
我现在正在进行修改的新架构大致分为以下几个层次:
- VS Code 集成层 (
extension.ts
,src/commands/index.ts
) 服务层 (
src/services/
)- 职责: 封装核心的业务逻辑和与外部资源的交互(如 API、缓存)。每个服务对应一个明确的领域。
命令处理层 (
src/commands/
)- 职责: 命令处理器接收来自 VS Code 的调用,然后使用服务层来完成具体的任务。它们是 VS Code 命令和业务逻辑之间的桥梁。复杂的逻辑(如
submitCurrentFile
)现在被清晰地封装在对应的命令处理器中。
- 职责: 命令处理器接收来自 VS Code 的调用,然后使用服务层来完成具体的任务。它们是 VS Code 命令和业务逻辑之间的桥梁。复杂的逻辑(如
UI 层 (
src/views/
,src/webviews/
)- 职责: 负责数据的展示和 UI 交互。
views/
: 包含 TreeDataProvider(如ProblemsetProvider
,SubmissionProvider
)。它们从服务层获取数据,并将其格式化为 VS Code TreeView 需要的结构。webviews/
: 包含 Webview Panel 的逻辑。重构后,我们为题目详情和提交详情创建了专门的类(ProblemDetailPanel
,SubmissionDetailPanel
),封装了各自的 HTML 生成、消息处理和生命周期管理。它们同样通过服务层获取数据,并且 Webview 内的操作(如“复制代码”)现在通常会通过postMessage
发送消息给 VS Code,由对应的命令处理器来响应。
核心/数据层 (
src/core/
,src/types.ts
)- 职责: 提供最基础的组件和定义。
- 重构过程中一个非常典型的例子就是
core/apiClient.ts
: 一个更纯粹的 HTTP 客户端,只负责发送请求、处理认证头、重试逻辑和基本的错误解释。它不再包含具体的业务端点逻辑。之前他妈的 getUserProfile, getSubmission, ...之类的全在这里面。
虽然重构的过程实在是让我吃了一坨狗屎,甚至会暂时引入新的 Bug,但它为 ACMOJ 助手的长期发展打下了坚实的基础。现在,我可以更有信心地去实现那些在 1.0 版本末尾构想的、更完善的功能了。
如果你也对 VSCode 插件开发感兴趣,或者想为自己常用的工具或平台构建集成,不要犹豫,动手去做吧!从 yo code
开始,遇到问题,解决问题,这个过程本身就是最好的学习。
项目仓库: [https://github.com/TheUnknownThing/vscode-acmoj]
感谢阅读!希望我的经历能对你有所帮助。