项目:vitest-playground | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
vitest-playground(Vitest 可视化学习平台) 是一个基于 Vite + React + 真实 Vitest 的交互式前端单元测试学习网站。它的核心定位可以概括为一句话:让学习者在浏览器里写测试、点一下按钮,就由本机真实安装的 Vitest 进程执行这段测试,并即时返回结构化的运行结果。
与传统的"看文档学测试"相比,本项目的差异化能力是"浏览器内运行真实 Vitest"。值得注意的是,这里的"浏览器内运行"并非指代码真正跑在浏览器沙箱(如 WebContainer / StackBlitz CDN)里,而是指用户体验上在浏览器内完成:浏览器把用户代码通过同源 HTTP 接口 POST /api/run-vitest 提交给 Vite 开发/预览服务器,由 vitestServerRunner.ts 在本机沙箱目录中真正 spawn 一个 Vitest 子进程执行,再把结果回传渲染。这一架构演进在 README.md 中有明确记载:早期版本曾使用 @webcontainer/api 在浏览器内启动 Node 运行测试,现已改为本地服务端中间件方案,离线即可运行(前提是 node_modules 已安装)。
目标用户包括三类:
grader + hiddenGrader)、知识小测(quiz)、成就徽章等机制进行自我检验或开展教学。与同类学习资源相比,本项目的边界也很清晰:它不是 Vitest 的参考手册(API 细节仍需查官方文档,课程代码只覆盖教学所需的常用子集,编辑器内的类型提示来自手写的精简声明文件 src/pages/vitest-learn/vitestGlobalsDts.ts,其注释明确说明"类型只用于编辑器诊断……真正的运行结果以服务端 vitest 为准");它也不是通用的在线 Playground(不能任起项目、不能装依赖,只能运行与课程数据绑定的课时代码)。它刻意做窄、做深:把"写测试—跑测试—看结果—记进度"这一个循环打磨流畅。理解这一定位,后续诸多设计决策(无鉴权、无数据库、单接口、localStorage 持久化)都会变得顺理成章。
项目的自述(AGENTS.md)给出了站点域名 https://vitest-playground.zhangzhengyang.com,生产形态为 NAS Docker 容器经 frpc 隧道到 VPS nginx 反代(详见 docs/deploy/runbook.md)。同时 AGENTS.md 明确警示:运行器会在本机执行用户代码,仅适用于本地/受信学习环境,不要把它暴露到不可信网络。
以下功能均可在源码中定位到对应实现文件:
| 功能 | 说明 | 关键实现位置 |
|---|---|---|
| 交互式课程(16 章 56 课时) | 课程数据全部集中在单一数据文件,新增/修改课时只需编辑该文件 | src/pages/vitest-learn/data.ts |
| 在线代码编辑器 | Monaco Editor 本地打包,行号/高亮/补全/暗色主题,Ctrl/Cmd+Enter 运行 | src/pages/vitest-learn/CodeEditor.tsx、monacoSetup.ts |
| 真实 Vitest 运行 | 代码 POST 到 /api/run-vitest,服务端沙箱内 spawn 真实 vitest 进程 | vitestServerRunner.ts、vite.config.ts |
| TDD 自动批改 | grader(可见测试,只读展示)+ hiddenGrader(隐藏防作弊校验) | data.ts、vitestServerRunner.ts 的 buildLessonFiles |
| 失败行内定位 | 失败用例在编辑器对应行显示红色波浪线(Monaco markers) | CodeEditor.tsx 的 markers 副作用、vitestServerRunner.ts 的 parseVitestJson |
| 逐用例耗时 | 结果列表展示每个用例的运行毫秒数 | vitestServerRunner.ts、CodeEditor.tsx 结果区 |
| 覆盖率可视化 | 默认课时开启 coverage,结果面板渲染整体/分文件行/分支/函数覆盖率 | vitestServerRunner.ts 的 readCoverage、CodeEditor.tsx |
| 性能基准(bench) | benchmark: true 的课时以 vitest bench 运行并解析表格报告 | data.ts 第 9.7 课、vitestServerRunner.ts 的 parseBenchTable |
| 知识小测 | 课时内穿插选择题卡片,提交即判分 | src/pages/vitest-learn/Quiz.tsx |
| 学习进度持久化 | 代码/通过状态/最后运行时间存 localStorage,刷新不丢 | src/pages/vitest-learn/progress.ts |
| 成就徽章 | 5 枚徽章(初次通关/小有成效/渐入佳境/整章攻克/测试大师)纯函数计算 | progress.ts 的 computeBadges、progressPage.tsx |
| 课程搜索与标签筛选 | 首页关键词搜索 + 主题标签(React/异步/Mock/快照/覆盖率/TDD 等)筛选 | src/pages/vitest-learn/index.tsx |
| 自动重跑 | 课时页开关,停止输入 1.2 秒后自动重跑(watch 体验的轻量替代) | src/pages/vitest-learn/chapter.tsx 的 debounce effect |
| 多文件预览 | extraFiles(如 vi.mock 依赖的真实模块)以只读 tab 展示 | chapter.tsx 的 Tabs 区、data.ts 的 extraFiles |
| 参考答案/重置/导出 | 查看答案(查看时以答案代码运行)、重置初始代码、导出为文件下载 | chapter.tsx 的 handleExport/handleReset 等 |
| 明暗主题切换 | 顶部导航一键切换,Monaco 与 antd 同步,localStorage 记忆 | src/theme.tsx、CodeEditor.tsx |
| 全局进度总览 | /progress 页展示整体/分章进度、徽章、重置与导出进度 | src/pages/vitest-learn/progressPage.tsx |
| 逐路由 SEO | 零依赖动态设置 document.title 与 meta description;index.html 含 JSON-LD | src/usePageMeta.ts、index.html |
| 课程内容质检脚本 | 逐课时实测 solution 必须通过、code 允许教学性失败,语法门禁 | scripts/check-lessons.ts |
| 自身测试体系 | vitest + happy-dom 自测运行器安全、进度模型、课程数据完整性 | tests/ 目录(见 06 篇) |
上表的各项能力最终都收敛到一个数据结构(Lesson)的不同字段组合上,形成四种学习者可感知的课时形态。理解它们是接手本项目的第一步:
普通模式是最经典的" playground "体验:编辑器里初始展示一段示例测试(Lesson.code,如第一章 1.1 的加法函数测试),用户照着注释提示补全更多用例,点"运行测试"看到绿色通过或红色失败。此时用户编辑的就是测试文件本身,失败时编辑器还会在出错行画上红色波浪线,形成"断言—失败—修正"的完整反馈环。
TDD 模式把课堂反转:课程先给出一份只读的测试文件(Lesson.grader,页面顶部渲染为只读编辑器,附"只读"标签与"下面是已给定的测试用例(只读),请在编辑器中实现让它们全部通过"的引导语),用户的编辑器里则是函数骨架(如 fizzBuzz 的 TODO 空实现)。用户写的是被测实现,跑的是课程给定(以及用户看不见的隐藏)测试——这正是第十四章"TDD 实战演练"、第十六章"TDD 进阶实战"的自动批改机制。
jsdom 模式(第十五章)叠加在 TDD 之上:用户实现的是 React 组件(Greeting、Counter 等),课程给定的测试使用 @testing-library/react 的 render/screen/fireEvent 断言 DOM。对学习者而言唯一可感知的差异是课时徽章从"Node 环境"变为"React + jsdom",以及编辑器里可以放心书写 JSX 语法。
基准模式(第 9.7 课)则让用户编写 bench() 而非 test():运行后结果面板不再是"通过/失败",而是每个基准的名字、平均耗时(毫秒)与每秒操作数——用它比较"循环累加"与"高斯公式求和"两种实现的性能差异。
四种形态加上知识小测(quiz)与学习提示(tips)的穿插,构成了一节课"讲概念—测理解—动手练—即时判"的完整节奏。
| 类别 | 技术 | 版本 | 选型理由(结合源码注释与架构事实) |
|---|---|---|---|
| 构建/开发服务器 | Vite | ^6.0.0 | 自带 dev/preview 服务器,可用插件注入自定义中间件(vitestRunnerMiddleware),这是"服务端跑真实 Vitest"的挂载点;与 Vitest 同源生态 |
| 前端框架 | React | ^19.2.5 | 组件化表达"课时页工作台"这类重交互 UI;@testing-library/react 生态成熟,且第十五章课程本身教 React 组件测试 |
| 路由 | react-router-dom | ^7.1.0 | createBrowserRouter 声明式路由,支持章节/课时两级动态参数(src/app.tsx) |
| UI 组件库 | antd + @ant-design/icons | ^6.4.3 | 提供表单/进度条/Tabs/消息提示等现成组件;AGENTS.md 记载项目已从 Ant Design Pro(Umi Max)模板剥离,仅保留组件库,避免框架层负担 |
| 代码编辑器 | @monaco-editor/react + monaco-editor | ^4.7.0 / ^0.52.2 | VS Code 同源编辑器:行号、语法高亮、补全、markers 错误波浪线;本地打包不依赖 CDN,配合 manualChunks 控制体积 |
| 测试框架(产品能力 + 自测) | vitest + @vitest/ui | 4.1.8(精确锁定) | 既是课程教的"被学习对象",也是运行器实际 spawn 的执行引擎;vitest.mjs 直接从 node_modules/vitest/ 取用,保证课程行为与本地一致 |
| 组件测试/环境 | @testing-library/react、@testing-library/jest-dom、happy-dom | ^16.3.2 / ^6.9.1 / ^20.10.1 | jsdom 课时(第十五章)在沙箱内以 // @vitest-environment happy-dom pragma 切换环境;jest-dom 匹配器经沙箱 setup.ts 注入 |
| 覆盖率 | @vitest/coverage-v8 | 4.1.8 | 运行器以 coverage.reporter: ['json-summary'] 输出 coverage-summary.json 供结果回传解析 |
| 语言/类型 | TypeScript | ^6.0.3 | 全仓 TS(strict),pnpm tsc 作为 CI 门禁 |
| 包管理器 | pnpm | 10.34.5(packageManager 字段锁定) | AGENTS.md 铁律 3:只用 pnpm,禁止 package-lock.json;pnpm-workspace.yaml 仅承载 allowBuilds: esbuild 放行配置 |
| 运行时 | Node.js | >=22.0.0(engines) | 运行器依赖 node:child_process、detached 进程组、process.kill(-pid) 等 Node 能力 |
两点补充说明:其一,package.json 中 vitest/coverage-v8/ui 三者钉在 4.1.8 精确版本,避免了运行器行为随 minor 版本漂移(parseBenchTable 的注释就明确针对 "Vitest 4.1.8 的 bench 内置 reporter 只有 default/verbose" 这一事实);其二,依赖里没有任何后端框架(Express/Koa 等),HTTP 层完全复用 Vite 服务器中间件,这是"轻量 playground"定位的直接体现。
vitest-playground/
├── index.html # SPA 入口;含 SEO/OG/JSON-LD 结构化数据与 <noscript> 课程大纲
├── package.json # 脚本、依赖清单(pnpm,Node>=22)
├── pnpm-workspace.yaml # 仅含 allowBuilds(esbuild) 放行,并非多包工作区
├── vite.config.ts # ★ react 插件 + vitestRunnerMiddleware(/api/run-vitest 挂载)
│ # + manualChunks(monaco/antd/react-vendor) + COEP/COOP 头
├── vitest.config.ts # 本项目自测配置(happy-dom、globals、setupFiles、include)
├── vitestServerRunner.ts # ★★ 服务端 Vitest 运行器(产品核心,位于项目根)
├── tsconfig.json # strict TS;paths 别名 @/ @root/;types: vitest/globals
├── .vitest-runs/ # 运行临时目录根(每次运行 run-<rand> 子目录,跑完即删,gitignore)
├── .vitest-sandbox/ # 历史迭代遗留的沙箱样例目录(已 gitignore,非运行时依赖)
├── scripts/
│ └── check-lessons.ts # 课时代码质检:solution 必须通过、code 允许教学性失败、语法门禁
├── src/
│ ├── main.tsx # React 入口:StrictMode + antd ConfigProvider(zh_CN)
│ ├── app.tsx # 路由表:/ 、/vitest-learn/:chapterKey(/:lessonKey)、/progress、*
│ ├── theme.tsx # ThemeProvider:明暗主题 + localStorage('vitest-playground-theme')
│ ├── usePageMeta.ts # 逐路由动态 title/description(零依赖)
│ ├── index.css # 全局样式 + data-theme 明暗变量
│ ├── components/
│ │ ├── AppLayout.tsx # 通用布局:顶部导航 + 全局进度 + 内容容器
│ │ └── appLayout.module.css
│ └── pages/vitest-learn/
│ ├── index.tsx # 首页(课程中心):Hero + 搜索/标签筛选 + 章节卡片网格
│ ├── chapter.tsx # ★ 课时工作台 LessonPage:useRealVitest 在此驱动运行
│ ├── progressPage.tsx # /progress 页:整体/分章进度 + 徽章 + 重置/导出
│ ├── data.ts # ★★ 课程数据唯一来源:16 章 56 课时(4900 行)
│ ├── types.ts # Lesson/Chapter/RunResult/TestResult/Quiz/Coverage* 类型
│ ├── runner.ts # 前端运行客户端:runVitest() + useRealVitest() Hook
│ ├── CodeEditor.tsx # Monaco 编辑器 + 工具栏 + 结果面板(覆盖率/逐用例/markers)
│ ├── monacoSetup.ts # Monaco 裁剪(仅 TS/JS)+ vitest 全局 d.ts 注入 + worker 路由
│ ├── vitestGlobalsDts.ts # 精简的 Vitest/RTL 全局类型声明(供 Monaco addExtraLib)
│ ├── Quiz.tsx # 知识小测卡片
│ ├── progress.ts # 进度持久化 + computeBadges 徽章模型
│ ├── chapter.module.css / welcome.module.css
│ └── (无 basics/、mock/ 等子目录——README 中的章节链接为历史遗留写法)
├── tests/ # 本项目自测
│ ├── setupTests.ts # localStorage/Worker/MessageChannel/matchMedia 等 polyfill/mock
│ ├── lessons.test.ts # 课程数据完整性(key 唯一、课时非空等)
│ ├── progress.test.ts # 进度持久化 + computeBadges 用例
│ ├── runner-security.test.ts # ★ 运行器安全/集成测试(363 行,真跑 vitest 子进程)
│ └── __mocks__/ # mermaid.ts、raw.ts —— 源码中无对应引用,疑为模板遗留
├── docs/
│ ├── deploy/runbook.md # 生产部署 runbook(nginx 限流/env 泄露面检查/故障处理)
│ └── superpowers/plans/ # 历史实施方案(运行器加固、容器化隔离等)
├── .github/workflows/ci.yml # CI:quality(tsc+test+build) 与 lessons(check:lessons) 双 job
├── gui-test-screenshots/ # GUI 黑盒测试的截图证据(t1_home、t3_run_pass、badge_earned 等)
└── public/ # favicon、og-cover、robots.txt、sitemap.xml、llms.txt 等 SEO 资源两个带 ★ 的文件是理解本项目的钥匙:vitestServerRunner.ts(服务端运行器,兼任 HTTP 处理器与沙箱执行器)与 src/pages/vitest-learn/data.ts(课程数据的唯一事实来源,服务端与前端共用同一份 chapters 数据——这也是"防篡改装配"的基础)。
环境要求:Node.js ≥ 22、pnpm(可用 corepack 或 npm i -g pnpm,仓库 packageManager 字段锁定 10.34.5)。
cd vitest-playground
pnpm install # 安装依赖(vitest 必须就位,否则运行器无法 spawn)
pnpm dev # 启动开发服务器 http://localhost:8000(已挂载 /api/run-vitest)打开浏览器访问 http://localhost:8000,进入任一课时(如 /vitest-learn/basics/first-test),点击"运行测试"或按 Ctrl/Cmd+Enter,即可看到真实 Vitest 的结构化运行结果。首次运行前请确认 pnpm install 已完成——运行器直接使用 node_modules/vitest/vitest.mjs,离线可跑,无需外网。
站在维护者角度描述一次"首次点击运行"背后发生的事,有助于建立直觉:浏览器把编辑器里的代码 POST 给本进程内的 Vite 中间件;中间件在校验请求、查到课时数据后,于 .vitest-runs/ 下创建一次性目录,写入 vitest.config.js、setup.ts 与课时代码文件;随后以项目根的 node_modules 为依赖源 spawn 一个 vitest run 子进程(默认课时还会带 --coverage);进程退出后运行器读取其写出的 result.json 与覆盖率摘要,组装成 JSON 响应返回,最后删掉整个临时目录。整个链路通常在数百毫秒到数秒之间完成(取决于机器性能与是否首次预热文件系统缓存),期间按钮处于 loading 态、结果面板显示"准备运行环境..."。若第一次点击就报"启动 Vitest 失败",几乎总是依赖未安装。
常用脚本一览:
pnpm build # 生产构建到 dist/(manualChunks 拆分 monaco/antd/react)
pnpm preview # 预览构建产物 :8000,同样挂载 /api/run-vitest(可验证生产链路)
pnpm tsc # 仅类型检查(CI 门禁之一)
pnpm test # 跑本项目自身测试(vitest run,happy-dom 环境)
pnpm test:watch # watch 模式
pnpm test:ui # Vitest UI 面板
pnpm test:coverage # 覆盖率报告(v8 provider)
pnpm check:lessons # 逐课时质检课程代码(node --experimental-strip-types)维护者的最常见改动路径:扩展课程内容只需编辑 src/pages/vitest-learn/data.ts,在对应 Chapter.lessons 中追加一个 Lesson 对象即可,无需改动任何组件;改完后运行 pnpm check:lessons 验证 solution 可通过、code 无语法损坏。与运行器相关的改动则务必先读 AGENTS.md 的"铁律"一节——不得削弱超时/kill 兜底、不得放宽白名单与并发限制。
一个重要约束需要在此强调(详见 06 篇):纯静态托管(GitHub Pages、Vercel 静态等)无法提供测试运行能力,因为 /api/run-vitest 依赖 Vite dev/preview 服务器的自定义中间件。部署必须使用承载了该中间件的 Node 服务(本项目生产形态为 NAS Docker 容器 + nginx 反代,见 docs/deploy/runbook.md)。
本套文档共 7 篇,建议按以下顺序阅读:
vitestServerRunner.ts 的执行链与安全加固;/api/run-vitest 的请求/响应契约与错误处理约定;Lesson/RunResult/进度/徽章等数据结构与生命周期;| 术语 | 含义与出处 |
|---|---|
| 课时 / 章节 | Lesson / Chapter(src/pages/vitest-learn/data.ts),路由与进度的基本寻址单位 |
| 装配 | 服务端把用户代码与课时数据合成为沙箱文件集的过程(vitestServerRunner.ts 的 buildLessonFiles) |
| grader / hiddenGrader | 可见批改测试 / 隐藏批改测试(data.ts 的 Lesson 字段),前者只读展示,后者不回传前端 |
| extraFiles | 随课时写入沙箱的附加源文件(如 api.ts),供 vi.mock 替换或被测引用 |
| pragma | 写在测试文件首行的环境指令注释 // @vitest-environment happy-dom,让单个文件切换执行环境 |
| 沙箱 | 每次运行临时创建的目录 .vitest-runs/run-<rand>/,内含配置与课时文件,跑完即删 |
| 通关 | 某课时运行结果 success === true 后写入的进度状态(progress.ts 的 status: 'passed') |
| marker | 失败用例在编辑器内的行级标记(Monaco setModelMarkers),数据来自 RunResult.markers |
| 运行器 | 本文档对 vitestServerRunner.ts 整体(HTTP 处理 + 装配 + 执行 + 解析)的统称 |
| 中间件 | vite.config.ts 中 vitestRunnerMiddleware() 插件,把运行器挂到 dev/preview 服务器的 /api/run-vitest |
此外有两处源码命名需要提前点破,避免接手时困惑:其一,前端请求客户端 src/pages/vitest-learn/runner.ts 与服务端运行器 vitestServerRunner.ts 名字相近,前者是浏览器侧的 fetch 封装(runVitest 函数 + useRealVitest Hook),后者才是真正执行测试的 Node 侧模块;其二,仓库根的 .vitest-sandbox/ 是历史迭代遗留的样例沙箱目录(已 gitignore,非运行时依赖,运行时目录是 .vitest-runs/),不要与运行器混淆。
以下是本次源码分析确认的现状事实与待改进项,接手维护时先过一遍,可以少踩很多坑:
文档漂移:README.md 的"学习章节"小节把每章链接指向 ./src/pages/vitest-learn/basics 等子目录,但这些目录并不存在——课程内容的真实存放处是单一文件 data.ts。该链接写法是早期按目录组织课程时的遗留,接手后应修正 README 或接受"data.ts 单文件"这一现实二选一。
遗留文件:tests/__mocks__/mermaid.ts 与 tests/__mocks__/raw.ts 在 src/ 中找不到任何对应引用(无 mermaid 导入、无 ?raw 导入),疑为 Ant Design Pro 模板时代的遗留 mock,当前为死代码;docs/superpowers/plans/ 是历史实施方案存档(运行器加固已落地,容器化隔离仅有方案);.vitest-sandbox/ 是早期共享目录方案的遗留物,其内的 vitest.config.js 形态(coverage 写在顶层)与现行 writeRunConfig(coverage 写在 test 内)已不一致,仅作历史参考。
未实现的规划:docs/superpowers/plans/2026-09-28-container-isolation.md 完整设计了 docker 一次性容器执行后端(VITEST_RUNNER_BACKEND=local|docker),但源码中无任何实现痕迹;"参考答案运行不标记 passed"的防刷机制在加固方案中被列为 backlog,同样未实现——当前查看参考答案并运行,会正常记通关。
产品侧已知取舍:runner.ts 的 useRealVitest 返回恒为 true 的 isReady(WebContainer 时代异步就绪检测的兼容垫片);CodeEditor 结果面板的占位文案"首次运行需联网安装依赖"与实际行为(本地运行、无需联网)不符,属文案漂移;AppLayout 顶栏进度在组件挂载时计算一次,跨页标记通过后需路由跳转才会刷新。
这些事项均不影响核心功能的正确性,但在动第一行代码之前知晓它们,能避免"以为是 bug"或"重复造已有方案"的弯路。