项目:vitest-playground | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本篇盘点项目的性能现状:哪些性能是"设计出来的"(刻意的取舍),哪些是"顺带成立的"(规模小所以没出问题),哪些是"明确的债"(已识别的优化点)。全项目没有 profiler 数据(⚠️ 待确认:仓库内无基准测试脚本针对自身性能),本篇的量化推断均标注依据。服务端执行链路的架构取舍(每请求冷启动 vs 常驻进程池)已在《01-系统架构》§3.4 论证过,本篇在此基础上给出成本分解与优化路线。
用户从点击"运行测试"到看到结果的总耗时,由四段构成(量级基于链路结构推断,具体数值随机器浮动 ⚠️):
| 阶段 | 内容 | 量级 | 主导因素 |
|---|---|---|---|
| ① 网络+校验 | fetch POST、readBody、字段校验、findLesson、信号量 | 毫秒级(排队时另计) | 请求体 5MB 上限内的传输 |
| ② 沙箱装配 | mkdtemp、写 vitest.config.js/setup.ts/课时文件 | 毫秒级(1-6 个小文件) | 磁盘 I/O |
| ③ vitest 执行 | node 启动 + vitest 初始化 + 跑用例 + 写 result.json/coverage | 数百毫秒到数秒 | Node 冷启动、模块解析、用例本身 |
| ④ 解析回传 | 读 result.json、parseVitestJson、删目录、响应 | 毫秒级 | 文件 I/O |
| (超时上限) | 任何阶段(主要是③) | 硬顶 60s | RUN_TIMEOUT |
设计上的关键取舍在③:每请求一个全新 vitest 子进程。冷启动成本的构成是——node 二进制启动(数十毫秒)、vitest.mjs 加载其模块图(数百毫秒)、vite-node 转换课时代码、可选的覆盖率插桩(v8 provider,非 bench 课时默认开启)。换来的是零共享状态、零竞态、零残留(《01-系统架构》§3.4 的"可预测性换吞吐")。教学场景(单人或小班、每次跑几条用例)下这一取舍完全合理;但有一个值得注意的细节:产品沙箱没有设置 cacheDir——writeRunConfig 写出的配置不含 cacheDir 字段,而对比脚本 scripts/check-lessons.ts 明确写了 cacheDir: '.vite-cache' 并注释"避免经符号链接把 vite 缓存写进仓库 node_modules"。按 Vite 的默认行为,cacheDir 相对 root 解析(即落到 runDir/node_modules/.vite,随沙箱目录删除),这意味着每次运行都是无缓存的冷转换,且不会污染仓库——正确性上没问题,性能上是纯冷启动(⚠️ 该行为按 vite 默认值推导,未实测确认)。若未来想预热,正确做法是把 depcache 放到 .vitest-runs/.shared-cache 这类独立目录并验证并发写安全,而不是改回共享沙箱。
createRunLimiter(2, 8)(vitestServerRunner.ts)定义了系统的容量模型:最多 2 个运行并行,最多 8 个排队,第 11 个并发请求立即 429。三个性能相关的推导:
排队延迟预算。最坏情况下一个请求的等待时间 = 自身前面 8 个排队者全部以 60s 超时跑满 + 自己再跑 60s,理论上限约 540s;nginx 的 proxy_read_timeout 600s 正是按"2 并发消化 8 深排队最坏约 300s(4 轮等待 + 自身)"推算并留缓冲的(《03-接口设计》§8)。也就是说应用层的容量参数与代理超时是耦合的,调大 MAX_QUEUE 或 RUN_TIMEOUT 必须同步重算 600s 这个数,否则公网会出现 504。
所有权转移的吞吐意义。release() 把槽位直接移交队头等待者(active 不减),消除了"减一 → 微任务 → 加一"间隙里新请求插队的可能。这在性能上的价值不是速度而是公平与确定性:队列深度不会因为调度间隙的"幽灵空槽"被绕过,容量模型精确成立(《07-模块详解》§1.6)。
教学负载的适配度。单人学习场景(点一次跑一次,自动重跑也只在自己打字后触发)几乎不可能触达 2 并发;自动重跑的 1.2s debounce(chapter.tsx)本身就是防止"每个字符一次运行"打爆闸门的前端节流设计。公网多用户场景下闸门是保护而非瓶颈——nginx 10r/m 限流在更前面就把单 IP 压住了。
所有"用户可控的量"都有显式上限,这既是安全防线(《08-安全与权限设计》§3 L7)也是性能防线。逐项看性能视角的含义:请求体 5MB(readBody)限制单次写盘与 JSON.parse 的输入规模;文件数 20(MAX_FILES)限制写盘次数与 vitest 收集的文件图规模;stdout/stderr 合并 1MB(spawnVitest 的 out.length < 1_000_000 守卫)防止用户 20 万行 console.log 把宿主内存打爆——超出后只是停止追加,进程继续跑,且解析主路径是 result.json 不受影响(集成用例「海量 stdout 不破坏解析且进程稳定」验证);输出文本截断(尾部 2000 字符、marker 200 字符、失败消息 6 行)限制响应体与 DOM 渲染规模。一个可以打磨的细节:1MB 截断保留的是前 1MB,而超时/解析失败时消费的却是"尾部 2000 字符"(out.slice(-2000))——海量输出场景下真正有诊断价值的报错尾部可能在 1MB 之外被丢掉,若改为滑动窗口(保留尾 1MB)诊断体验更好,成本为零(见《09-测试与质量保障》§5.2-7 相关的《08》§6-7)。
按收益/成本整理,供演进排期参考(与《13-技术债务与演进建议》的路线图呼应):
| 优化项 | 现状 | 建议 | 预期收益 | 代价/风险 |
|---|---|---|---|---|
| vitest 启动预热 | 每请求冷启动,cacheDir 落沙箱随删 | 引入共享 depcache 目录(需验证并发写安全) | 每次 run 减少 0.5-1s 级(⚠️ 需实测) | 中:缓存失效与并发写是新竞态面 |
| 常驻 worker/进程池 | 无 | 不建议 | - | 破坏"零共享状态"架构公理(《05》不变量 1),60s 教学负载下收益低 |
| 容器化隔离 | 未实现(仅方案文档) | 按 docs/superpowers/plans/2026-09-28-container-isolation.md 落地 | 安全性质变;性能上增加 1-2s 容器冷启动 | 高:镜像管理、lockfile 联动 |
| coverage 默认开启 | 非 bench 课时全部 --coverage(插桩开销) | 仅覆盖率相关课时开启(需给 Lesson 加开关字段) | 每次运行省插桩与 summary 写出 | 低:加字段 + resolveRunOptions 一行 |
writeFileSync 同步写 | writeRunConfig 同步阻塞事件循环 | 改 fs.promises | 释放主线程毫秒级 | 极低 |
| stdout 滑动窗口 | 保留前 1MB | 改保留尾 1MB | 排障体验 | 极低 |
编辑器是前端最大的体积与启动成本项,项目的处理是一套完整的"本地化 + 裁剪"组合拳。体积面:vite.config.ts 的 manualChunks 把 monaco-editor 与 @monaco-editor/react 拆成独立 chunk(另有 antd、react-vendor 两块),chunkSizeWarningLimit: 2500 配合 CI 的 build 步骤构成体积回归门禁;monacoSetup.ts 不用全量入口,只引 editor.api 内核 + TypeScript 语言贡献(basic-languages/typescript.contribution + language/typescript/monaco.contribution),不打包 json/css/html 等用不到的语言与 worker——注释直言目的是"大幅减小产物体积";Monaco 完全本地打包、loader.config({ monaco }) 让 @monaco-editor/react 使用本地实例,无 CDN 网络依赖(这既是离线能力也是首屏稳定性)。Worker 面:MonacoEnvironment.getWorker 按 label 路由 tsWorker/editorWorker,worker 以 Vite 的 ?worker 构建为同源资源;COEP/COOP 响应头(vite.config.ts server/preview 两处)保证跨域隔离环境下 worker 正常加载——README.md 说明该头保留主要是为了 Monaco Worker 的同源加载一致性。类型服务面:setCompilerOptions(strict/ReactJSX/ESNext/noEmit)+ addExtraLib(VITEST_GLOBALS_DTS) 注入手写精简声明(同时注入 TS 与 JS 两个语言服务),让 Monaco 的独立 TS 服务"看不见 node_modules"也不爆红;d.ts 的取舍是刻意的性能决策——完整的 node_modules 类型索引会让 TS worker 内存与 CPU 暴涨,精简声明把语言服务开销压到最低。一个已知的性能噪声:TS worker 在模型异步回收时偶抛 "Could not find source file: 'inmemory://...'"(HMR/课时切换常见),monacoSetup.ts 用 unhandledrejection 监听吞掉,不影响功能。
首屏与渲染:路由级无代码分割(章节页/首页/进度页在同一 bundle,monaco/antd 已拆分懒由浏览器并行加载 ⚠️ 未实测首屏指标);首页统计与章节进度是每次渲染重算的 useMemo(无依赖数组或依赖 [version]),56 课时 × localStorage 读的规模下无感知,但 AppLayout 的 useMemo([]) 把顶栏进度钉死在挂载时(正确性取舍见《13-技术债务与演进建议》);结果面板的 output 是 <pre> 全量渲染,当前 output 受服务端组装规则限制(逐用例 + 6 行失败消息)规模可控,若未来放开长输出需虚拟化。
localStorage 的写入策略是"全量读-改-全量写":setLessonProgress(progress.ts)每次都 loadProgress()(JSON.parse 整个 Map)→ 浅合并该课时条目 → saveProgress(JSON.stringify 整个 Map → setItem)。触发频率最高的是 chapter.tsx 的 handleCodeChange——每次击键都执行一次全量序列化写盘。成本估算:56 课时全量存档通常 < 1MB(《04-数据模型》§11),单次 stringify+setItem 在 0.1-1ms 量级,逐键执行在人机输入速率下无感知;真正的风险不在速度而在两点:其一,写放大与配额——进度 JSON 含每个课时的完整代码,配额(一般 5MB/域)虽然远够,但 saveProgress 没有 try/catch,配额溢出会抛异常且当前无处理(《08-安全与权限设计》§6-1);其二,同步阻塞——setItem 是同步 IO,极端大 Map 时会卡 UI 主线程。
优化建议按渐进顺序:第一步(零风险)给 saveProgress 包 try/catch,溢出时降级提示;第二步(低成本)把逐键写入改为 300-500ms debounce(与自动重跑的 1.2s 思路一致,但注意 debounce 期间刷新页面会丢最后几个字符,需权衡"刷新不丢代码"的体验承诺);第三步(结构升级,仅当引入大文件类功能时)迁移到 IndexedDB(异步、配额大)或只存 diff——同时启用新的存储 key 版本号(-v2)做迁移(《06-部署运行》§9 的版本化约定)。
把全篇结论收拢成一句评述:这个项目的性能设计处处体现"为教学负载做定制,为恶意负载设上限"——每请求冷启动换可预测性、2/8 闸门换宿主存活、1MB/5MB/20 文件/60s 上限换资源有界、Monaco 裁剪 + 精简 d.ts 换前端体积与语言服务开销可控、localStorage 全量写换实现简单(在个人学习规模下成立)。当前所有已知的性能短板(无缓存预热、coverage 全开、同步写、无 CDN)在"单人/小班教学"的真实负载下都不构成用户可感知的问题;真正值得投入的优化顺序应当跟着《13-技术债务与演进建议》的安全演进走——容器化落地后如果引入了排队延迟增量,再回头做缓存预热才有意义。换句话说:性能优化在这个项目里是"随架构演进顺路做的事",而不是需要单独立项的主线。