t3code:用一条命令搞定提交前的类型检查、测试与代码风格
“t3code”这名字听起来像是某种编码练习但在我这里它是一次实打实的工程方案。几个月前我实在受不了每天提交代码前要在终端里手动敲三四条命令还总是有人漏掉某个检查导致 CI 上反复亮红灯。于是我给工作流起了个代号 t3code意思很直白Type、Test、Tidy即类型检查、单元测试、代码风格校验。这个工具本质上是一个本地执行的命令编排器把三个独立的质量关卡合并成一条命令谁提交代码之前跑一遍npx t3code就够了。这篇文章就记录它的完整实现过程包括最初的原型、配置化重构、并行执行设计和实际踩到的坑适合那些想给团队或自己项目做一个统一检查入口的开发者参考。1. t3code的由来提交前检查的“最后一公里”痛点先说结论大多数代码质量问题不是没工具管而是工具太多、入口太散。我和当时搭班的几个同事维护一个不算小的 TypeScript 前端项目早早就接上了 ESLint、Jest 和 TypeScript 的严格模式。单看每一项配置都会觉得挺完善。但一落到“提交前到底怎么检查”这个环节问题就开始暴露了。1.1 靠自觉的上岸方式不靠谱我观察过大家提交前的习惯大致能分出三类。一类人只跑eslint .看到没有 error 就提交完全不关心类型对不对。一类人倒是会跑tsc --noEmit但测试测不测全看心情经常导致 CI 上jest挂了之后又来回补 commit。还有一类人干脆什么都不跑等 CI 跑完发现问题再回来改一来一回浪费十几分钟都是少的。这其实不是大家懒而是入口不统一。每个人都有一套“我自己觉得够用的检查组合”但人与人之间不交叉。你包的package.json里的scripts倒是写了不少但至少三种不同的命令缩写在里面互相纠缠新来的同事看着就懵。1.2 三个阶段的职责怎么划分我最初的思路就是给提交前的检查分三个独立阶段每个阶段只干一件事顺序先后有讲究TType跑 TypeScript 的类型检查命令是tsc --noEmit保证类型层不出问题。TTest跑 Jest 单测命令是jest --runInBand保证行为层的变化没有被破坏。TTidy跑 ESLint 做风格和静态检查命令是eslint . --ext .ts,.tsx保证代码层面整洁一致。三个阶段顺序不能乱。类型错误经常会导致测试在编译阶段就挂掉所以 Type 必须放在最前面。Tidy 会检查未使用的变量之类的问题这些跟类型和测试没有依赖关系但它最容易因为项目内部约定产生噪音放最后不会挡住更严重的错误。这个“三 T”结构就是我们叫它 t3code 的原因三道关卡、三个以 T 开头的动作、最终浓缩成一个命令。它不是要发明新的质量检测手段而是把已有工具拧成一股绳让提交前的动作从“我记得应该跑一下”变成“我跑了一下 t3code”。2. 第一个版本三行命令串联的原始脚本一开始我没打算做得多复杂就写了一个简单的 Node 脚本用child_process.execSync依次执行三条命令。当时的想法是先把流程跑通再看要不要优化。2.1 最朴素的实现方式先看一下最初的代码很简单甚至有点粗糙const { execSync } require(child_process); try { console.log([t3code] 阶段 1/3类型检查); execSync(tsc --noEmit, { stdio: inherit }); console.log([t3code] 阶段 2/3单元测试); execSync(jest --runInBand, { stdio: inherit }); console.log([t3code] 阶段 3/3代码风格检查); execSync(eslint . --ext .ts,.tsx --max-warnings0, { stdio: inherit }); console.log([t3code] 全部通过); } catch { console.error([t3code] 有阶段未通过阻止提交); process.exit(1); }这段代码用stdio: inherit让子进程的输出直接透传回终端所以能看到后面工具原本的彩色输出和进度条。try/catch捕获execSync抛出的异常一旦任何一个阶段非零退出就立刻process.exit(1)CI 或 pre-commit 钩子里就能识别本次检查不通过。2.2 为什么第一版选 Node 而不是 Shell纯用 Shell 写看起来更短很多人可能第一反应是写一个check.sh三行脚本完事。但我有两个必须考虑的因素。一是团队里有人用 Windows。Shell 脚本在 macOS 的 bash 和 Windows 的 cmd/PowerShell 之间行为差异很大光是那句trap echo failed ERR就要处理半天。Node 脚本有cross-env等生态支持至少我们能在一个地方统一开发。二是后续要加参数、加配置文件、加并发控制Shell 会变得极难维护。Node 在组织复杂命令和解析逻辑上明显更顺手。哪怕原始版本看起来只能算个“高级的批处理”它至少保留了程序化的骨架。2.3 第一版明显的三个问题用了一周之后问题很快暴露出来。第一串行执行导致总耗时等于三个阶段耗时相加。我们的单测大概要跑 2 分多钟eslint 也要 40 秒整个流程接近 3 分半很多人急着提交就懒得跑了。第二一旦第一个阶段失败后面的阶段根本不会执行所以你无法一次看到全部问题。比如说你类型检查挂了一个eslint 里有三十个警告你也只能先修复类型错误再跑一次才能看到 Tidy 的问题来来回回非常浪费时间。第三我几乎没法控制“要不要跑某个阶段”。有时候只是改了一个 README并不想跑测试但这个脚本没有任何参数所有的判断都只能靠注释代码不能灵活配置这对工程实践来说是个很大的隐患。这些痛点直接推动了下一版的重构。你要是也想动手做类似的工具建议第一版别追求完美先让串行流程跑起来体验过痛感之后再看如何优化。3. 配置化重构用一份 t3code.config.js 驱动所有阶段第二版是彻头彻尾的配置驱动目标很简单用一份放在项目根目录的t3code.config.js就能定义“跑哪些阶段、用什么命令、失败之后是否继续”。3.1 配置结构设计先看最终设计出来的配置文件长什么样// t3code.config.js module.exports { stages: [ { name: type, command: tsc --noEmit -p tsconfig.json, color: #4aa3f7, continueOnError: false, }, { name: test, command: jest --runInBand --coveragefalse, color: #52c41a, continueOnError: false, }, { name: tidy, command: eslint . --ext .ts,.tsx --max-warnings0, color: #ff8c00, continueOnError: true, }, ], stopOnStageFail: false, showOutput: summary, maxOutputLines: 80, };几个关键选项是深思熟虑过的。name用来做日志前缀比如[t3code:test]一眼就知道是哪个阶段出的问题。command是完整的 Shell 命令允许带参数。这里我故意不拆分成executable args的结构原因是团队里可能有pnpm、yarn、npm拆开反而让配置文件更啰嗦。color用于在终端里给每个阶段一个标记色视觉上区分阶段不参与业务逻辑。continueOnError表示这个阶段失败了是否继续跑后续阶段比如tidy失败不会阻塞测试结果展示。stopOnStageFail是全局开关如果为true某个阶段失败后立即停掉所有后续阶段。showOutput取值full或summary决定是直接把全部输出打到终端还是只汇总错误信息。maxOutputLines限制每个阶段最多展示多少行防止三个工具的输出同时爆炸把终端刷屏。3.2 配置加载与解析逻辑配置文件放在项目根目录用 Node 的require加载即可。因为是 JS 文件还可以支持注释和使用path模块动态拼接路径比 JSON 灵活太多。配套的加载逻辑长这样const fs require(fs); const path require(path); function loadConfig(cwd process.cwd()) { const candidates [ path.join(cwd, t3code.config.js), path.join(cwd, t3code.config.cjs), ]; const configPath candidates.find((p) fs.existsSync(p)); if (!configPath) { return { stages: [ { name: type, command: tsc --noEmit }, { name: test, command: jest --runInBand }, { name: tidy, command: eslint . --ext .ts,.tsx }, ], stopOnStageFail: false, showOutput: summary, maxOutputLines: 80, }; } const userConfig require(configPath); // 对配置做一些基础校验 if (!Array.isArray(userConfig.stages) || userConfig.stages.length 0) { throw new Error([t3code] stages 必须是非空数组); } return { ...defaults, ...userConfig }; }为什么要把默认配置内嵌在代码里这是借鉴了工程上常见的“零配置优先”思路。当新同事拉下仓库、还没创建配置文件的时候直接跑t3code也能执行一套合理的默认检查逐步感受到自定义需求再创建配置文件覆盖即可。对刚开始推广的团队降低使用门槛比功能丰富更重要。3.3 按配置串行执行同时保留失败信息第二版的执行器仍然是最简单的串行模型但彻底贯彻了配置项。核心逻辑是遍历config.stages逐个运行如果某个阶段失败且continueOnError为false就立即终止如果为true则把错误记录下来继续跑。最后根据是否有失败决定退出码。async function runSerial(config) { const failed []; const results []; for (const stage of config.stages) { const start Date.now(); process.stdout.write([t3code] 运行 ${stage.name} ...\n); try { await execStage(stage.command); results.push({ name: stage.name, passed: true, duration: Date.now() - start }); } catch (err) { results.push({ name: stage.name, passed: false, duration: Date.now() - start }); failed.push(stage); if (config.stopOnStageFail !stage.continueOnError) break; } } results.forEach((r) process.stdout.write( ${r.passed ? PASS : FAIL} ${r.name} ${r.duration}ms\n ) ); return failed.length 0 ? 0 : 1; }这种显式continueOnError和全局stopOnStageFail的组合很实用它允许你配置“即使 lint 挂了也要把测试跑完”的场景又不至于让一次失败的提交拖着全套工具白白耗时。4. 并行化与结果汇总让 t3code 真正变成工具配置化重构让 t3code 变得可用了但速度瓶颈还在。三阶段的串行时间仍然是总和于是我开始琢磨并行化。4.1 为什么要并行以及并行时的最大挑战Type、Test、Tidy 三个阶段在命令层面完全独立tsc --noEmit不依赖 jest 的产物eslint 也不需要编译结果这就意味着可以同时启动三个子进程。对现代计算机来说多一个核心跑多一个进程顺理成章理想的耗时应该约等于最慢的那个阶段而不是三者之和。并行最大的挑战不是性能而是输出管理。如果三个子进程共享同一个终端输出管道你会看到 tsc 的报错、jest 的进度条、eslint 的 warning 交错在一起根本分不清是哪来的。解决办法是让每个子进程把自己的stdout/stderr捕获到独立的缓冲区里全部跑完后按阶段顺序统一输出。4.2 使用 spawn 配合 Promise 实现并行这段逻辑我用child_process.spawn重写了。spawn不主动缓存输出必须手动监听数据事件这正适合做输出采集。const { spawn } require(child_process); function runStage(stage, maxOutputLines) { return new Promise((resolve) { const child spawn(stage.command, [], { shell: true, env: process.env, }); let output ; let lineBuffer []; child.stdout.on(data, (d) processChunk(d)); child.stderr.on(data, (d) processChunk(d)); function processChunk(chunk) { const text chunk.toString(); lineBuffer.push(...text.split(/\r?\n/)); if (lineBuffer.length maxOutputLines * 2) { lineBuffer lineBuffer.slice(-maxOutputLines); } } child.on(close, (code) { const outputText lineBuffer.length maxOutputLines ? ... (截断剩余 ${lineBuffer.length - maxOutputLines} 行)\n lineBuffer.slice(-maxOutputLines).join(\n) : lineBuffer.join(\n); resolve({ name: stage.name, code, output: outputText, }); }); child.on(error, (err) { resolve({ name: stage.name, code: -1, output: err.message }); }); }); }进程其实并不是严格同时启动的而是通过Promise.all来等待。关键点是shell: true这能确保配置里写tsc --noEmit -p tsconfig.json这样的命令可以跨平台执行但代价是如果命令里包含引号解析会变得复杂这个我在后面的坑里会细说。执行所有阶段只需要一行const results await Promise.all(config.stages.map((s) runStage(s, config.maxOutputLines)));4.3 结果排序问题Promise.all的返回顺序和传入数组的顺序一致所以并行跑完了之后打印结果时依然按配置顺序来不会出现 Tidy 输出出现在 Test 前面这种视觉错乱。但注意如果某个阶段在并行过程中失败了其他阶段可能已经跑完了所以最终汇总时要把耗时也打出来方便横向比较。打印结果时我按下面的格式输出[t3code] 结果汇总 PASS type (1.2s) FAIL test (42.8s) → Error: expect(received).toBe(expected) → at src/foo.test.ts:18:32 PASS tidy (3.5s)如果showOutput设置为detail则完整打印每个阶段的输出如果summary那只有失败阶段会打印对应内容。这个设计是为了让大家可以在本地开发时使用detail快速定位而 CI 中只输出 summary 避免日志过长。5. 实测中的几个坑与处理方案任何工具的价值都要经过真实使用才能体现。t3code 从脚本变成工具后我连续在项目里用了几周也踩了几个不常见的坑在这里分享一下排查过程和最终方案。5.1 shell: true 带来的引号转义问题并行版本引入后第一周就有人报错“我的命令里带了双引号怎么全都碎了”典型例子是jest --testPathPatternsrc/user/.*。在spawn的shell: true模式下命令会被交给操作系统的默认 shell字符串会经历一层隐形的引号解析嵌套引号可能导致命令被错误拆断。排查过程很简单我先单独在终端里运行这条命令发现能过再通过 t3code 跑发现子进程报 unknown option。对比之后确定问题出在shell: true的引号重解析上。这个问题的解决方案是尽量让配置文件里的命令不包含内嵌引号。如果确实需要复杂参数使用双反斜杠转义例如jest --testPathPattern\src/user/.*\。更稳妥的做法是放弃shell: true把命令解析成可执行文件和参数数组但这样会导致配置不够直观平台兼容性也要重新维护。最后我的选择是保留shell: true在文档里明确建议用户避免嵌套引号。5.2 并行跑 jest 和 eslint 时的缓存文件冲突有一次我在本地跑 t3code发现 jest 在解析测试文件时总是报“Cannot find module”但单独运行jest --runInBand却没事。用--verbose排查了半天最后定位到是 eslint 的.eslintcache文件正在被另一个并行进程写入导致 jest 的模块解析器偶然读到了半截状态。这个坑的技术细节不算深但它说明了并行编排的一个原则并行的子进程最好互相隔离有写入状态的路径。我的处理方式是给 eslint 单独指定一个本地缓存路径比如--cache --cache-location .cache/.eslintcache并把它加进.gitignorejest 也设置--cacheDirectory.cache/jest。这样两个进程的临时文件落盘位置完全分开冲突自然消失了。5.3 CI 里彩色输出变成乱码本地跑得好好的 t3code到 GitHub Actions 上一跑输出里全是[38;5;49m之类的 ANSI 转义序列。原因是很多检查工具默认根据process.stdout.isTTY来判断是否使用彩色输出CI 环境下非 TTY但我的工具里用了stdio: inherit或者直通输出流转义码被原样打了出来。排查很快只需在 CI 环境变量里设置NO_COLOR1或者分别为三个子进程环境变量添加FORCE_COLOR0。我后来在 t3code 启动时检测process.env.CI如果存在就自动给所有子进程环境变量追加FORCE_COLOR: 0彻底解决这个跨环境问题。5.4 CtrlC 后残留子进程并行跑三个进程时如果用户在终端按了 CtrlCNode 主进程默认会结束但三个子进程可能变成孤儿进程继续跑卷走 CPU 和内存。这是个比较隐蔽的问题因为开发机上你可能根本察觉不到但在 CI 上这会导致任务超时挂死。我后来给 t3code 加了信号处理let children []; process.on(SIGINT, () { children.forEach((child) child.kill(SIGTERM)); process.exit(130); });每次spawn之后把 child 记录到children数组里等到主进程收到中断信号时统一清理。对进程编排类工具来说徒增“孤儿进程”风险是必须防范的不能期待用户每次都能干净退出。5.5 增量检查大项目跑一次太久怎么办t3code 的并行方式虽然快但在一个几千个文件的 monorepo 里全量tsc --noEmit仍然可能要两分钟这对频繁提交的人来说还是太慢。我在后续迭代里给 Type 阶段加了一个增量模式在package.json里提供t3code:incremental脚本只检查 git 暂存区中改动的文件相关的类型声明。做法是用git diff --name-only --cached拿到文件列表然后把这些文件路径传给tsc --noEmit的--jsx等参数同时保留转编译能力。不过说实话这只对前端项目有效对大型 monorepo 最好还是信任全量检查。增量检查可以当成一个加速的小技巧但不能作为唯一的门禁标准。我平时使用的时候是在本地提交前跑一次增量等 push 到远端后全量检查仍然会在 CI 环节兜底。最后说点个人体会t3code 不是那种高高在上的基础设施它就是一个解决“检查入口太散”的编排器代码量也不过两百行出头。但我用了它之后最大的感受是很多痛苦其实不是工具能力不足造成的而是流程没有被固化成脚本。当你把质量检查从“靠自觉、靠记忆”变成“一个命令、一定退出码”团队里的沟通成本会瞬间降下来犯错的概率也会变小工程师跑检查的效率自然就高了。如果你现在正在被提交前检查流程困扰我的建议很简单先别追求完美设计用execSync串一个三阶段脚本跑起来感受一下觉得慢再做并行觉得要灵活再上配置文件。工具是为流程服务的流程清晰了代码怎么写都只是时间问题。