我们组从年初开始推进代码工程标准化,前后折腾了好几轮方案,最后沉淀下来一套内部代号叫t3code的实践体系。它不是某个框架,也不是一个具体的工具,而是一整套关于"怎么写代码、怎么组织代码、怎么保证代码长期可维护"的规则和自动化约束。这篇文章就是我把这套东西从思路到落地完整复盘一遍,里面包含了目录设计、命名规则、工具链配置、老项目迁移路径,以及我们在真实业务里踩过的坑。如果你正在被代码风格不统一、模块边界模糊、接手老项目想重构又不敢动这类问题困扰,这篇应该能给你一个比较完整的参考。
1. 为什么是“t3code”:第三代代码重构的由来
t3code 这个名字里的 T3,其实就是 Third Generation 的缩写,代表我们内部代码治理思路的第三代方案。但一开始并没有这么宏大的规划,事情是从一个很具体的痛点开始的。
大概在一年前,我接手了一个已经迭代了两年多的中大型业务系统,代码量在几十万行这个量级。当时最让人崩溃的不是业务逻辑复杂,而是同一套代码里同时存在好几种完全不同的编码风格。有人用 4 空格缩进,有人用 Tab;组件文件有的叫UserCard.vue,有的叫user_card.js,还有的叫userCard.ts;状态管理有的放 Vuex 里,有的散落在各个组件内部;工具函数更是重灾区,utils/目录下堆了两百多个文件,文件名从a.js到zzz.js什么都有,里面大量重复功能的函数只是参数顺序不一样。每次改一个需求,光是在不同风格的文件之间来回切换就要耗费不少精力。
更麻烦的是,团队不是只有我一个人。前后端、桌面端、小程序端几个小组各自有自己习惯的写法,合到一个仓库之后,这种差异就被无限放大了。我们之前不是没有尝试过治,比如拉一个 CODE_STYLE.md 文档,要求大家照着做。但很快就发现,文档是死的,人是活的,而且每个人对文档的理解还不一样。有人觉得"函数名用驼峰"这条只适用于新文件,老文件不用动;有人觉得"组件目录用 kebab-case"只限于一级目录,嵌套层可以随意。规范执行到后来基本靠脸皮,谁比较较真谁就多吃亏,最后妥协的都是那些难啃的老代码。
T3 这套思路,本质上就是针对上面的情况做的一次彻底改革。第一代方案我们叫"约定时代",靠的是人和文档的自觉;第二代叫"工具时代",上了 ESLint 和 Prettier,但只是做语法层面的约束,管得住格式管不住结构;第三代 T3 的思路则是把规范内嵌到工程骨架里,让它成为项目初始化时候的一部分——你新建一个模块,目录结构天然就是符合规范的;你写一个组件,命名风格天然就是统一的;你要提交代码,自动化检查会拦截掉所有不符合规则的东西。规范不再是贴在墙上的告示,而是项目的默认行为。
拿个生活化的例子来说:第一代方案像是小区门口立了块牌子写着"请勿高空抛物",第二代方案是装了监控摄像头,谁扔了垃圾能查到人,T3 的思路则是直接把楼栋设计改成没有可以往外扔东西的窗户。你没法违反一个结构上就不存在可能性的规则。
如果你是一个三五个人的小项目组成员,可能觉得 t3code 这套东西有点重;但只要你经历过二十人以上协作、产品迭代进入维护期的阶段,应该能理解这种"在源头锁定混乱"的思路有多重要。我们 T3 的目标不是约束单个文件的写法,而是从文件组织、模块边界、代码依赖方向、变更流程四个维度同时下手,把个人的发挥空间限定在一个合理的网格里。
这个项目的适合人群其实很明确:被老代码折磨的维护者、正在制定团队规范的 Tech Lead、以及准备对现有工程做一次深度梳理的技术负责人。它不是某个脚手架工具那样开箱即用的东西,更接近一套可以按需裁剪的思考框架和实操模板。
2. 从结构上掐死混乱:T3 的目录分层与模块边界设计
T3 的整个体系里,最先落地、收益也最直接的,是对目录结构和模块边界的设计。很多团队把代码混乱归咎于"程序员素养不够",但我的观点是:只要结构给了足够的引导,大部分人的代码会自动变得整齐。环境暗示对行为的影响远比规章制度大。
2.1 特征化目录:一眼看清代码的角色
T3 把代码目录按"角色特征"而不是"技术类型"来划分。这句话怎么理解?很多项目的目录是按技术栈划分的,比如components/、views/、api/、utils/,这种划分的问题是:你只知道一个文件是什么(组件、接口、工具),却不知道它在业务里承担什么角色。结果就是api/目录里渐渐混进了业务逻辑,utils/目录里堆积了大量和业务耦合的私有函数。
T3 的第一层目录只分四类:core/(领域核心)、app/(应用组装)、shared/(通用共享)、infra/(基础设施)。看名字你就能感受到差别:
core/放的是业务领域模型和不可变规则,它不依赖任何 UI 框架和外部服务,纯 TypeScript 实现。这一层的代码是整个系统最稳定的部分。app/放的是和具体使用场景相关的构成块,比如页面、组件、状态管理、路由配置。它依赖core/,但不依赖shared/里的东西。shared/放的是无业务含义的通用工具和基础组件,比如日期格式化、请求封装、通用按钮。它不依赖core/和app/,可以被任何层引用。infra/放的是外部对接相关的实现,比如数据库访问、缓存、第三方服务 SDK。它通常被core/的接口反向依赖,实现依赖倒置。
这个分层的价值在于,依赖方向是强制的,而且是单一指向的。app/可以依赖core/,但core/绝对不能反过来依赖app/;shared/谁都能依赖,但它不能依赖任何人。在代码评审的时候,只要看到目录层级之间有不符合方向的 import,直接打回就是,不需要扯一堆"架构原则"的皮。
对了,这一层落地最讨厌的就是"按层分包"的诱惑。有人会先把core/、app/建好,但写到一半发现core/里有个函数需要用到shared/里的工具,于是图省事直接 import 了。T3 的做法是在 ESLint 里加一条no-restricted-imports规则,把跨层引用方向直接写成限制条件,违反规则的时候控制台会报错,CI 直接不过。这种地方宁可狠一点,也不要给人性留试错空间。
2.2 模块私有化与"开放接口"显式化
目录分层解决的是大方向的混乱,模块之间怎么协作则是另一个问题。很多项目里,两个互不相干的模块会直接互相 import 对方内部的组件和函数,日积月累就形成了一张剪不断理还乱的依赖网。T3 对所有模块做了一个约定:每个目录下的index.ts是唯一的对外出口,模块内部文件不得被外部直接引用。
这句话展开说就是三层意思:第一,外部模块想使用当前模块的任何能力,都必须从index.ts导入,入口文件做了显式导出;第二,模块内部非index.ts文件,ESLint 一律禁止被外部 import,从工程上保证隐私边界;第三,index.ts本身只导出公共 API,内部实现细节不该被外部看到的一律不导。
践行这个规则之后,代码发生的最直观变化是:所有 import 路径都变短了,且 import 的来源非常聚合。以前可能一个文件里从别的模块引了十几个分布在不同层级的路径,现在统一收敛到@module/xxx这种入口上。重构时也轻松很多——只要入口函数签名不变,模块内部随便翻天覆地,外部都感知不到。依赖图上模块与模块之间是干净的星型拓扑,而不是一团乱麻。
2.3 目录命名、文件命名与导出的三重一致
如果你觉得上面说的都是"大方向",那接下来这个就是"细节强迫症"。T3 要求文件名、默认导出的组件名、路由路径三段式命名保持一致。这样说有点抽象,实际落地就是pages/login/index.vue这个文件里默认导出的组件必须叫LoginPage,路由注册的路径就是/login。体现到目录名称上,user-center 这个目录里出现的组件文件名、导出的模块名都要和 userCenter 这个业务域有明确关联。
这个规则的好处是反直觉的——看起来像是吹毛求疵,实际上它把"搜索"这件事变得极其高效。以前找一个功能对应的文件,要先在路由表里查路径,再猜组件名,然后在编辑器里全局搜,可能搜半天还搜不对。现在只要知道 URL 路径,倒推文件名、组件名、目录名是一条龙直达,反向从代码找路由也一样顺利。
命名规范具体到落地,我们是直接写在 ESLint 插件规则里的,不靠人肉记忆:
{ "rules": { "filenames/match-regex": [2, "^[a-z0-9-._]+$", false], "vue/component-definition-name-casing": ["error", "PascalCase"], "vue/match-component-file-name": ["error", { "extensions": ["vue"], "shouldMatchCase": true }] } }这里有个容易忽略的坑:match-regex会把.vue文件的后缀也纳入匹配范围,你配置的时候要把后缀考虑进去。我们一开始没注意,导致所有.vue文件全部报错,排查了两小时才发现是正则写得太死。这种血泪教训后面再说,在配置阶段想全比在运行阶段补救要省力得多。
3. 让机器替人记规矩:T3 的自动化工具链配置
规范文档写得再好,都不如把规范变成 CI 流水线里一道过不过得去的关卡。T3 的自动化分三个层级:编辑器实时约束、提交前强制检查、合并请求双保险。每一个层级的工具选型和配置细节,下面拆开说。
3.1 编辑器与提交钩子:把问题消灭在萌芽
第一道防线是编辑器层面的。我们要求团队统一使用 VS Code,项目的.vscode/settings.json直接提交到仓库。这样任何新成员拉下代码,编辑器的默认配置就是正确的,不需要再手动装插件和调格式。配置里比较关键的是这几个:
{ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "eslint.validate": ["javascript", "typescript", "vue"], "typescript.tsdk": "node_modules/typescript/lib", "files.eol": "\n" }formatOnSave和fixAll.eslint是配套使用的,保存时 Prettier 负责物理格式(缩进、换行、逗号),ESLint 负责逻辑约束(命名、禁止事项、依赖方向)。很多人会在这两步遇到冲突,比如 Prettier 想把一行的属性折行,ESLint 又想合并成单行,吵个不停。我们最后定下来的原则是:格式类交给 Prettier,逻辑类交给 ESLint,两者重叠的规则在 ESLint 里关掉,比如indent、max-len这种容易打架的,全交给 Prettier 就完事。
第二道防线是 Husky + lint-staged 组成的提交钩子。每次git commit的时候,只对暂存区里要提交的文件跑一遍检查和测试,而不是全仓库跑——全仓库跑在老项目上动辄几分钟,开发者等不起,就会想办法绕过钩子。lint-staged配置可以参考:
{ "lint-staged": { "*.{js,ts,vue}": ["eslint --fix", "prettier --write"], "*.{js,ts}": ["node scripts/check-type.mjs"] } }特别说明一下check-type.mjs这个脚本。我们并不是每次提交都跑全量tsc,因为老项目动一次全量类型检查要十几秒到几十秒,很影响体验。check-type只对当前改动文件做增量检查,真要全量验证,等到推 MR 的时候让 CI 去跑。非要全量的同学可以试一次提交十次等一分钟的感觉,测完就理解我们为什么做这个折中了。
3.2 核心配置:ESLint、Prettier 与 TypeScript 的三方协同
三方协同这里要展开讲讲。ESLint 负责逻辑正确性,Prettier 负责审美一致性,TypeScript 负责类型安全,三者的边界划分清楚了才不会互相使绊子。
我们的.eslintrc是扁平化配置(Flat Config),如果你还在用老式的.eslintrc.js格式,新版本 ESLint 已经默认为 Flat Config 了,这里直接给新格式的例子:
// eslint.config.js import t3 from '@t3code/eslint-config'; export default t3([ { files: ['**/*.ts'], rules: { '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/explicit-module-boundary-types': 'off' } }, { files: ['**/*.vue'], rules: { 'vue/multi-word-component-names': 'off' } } ]);有几个规则是我们经过实际项目体验后才加入的,特别值得提:第一个是@typescript-eslint/no-explicit-any,把any直接禁掉。刚推行时研发抱怨很多,说有类型不好写,但坚持下来之后,线上 bug 率确实降了一个量级,因为很多any背后藏着的都是运行时才炸的类型隐患。如果你觉得全禁太激进,可以退一步改成warn,但不要完全放开。
第二个是vue/multi-word-component-names,这个规则要求组件名至少两个单词。为什么?单单词组件名在 HTML 里容易和原生元素冲突,而且在 Vue 内部注册时也可能产生命名歧义。但对某些页面级入口组件,我们确实想用单名,所以上面配置里对**/*.vue把这规则关掉了——这是有意的取舍,不是偷懒。
3.3 CI 流水线:图片般的铁门槛
本地约束做得再好,也架不住有人git push --no-verify或者本地 hook 没装上。所以 MR 合并之前的 CI 检查必须是硬性的,和本地完全是两套独立控制。我们 Jenkins 流水线里 T3 相关的就四步:
第一步是npm run lint,全量跑 ESLint,零警告通过才行,有个 warning 都不行——这是 T3 和其它方案很不一样的地方。很多人觉得 warning 无所谓,但实践证明,只要允许 warning 存在,三天后 warning 数量就能超过 error。
第二步是npm run typecheck,全量 TypeScript 类型检查。这个在本地可能因为缓存问题检查不全面,CI 上是用tsc --noEmit干净地跑一遍。
第三步是npm run test:unit,跑核心模块的单元测试。这里我们用了--changedSince参数做增量测试,但对core/目录是强制全量跑的——因为领域核心一旦炸了,影响的是所有上层模块。
第四步是npm run build,构建产物并验证能否正常产出。这一步能拦截一类特别隐蔽的 CI-only 问题:本地因为路径大小写不敏感能编译过,Linux CI 上却因为文件名大小写不一致而失败。
四步全部绿了,MR 才允许合入。从时间和成本角度看,全量 CI 跑一趟大概四到六分钟,对于核心仓库来说是可接受的。我们的经验是:宁可等这五分钟,也不要让坏味道通过代码评审慢慢扩散。
4. 老项目迁移到 t3code:一条不那么痛的改造路径
光讲新项目怎么按 T3 做其实意义有限,因为我们绝大多数人都不是在空地上建房子,而是要在已经住了人的老房子里装修。T3 能不能在老项目上落地,才是它真正价值的体现。这一节把我们的迁移路径和踩坑记录完整走一遍。
4.1 迁移前的摸底:用事实数据说服团队
迁移这种事最怕的不是技术难点,而是团队阻力。很多人一听"重构"两个字就条件反射地紧张,觉得要伤筋动骨甚至推翻重来。为了减少这种阻力,我给自己的原则是:用数据说话,不搞大动作。
第一步做个代码健康度盘点。用一个脚本扫描仓库,统计这些指标:文件命名风格分布、目录层数分布、模块间的循环依赖数量、any出现的次数、单个文件超过 500 行的数量。把这些数据列成一张表,往周会上一放,比任何苦口婆心的建议都管用。我们的仓库当时的情况是:模块间循环依赖有 37 处,单个文件上千行的有 19 个,any出现了 4000 多次。激烈讨论十分钟后,大家一致认为这事确实得治。
第二步是圈定一个试点模块,不要上来就全仓库铺开。我们选的是一个规模适中、重要程度中等、但依赖关系最混乱的模块。先拿它做第一个炮灰,跑通整个迁移流程。这个试点项目最大的作用是建立大家对 T3 的信任——别人看到改造后代码结构确实清楚了,debug 时间确实短了,潜在阻力就自然而然消解了。
4.2 渐进式迁移策略:护城河先挖、建筑后盖
试点过程中我们摸索出的核心策略可以总结为四句话:依赖方向先理清,目录结构后调整,文件命名稳推进,工具链约束最后上。
具体操作上分四个阶段走:
阶段一:打底。先把 ESLint 和 Prettier 的配置全部切到 T3 标准,但先把大部分规则设成 warn,只有明确了必须整改的条目设成 error。重点加入no-restricted-imports规则,把跨层依赖方向先拦住。这个阶段代码不用大改,只是先定好齿轮的齿形。
阶段二:拆循环依赖。把盘点时列出来的循环依赖一个个处理掉。处理手段不一定是重写逻辑,有时候只是把共同依赖的代码抽到shared/或core/里,就能解开死结。这个阶段每处理一个循环依赖,就用 madge 工具重新生成依赖图来看效果。依赖图从一团乱麻变成基本清晰的时候,后面的结构调整才有了可能。
阶段三:目录迁移。按 T3 的分层把现有代码对号入座。这个过程最花时间,但是机械性最强。我们的做法是一个模块一个模块地搬,每搬完一个模块就运行增量测试和类型检查。特别注意:不要在搬目录的同时改逻辑,那是自己给自己挖坑。纯移动文件 + 修改 import 路径的行为是可以用git mv完成的,改完路径后 diff 里只有路径变更,reviewer 很容易确认没有引入新 bug。
阶段四:标准收口。把 warn 规则逐步升级为 error,并把新的命名规则、导出规则全部打开。这一步不一定等前三个阶段全完成才做,可以对已经改造完成的模块单独开启。开启后如果出现报错,就集中修一波,修完再开启下一个模块。
4.3 迁移中最容易翻车的三个操作细节
老项目迁移的坑很多是隐藏在水面下的,这里列三个我们在实操中付出过代价的细节,供你参考。
第一个是Windows 和 macOS 文件名大小写问题。我们有一个同事在 macOS 上把UserCard.vue重命名成了userCard.vue,本地一切正常,推到 CI(Linux 环境)上直接构建失败。原因是 macOS 文件系统默认大小写不敏感,而 Linux 完全敏感,Git 虽然能记录重命名,但本地对大小写变更常常不以为然。建议迁移期间在 CI 上显式加入一句:
git ls-files | grep -E '([A-Z])' | grep -v -E '^\.'发现异常大写的文件直接爆红,从源头上避免这类问题。
第二个是修改 import 路径时用全局替换的连带误伤。我们用 codemod 自动替换 import 路径时,有一批路径带有相似前缀,比如@/utils/date和@/utils/date-format,全局替换@/utils/date前缀时把所有date-format的路径也改了。好在有类型检查兜底,跑一次tsc立刻暴露了四十几处错误,没有让污染进入主干。此后我们定了一条规矩:凡是路径替换类的 codemod,跑完必须立刻全量类型检查,不允许分批次慢慢验。
第三个是测试覆盖率在迁移期必须承担回归安全网的角色。迁移过程中一旦出现"模块 A 依赖模块 B,但 B 正在被搬到新位置"的局面,运行时错误是最难排查的。T3 的做法是迁移期间强制要求核心模块的行覆盖率不低于 70%,低于就回退或者补测试再动。这个红线非常有用,它能保证你每次搬移后快速发现问题,而不是等到上线那天才在用户端暴露。
5. 条条框框之外:T3 的灰度、例外与持续演进
如果读者觉得 t3code 是一堆规则和限制的集合,那是我的表达失败了。任何规范体系都必须保留灰度、例外和演进机制,否则它就是僵化的。这一节专门讲讲规则之外的东西。
5.1 灰度机制:让团队在适应期不被规则淹没
直接全员铺开所有规则,会让团队产生强烈反弹,这是我们第一次试点得到的教训。T3 后来设计了三档规则类型,按灰度递进投放:
第一档是"指导档",以 warn 形式出现,告诉开发者这里不规范但暂时容忍,比如一些老文件的命名风格。第二档是"强制档",以 error 形式出现,阻断提交与合入,比如any的使用、跨层 import。第三档是"愿景档",连 error 都不设,只是作为文档和示例存在,比如未来计划引入的废弃技术栈替换方案。
这个分档机制要配合时间表使用。我们是按季度推进的:第一个季度把指导档里的内容逐步升级为强制档,给每个模块设定升级时间节点。开发者看到规范不是一次性压下来的,而是有条有理地一批批消化,心理接受度会高很多。
5.2 合理例外:哪些场景允许破坏规则
制定规则的难度不在于列出"必须做什么",而在于明确"什么情况下可以不做"。T3 对例外有一套透明流程,而不是靠特批。
目前允许的例外场景有这几类:第一类是应急修复。线上出了 P0 故障,需要马上改一个文件上线,如果改完为了满足某个命名规则导致要连带改另外三个文件,那这次就破例。流程是:提交时可以带[T3-SKIP-LINT]标记,但必须同步创建一个 issue,48 小时内补上对应改造。我们做过统计,实际发生的破例次数很少,但这条逃生通道的存在本身就让团队安心不少。
第二类是框架层硬限制。比如第三方库要求特定文件名或导出格式,这种因为外部接入的约束,T3 不做强行纠正。比如某些组件库的 theme 文件要求必须叫index.js,你说我非要改成theme.js那纯粹是自找矛盾。
第三类是迁移期过渡。老项目里那些体量极大、当前无法安全拆分的巨石文件,原则是"先冻结再优化",不要求一次性破解。冻结是指不许在这个文件里继续堆新业务了,新代码一律写到新目录去;优化则放到专项排期里慢慢拆。这样既守住 T3 的推进节奏,也不让团队过度背负历史包袱。
5.3 规范的进化:每半年做一次规则体检
规则体系最怕的就是划地为牢,明明是环境变了,规则还死守着一万年不动。团队刚开始执行 T3 的那段时间,新成员经常会提出类似"为什么这个工具函数要放在shared/而不是core/"的疑问,一开始我们都按既有约定解释,但问的人多了,就说明这些约定本身存在需要重新审视的地方。
T3 从推行之初就约定了一个机制:每半年做一次规则体检,由每个模块的技术负责人提交"规则增删改提案"。提案要写清楚背景、影响面、预期收益,然后放到技术委员会投票决定。过去一年里做三次体检,增补了四条规则(比如禁止在app/层直接 new 一个core/里定义的不可变对象,必须走工厂函数),也撤掉了两条规则(比如当时为了调试方便加的console.log检查,现在调试工具成熟了就没必要强行限制)。
这个体检机制本身的意义比具体规则内容更重要——它让所有人知道,t3code 不是一个已经死掉的教条,而是持续生长的工程共识。团队对规范的认同感,恰恰是在这种"可以商量、可以改进"的氛围里建立起来的。
6. 落地 t3code 之后:代码质量的可量化改善与团队体感
花了这么大篇幅讲方法论,最后用我们自己的数据来验证一下这套东西到底有没有用。毕竟没有量化结果的工程实践,和自嗨没什么区别。
从全仓库维度看,T3 推行一个季度后,核心指标变化如下:
| 指标 | 推行前 | 一个季度后 | 变化幅度 |
|---|---|---|---|
| 模块间循环依赖 | 37 处 | 9 处 | 下降 75.7% |
any类型出现次数 | 4600+ | 600+ | 下降约 87% |
| 单文件超 500 行的数量 | 19 个 | 7 个 | 下降 63.2% |
| 新代码中命名合规率 | 没有约束 | 99.1% | 从无到有 |
| 核心模块单元测试覆盖率 | 42% | 76% | 提升 34 个百分点 |
这些数字都非常直接。但我想说的是,真正让我觉得 t3code 值得的,是那些数字背后的团队感受变化。
以前新同事入职,光适应代码风格大概要两到三周,期间会不断在代码评审里因为风格问题被 challenge。现在新同事拉下代码,目录结构清晰,导入路径规整,命名风格统一,基本一两天就能上手改 bug。以前跨模块做一个需求,最怕的是改了一个文件带崩另一个不知道在哪的模块,现在因为依赖有向无环,模块边界干净,影响范围变得可以预期,联调效率明显提升。以前代码评审里 40% 的评论都在讨论"风格问题",现在大家可以把精力真正花在"这个逻辑到底对不对"上。
有一个场景我印象很深:一个刚入职两周的应届生,对着 T3 整理过的代码独立找出了一个线上问题根因,定位过程只花了一个下午。放在以前的老代码里,这个定位时间大概率要翻三倍不止。这种生产力上的差距不是某个人的能力差异,而是工程秩序提供的确定性红利。
写在最后
我始终相信,代码治理这件事不是靠一次大扫除就能一劳永逸的,它更像是一套免疫系统,需要持续维护和进化。t3code 是我们在实际业务压力下摸索出来的一套方案,它肯定不是唯一答案,甚至不是最优答案,但它在我们自己的土壤里被验证是有效的。
如果你也想在自己团队里尝试类似的做法,我的建议是:不要照抄全套,先从数据摸底开始,从一个模块试点开始,让团队先尝到秩序带来的甜头,再逐步推开。规则是为了解放生产力,不是为了制造约束,这个初衷时刻不能忘。最后留一个小技巧:无论你用不用 T3 的整套规则,把"保存时自动格式化、提交前自动检查、合入时强制验证"这三道闸门先立起来,你的代码仓库就能立刻变干净不少。