☰
t3code:前端存量项目代码质量基线建设实践
2026/10/9 12:06:26 网站建设 项目流程

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.lines=80

但龟速执行这块不禁让我想起之前用的 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-staged+husky的组合,配置很直接:

// 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时执行依赖审计有高危漏洞直接失败
第二关lint+typecheck+test任何一项失败则阻断合并
第三关构建产物对比(相对于主干分支)产物体积超过阈值则提示人工确认

第三关是为了防止“悄悄胖起来”的依赖问题。实现方式是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 或返工需求;
  • 把结果贴到群里,让数据替规则说话。

最终有两条规则成功升级为error:@typescript-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 的同事都切实体会到了的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询