t3code:前端存量项目代码质量基线建设实践
t3code给存量项目立一条摸得着的代码质量基准线先说结论t3code 不是某个开源框架也不是哪家公司的 SDK它是我给一个长期迭代的前端中后台项目起的内部代号。这个项目跑了三年多经历过三波开发交替、两轮技术栈升级代码量从最初的两万行滚到二十万行出头。最典型的症状是新需求提测前没人敢拍胸脯说“这轮改动没碰到别的模块”Code Review 里大家讨论最多的问题不是业务逻辑而是“这段代码为什么这么写”。后来我用 t3code 做了一次全面的代码质量治理把散落各处的潜规则变成了一条可落地的基准线并把这条基准线固化成 CI 流程里的硬性门槛。如果你正在维护一个多人协作、且已经积累了大量历史包袱的代码库这篇文章里的思路、配置和踩坑记录应该能帮你省去不少试错成本。t3code 要解决的本质问题不是把代码改得“好看”而是用工具和流程强制保住团队的下限——上限靠个人能力下限靠制度和脚本。1. 项目整体设计t3code 的四层质量模型1.1 为什么不用“定规范、靠自觉”的老路子接手这个项目的时候团队已经有了一份长达 40 页的《前端开发规范》写得很细从缩进到命名到组件拆分建议都有。但实际执行效果接近零——新同事入职看完记不住老同事遇到紧急需求直接跳过Code Review 提了意见改一轮下次照样犯。问题的根源不是规范写得不好而是规范没有嵌入到工作流里。人靠自觉永远是最后一道防线而不是第一道。t3code 的设计起点是把能自动化判断的都自动化把需要人判断的压缩到最小范围。我的做法是把质量要求拆成四层每一层对应一个明确的检查时机和责任人层次关注点检查工具检查时机第一层代码风格与基础语法ESLint Prettier保存文件时、提交代码前第二层类型安全与潜在逻辑错误TypeScript 严格模式编译/构建时第三层单元测试与覆盖率门槛Vitest 覆盖率报告MR/PR 合并前第四层构建产物与依赖健康度构建分析 依赖审计发布流水线中这个模型的核心逻辑是漏斗式拦截越靠前的层出现频率越高、修复成本越低所以要由工具实时兜住越靠后的层越接近用户真实感知所以必须在发布前把关。四层配合基本覆盖了一个需求从编码到上线的全部路径——每一段路都有东西在盯而不是靠“记得看规范”。1.2 t3code 的命名逻辑和包结构设计取名 t3code 其实有语义在里面t 代表 tooling、teamwork 和 trust。工具链三要素——工具要自动化、协作要可追溯、产出要被信任。当时我强烈反对叫“new-standard”或“code-spec”这类名字因为团队看到“规范”两个字下意识就想逃。一个项目代号的心理暗示很重要内部沟通时你说“t3code 过了没”比说“规范检查过了没”更有仪式感也更容易形成条件反射。从架构层面我把它设计成三层包结构t3code/ ├── configs/ # 共享配置中心 │ ├── eslint.base.js │ ├── typescript.base.json │ ├── prettier.base.js │ └── vitest.base.ts ├── scripts/ # 脚本层 │ ├── check-staged.js # 提交前增量检查 │ ├── generate-coverage.mjs # 覆盖率对比 │ └── audit-deps.mjs # 依赖安全审计 └── docs/ ├── rules/ # 规则变更记录 └── faq/ # 常见问题速查配置集中、脚本独立、文档配套这样新项目接入时只需要做一件事安装 t3code 依赖然后在自己的项目里引用共享配置。后面所有规则调整都改一处所有项目同步生效不用再跑到每个仓库里改配置。这套模式后来我们团队内部新开了三个子项目接入时间都在 20 分钟以内。2. 工具链选型细节每个关键的为什么2.1 ESLint 扁平化配置的取舍ESLint 从 v9 开始把扁平化配置设为默认方案也就是 eslint.config.js 替代了原来的 .eslintrc。很多团队升级时嫌迁移麻烦一直在旧版上硬挺。t3code 里我直接选了扁平化配置原因有三个一是扁平化配置天然支持把规则集当数组导出非常适合做共享配置二是旧版 .eslintrc 的继承机制在 monorepo 里经常发生“幽灵配置”问题排查起来非常痛苦三是新生态插件基本全面倒向扁平化长期看迁移成本只会越来越高。实际落地时核心配置长这样// configs/eslint.base.js import js from eslint/js; import tseslint from typescript-eslint; import vue from eslint-plugin-vue; import prettier from eslint-config-prettier; export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, ...vue.configs[flat/recommended], prettier, { files: [**/*.ts, **/*.vue], languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, }, }, rules: { typescript-eslint/no-explicit-any: error, typescript-eslint/no-floating-promises: error, typescript-eslint/no-non-null-assertion: warn, vue/multi-word-component-names: off, vue/no-v-html: warn, }, }, { ignores: [dist/**, coverage/**, node_modules/**], } );注意recommendedTypeChecked这个级别它和普通的recommended最大的区别在于需要类型信息参与检查。比如no-floating-promises这条规则能直接抓出“调用了异步函数但不处理 Promise”的问题——这种 bug 在 Code Review 时极难发现但线上出事故往往就是这种“漏网之鱼”。当然开启类型检查规则后ESLint 的运行时间会明显变慢。我们的项目从 8 秒涨到了 22 秒左右为了质量这个代价是值得的后续用projectService: true做增量复用后降到 15 秒。2.2 TypeScript 严格模式的边界控制项目历史上一直开着strict: true但让我意外的是团队里很多人写代码时会刻意绕开类型约束——疯狂用any、as断言、!非空断言。这反映出一种心态类型系统并没有真正成为开发者的“安全带”反而成了需要挣脱的束缚。用any一时爽三个月后重构火葬场这是老生常谈但 t3code 里我用数据验证了这件事对存量代码做了次统计any出现的频率和该模块的历史 bug 数呈明显正相关。于是 t3code 的规则把no-explicit-any直接设为 error同时允许warn级别的非空断言——因为你不能指望一次治理就把历史存量归零先拦住增量的泛滥再逐步消化存量。在 tsconfig 上做了两个关键调整{ compilerOptions: { strict: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true, noPropertyAccessFromIndexSignature: true } }这三个额外开关里noUncheckedIndexedAccess是对业务代码影响最大的开启后访问数组元素或对象索引时类型会自动带上undefined的联合类型。换句话说list[0]的类型变成T | undefined你必须处理“取不到值”的分支。刚上线那两周团队骂声一片但运行三个月后相关类型导致线上 bug 的记录为零——因为在编译期就逼着你把边界情况处理掉了。2.3 测试基线的选择覆盖率门槛不设“审美线”很多团队喜欢把覆盖率门槛定在 80% 或 90%看起来很美实则存量和新增纠缠不清。t3code 的做法是对新增代码设硬性门槛对存量代码设渐进目标。核心工具是 Vitest 的--coverage.enabled配合自定义的对比脚本vitest run --coverage --coverage.thresholds.lines80但龟速执行这块不禁让我想起之前用的 Jest。Vitest 基于 Vite 运行对于我们这种 Vite 技术栈的项目来说复用同一套 transform 配置让测试环境的兼容性问题少了一大半。跑一遍全量测试从 Jest 时代的 4 分多钟降到了 1 分半左右开发时的心智负担显著降低。覆盖率增量对比脚本的逻辑大致是读取本次变更涉及的函数/分支列表逐个匹配测试报告中对应行的覆盖情况最终输出“未覆盖的新增代码清单”。这样就能把“新代码必须覆盖”从一句口号变成一个可执行的检查项老旧代码覆盖率低的问题不再拖后腿。3. 实操过程从零到一搭建 t3code 全流程3.1 存量项目的体检与基线记录任何治理项目的第一步都不是写规则而是摸清家底。t3code 上线前我先组织了一次为期两天的“代码体检”——不是人工 review全部靠工具扫描用数据说话。体检项目包括ESLint 规则告警数量与类型分布TypeScript 类型错误数tsc --noEmit单元测试数量、覆盖率、运行时长依赖数量、体积与安全漏洞审计结果构建产物的 gzip 大小与首屏资源占比体检报告出来时数据确实有点触目惊心ESLint 告警 4000其中any相关的占了近三分之一单测覆盖率不足 30%依赖包总数 1200其中超过 50 个包超过两个大版本没升级。这些数据最大的价值不是“批判过去”而是给 t3code 提供了量化基线——后续每轮迭代是好是坏拿来对比就知道了。体检完成后我把结果同步给团队并明确一个预期t3code 的目标不是一个月内把这些数字全部变绿而是每个迭代只准变好、不准变坏。存量包袱我们用季度维度慢慢还增量错误当天拦截。3.2 接入 Git Hooks让检查发生在“最有痛感”的时刻实践中我发现质量工具最有效的拦截点不是在 CI 上而是在开发者准备提交代码的那几秒。因为 CI 拦截的反馈周期太长——半小时后告诉你这行有问题开发者可能都切到别的任务上去了。Git Hooks 提供的即时反馈完全不同。这里我用了lint-stagedhusky的组合配置很直接// package.json 片段 { lint-staged: { *.{ts,vue,tsx}: [ prettier --write, eslint --fix, vitest related --run ] } }# .husky/pre-commit npx lint-staged注意最后一步vitest related --run这个命令是 Vitest 提供的增量单测能力——只运行本次改动文件对应的测试用例。它把单元测试的反馈也塞进了提交前检查等于在那几秒钟内已经完成了一轮快速验证。实测下来一个十几秒的 pre-commit 检查能挡住大约 40% 的“明显问题提交”包括格式错误、低级 lint 违规、语法错误等。别忽略 pre-push 这个环节。我们配置了 hook 在 push 前执行tsc --noEmit和全量 lint# .husky/pre-push npm run typecheck npm run lint npm run test:unit -- --run这一步的意义在于覆盖了 pre-commit 可能漏掉的跨文件类型检查——比如你改了 A 文件的接口B 文件的实现跟着编译报错这种情况 lint-staged 是发现不了的但全量tsc一跑马上现形。3.3 CI 流水线里的“三关”设计本地钩子是辅助CI 才是最终守门员。t3code 在 CI 里设置了三个检查关卡合并请求必须全部通过才能合入关卡执行内容失败时表现第一关install时执行依赖审计有高危漏洞直接失败第二关linttypechecktest任何一项失败则阻断合并第三关构建产物对比相对于主干分支产物体积超过阈值则提示人工确认第三关是为了防止“悄悄胖起来”的依赖问题。实现方式是script记录当前分支的构建产物大小与主干分支上一次构建的产物大小对比如果 gzip 后涨幅超过 5% 或绝对值超过 10KB就给 MR 打上size-warning标签。这类问题靠 Code Review 是很难发现的——你看到代码里新增了一个 import但很难立刻判断出它的传递依赖有多重。让 CI 去盯这件事比人靠谱得多。3.4 从“治存量”到“防增量”的过渡节奏t3code 上线后我采用了“先严增量、后啃存量”的节奏推进避免团队一次性工作量过大产生对抗情绪。第一个月所有新增代码必须过 lint 类型检查 单测覆盖存量问题只记录不强制修。这个阶段最容易推行因为不涉及重写旧代码。第二到三个月按模块拆解存量问题清单按“影响用户面大小 × 修复成本”排序优先处理那些“看起来没坏但隐患最大”的部分——比如到处散布的any、被 try/catch 吞掉后无日志的错误处理、未处理的大数组循环等。第四个月起将关键模块的覆盖率目标逐步上调从基线 40% 调向 60%同时建立了“每次迭代必须修复至少 5 个存量问题”的团队约定。实际效果比预想顺利原因是存量问题有了明确的“验收单”而不是一句空洞的“提升质量”。每修完一个打一个勾团队会有一种在清债的成就感。4. 常见问题与排查技巧实录4.1 lint-staged 误伤格式化导致无意义的全量 diff现象某个同事提交代码后MR 里出现了大量和功能无关的格式改动——比如某个文件被 Prettier 整体重排了。原因排查.prettierrc配置在 t3code 中是共享的但那个同事本地编辑器装的是旧版 Prettier 插件格式化风格和 t3code 的prettier.base.js不一致。lint-staged 执行prettier --write时把整个文件按新规则全量格式化了一遍。解法要求团队所有成员统一使用项目内的 Prettier 版本而不是编辑器插件自带的版本在.prettierrc中明确设置editorconfig: true让编辑器自动识别根目录配置提交前检查里加了一步“只格式化变更行”的配置避免大范围重排。prettier --write staged-file --range-start start --range-end end但实测中 range 模式对 Vue 模板支持一般最后选择了最稳妥的办法——让所有人安装t3code/prettier-config并用同一命令格式化彻底消除“各写各的格式”的源头。4.2 覆盖率门槛被恶意“刷绿”现象某模块覆盖率数字不达标但测试代码明显是“为了覆盖率而写”的——比如断言里全是expect(true).toBe(true)或者干脆用/* istanbul ignore next */把关键分支排除掉。原因纯数字指标天然会被钻空子。人会用最低成本去满足一个数字指标而不是真正去提升质量。解法在 t3code 的 vitest 配置里禁用了istanbul ignore注释的总开关确保没法通过注释逃避覆盖将阈值检查细化为函数覆盖率和分支覆盖率双指标。只看行覆盖率非常容易被空跑代码刷高但 branch coverage 刷高需要周全考虑分支难度大得多Code Review 中抽查测试断言质量一旦发现“假测试”直接打回并纳入团队质量记录。另一个很实用的技巧是给覆盖率报告接入“增量提示”。我们的实现是在本地跑测试时如果新增代码未覆盖会在终端打印具体行号和未覆盖原因直接把问题推到开发者脸上而不是给一个冷冰冰的百分比。4.3no-floating-promises引发大量存量告警的处理策略现象开启类型检查规则后存量代码瞬间多出 200 告警很多是异步函数调用没加await但实际业务上并不需要等待返回结果。原因很多 fire-and-forget 的写法从业务含义上可以容忍比如上报埋点。但规则不可能自动判断“这里到底需不需要 await”。解法先分三桶处理——埋点、日志上报等明确不需要等待的显式添加void操作符表明意图void trackEvent(...)告知规则“我就是要丢弃这个 Promise”。这比加 eslint-disable 更诚实有状态更新逻辑的必须补await这往往是潜在 bug 的温床无法确定语义的交回给作者确认。经过一轮处理存量告警降了 70% 左右剩余的都是真正需要人工和产品确认逻辑的场景。这个过程中最有价值的不是把告警清零而是让团队养成了“写异步代码时先想清楚要不要等待”的思考习惯。4.4 monorepo 模式下共享配置的继承冲突现象子项目 A 引用了 t3code 的 eslint 配置后自身再叠加自定义规则时发生了规则覆盖不生效或报“规则冲突”的错误。原因排查扁平化配置中后加载的配置对象会覆盖先前的同名规则但如果子项目在引用时把tseslint.config的数组拆散后重新组装某个ignore或files的匹配范围就会把自定义规则“隔离”掉。解法给 t3code 配置增加了“分片导出”能力// configs/eslint.base.js 中补充 export const t3codeIgnore { ignores: [dist/**, coverage/**, node_modules/**], }; export const t3codeRules tseslint.config( js.configs.recommended, // ... );子项目引用时就非常灵活// 子项目 eslint.config.js import { t3codeIgnore, t3codeRules } from t3code/configs; export default [ ...t3codeRules, t3codeIgnore, { files: [src/**/*.ts], rules: { no-console: warn, }, }, ];核心经验是共享配置必须可拆分、可组合而不是一个黑盒对象。黑盒配置对用的人来说是灾难——他们不知道规则怎么叠的一旦出问题只能干瞪眼。5. 团队落地经验与长期维护建议5.1 规则变更要用“事实”说服人而不是用“权威”t3code 运行三个月后有同事提了个好建议把 eslint 规则里某些warn级别的东西升级为error。但团队里也有人反对理由是“太严了影响开发效率”。我的处理方式不是开个会争论而是先跑一份数据统计这三个月来哪些warn告警出现次数最多抽样看这些告警中有多少比例后来演变成了 bug 或返工需求把结果贴到群里让数据替规则说话。最终有两条规则成功升级为errortypescript-eslint/no-unnecessary-condition和typescript-eslint/no-unnecessary-type-assertion。它们本质上是帮你删掉“防御性代码”——那些你以为是保护、实际是掩盖问题的多余判断。这类规则升级靠强推也行但用数据更容易让团队心服口服。5.2 自动化之外的“人肉环节”Code Review 的聚焦化工具能解决下限但没有工具能替代 Code Review。t3code 对 Code Review 的改造是对齐“聚焦点”要求 review 时重点看三类问题——业务逻辑正确性、异常处理存不存在、改动影响范围是否明确。类型和格式问题全部交给工具去查reviewer 把精力集中在机器看不懂的地方。执行方式是给 MR 模板增加了几个可选勾选项本 MR 涉及哪些既有行为变更是否处理了异常路径网络超时、空数据、权限不足是否补充了对应测试用例测试命名是否符合“场景-预期”模式模板不是摆设MR 描述缺少这些信息时CI 会直接 block。用流程设计去逼着人思考比口头强调“希望大家认真 review”有效得多。5.3 长尾迭代定期“质量复盘日”的价值t3code 上线满半年时团队形成了一项固定活动每个月最后一个周五下午花一小时过一遍质量数据。看三个指标线上错误率趋势、CI 失败率趋势、测试覆盖率变化。不追责、不批评只找系统性原因。这个复盘日的作用很微妙——它让质量治理从“项目初期的冲刺活动”变成了“持续运转的日常机制”。t3code 这个名字也从一个代号沉淀成了团队文化的一部分新人入职第一周就要过一遍 t3code 的所有配置和规则不需要背下来但要理解每层检查在防什么。我个人这几年的实操体会是代码质量治理的难点从来不在于技术方案选型而在于让每个环节的人都能感受到这套机制在帮自己减少麻烦而不是给自己添堵。t3code 能坚持下去核心原因是它把“质量”从一句口号拆成了一个又一个具体、可执行、有反馈的动作。每一行被拦下来的问题代码都在悄悄降低某个深夜被叫起来修 bug 的概率。这份回报是所有使用 t3code 的同事都切实体会到了的。