1. 从"t3code"这个标题说起:它到底是什么
第一次看到"t3code"这个词,我脑子里蹦出来的第一反应是——这大概率是个跟代码生成、代码工具链或者某种编码规范相关的东西。原因很简单,"code"这个词在技术圈里指向性太强了,而前面的"t3"要么是版本号,要么是某个项目/团队的代号,要么是某种技术栈的缩写。后来我花了一些时间去梳理这个标题背后的可能性,发现它其实可以对应好几类不同的东西:可能是一个轻量级的代码片段管理工具,可能是一套团队内部的编码规范体系,也可能是某种基于模板的代码生成方案。
不管它具体指向哪一种,核心都绕不开一个词——代码的组织与复用。这也是我决定写这篇东西的原因。在实际开发中,代码复用这件事看起来简单,做起来坑特别多。你可能遇到过这种情况:同一个功能在三个不同的项目里写了三遍,每次都有细微差别,改bug的时候要改三个地方,改漏一个就出线上问题。或者你接手了一个老项目,里面的工具函数散落在各个角落,想找一个日期格式化的方法得翻半天。这些问题本质上都是代码没有做好组织与复用导致的。
"t3code"这个标题吸引我的地方在于,它暗示了一种结构化的、有编号体系的代码管理思路。"t3"可以理解为"tier 3"或者"type 3",也可以理解为某种三层结构。不管是哪种解读,它都指向一个核心问题:如何把代码按照一定的层级和规则组织起来,让它既好找、又好用、还好维护。
这篇文章适合哪些人看?如果你是一个正在带小团队的技术负责人,或者是一个独立开发者,手里维护着多个项目,经常需要在项目之间搬运代码,那这篇内容会对你有帮助。如果你是一个刚入行的开发者,想了解怎么把自己的代码组织得更专业,也能从里面找到可操作的方法。我会从设计思路、核心细节、实操过程、常见问题几个维度展开,尽量把每个环节都讲透。
2. 整体设计思路:为什么要给代码做"分层编号"
2.1 代码复用的三层结构模型
我在实际项目里摸索出来的一套方法,跟"t3"这个命名逻辑不谋而合。我把它叫做三层代码结构:最底层是通用工具层,中间层是业务逻辑层,最上层是应用组装层。这三层之间的依赖关系是单向的——上层可以调用下层,下层不能反向依赖上层。
为什么这么分?因为代码复用的本质是找到变化中的不变。通用工具层放的是那些跟具体业务无关的东西,比如字符串处理、日期计算、数据校验、加密解密。这些东西在任何项目里都能用,变化频率极低。业务逻辑层放的是跟具体业务场景相关的代码,比如订单状态流转、用户权限判断、报表数据组装。这些东西在同一个业务域内可以复用,但跨业务域就不行了。应用组装层是最上层,它负责把下面两层的能力拼装成一个完整的应用,这一层几乎不复用,每个应用都是独立的。
这样分层的好处是,当你需要复用代码的时候,你很清楚应该去哪个层找。需要日期工具就去通用工具层,需要订单相关的逻辑就去业务逻辑层。而不是像以前那样,在一个大杂烩式的utils文件夹里翻来翻去。
2.2 编号体系的设计逻辑
"t3code"里的"3"让我想到的是编号体系。给代码加编号这件事,很多人觉得没必要,觉得文件名已经说明问题了。但我在实际使用中发现,编号能解决一个很关键的问题:排序和定位。
举个例子,假设你的通用工具层里有这些文件:dateUtils、stringUtils、validationUtils、cryptoUtils。按字母排序的话,crypto排第一,date排第二,string排第三,validation排第四。这个顺序跟使用频率、重要性没有任何关系。但如果加上编号:t1-dateUtils、t2-stringUtils、t3-validationUtils、t4-cryptoUtils,那顺序就由你控制了。你可以把最常用的放在前面,把最不常用的放在后面。
更重要的是,编号体系让代码审查和交接变得更容易。当你说"t3code里的第三个模块有问题"的时候,对方能立刻定位到具体位置。而不需要说"就是那个处理校验的文件,名字我记不太清了"。
2.3 为什么不用现成的包管理方案
有人可能会问,为什么不直接用npm、pip、maven这些包管理工具来做代码复用?我的回答是:包管理工具解决的是分发问题,不是组织问题。
包管理工具确实能让你把代码发布出去,然后在其他项目里安装使用。但它有几个限制:第一,发布流程比较重,改一行代码要发一个版本;第二,版本管理复杂,不同项目可能依赖不同版本;第三,对于团队内部的私有代码,搭建私有仓库有额外成本。
而"t3code"这种思路更适合团队内部、项目之间的轻量级复用。你可以直接把代码目录复制过去,也可以用git submodule,也可以用monorepo。方式很灵活,不需要走完整的发布流程。当然,这种方式也有代价,后面我会讲到怎么控制版本一致性的问题。
3. 核心细节解析:每个层级该放什么、不该放什么
3.1 通用工具层的边界划定
通用工具层是最容易膨胀的一层。很多人写着写着,就把什么都往里塞,最后这个层变得无比庞大,依赖关系错综复杂。我在实际项目里总结了几条边界规则:
第一条规则:不依赖任何业务概念。如果一个工具函数的参数或返回值里出现了"订单""用户""商品"这类业务词汇,那它就不属于通用工具层。比如formatOrderId这个函数,虽然看起来是个格式化工具,但它依赖了"订单"这个概念,应该放到业务逻辑层。
第二条规则:不依赖外部服务。通用工具层应该是纯函数或者纯计算,不涉及网络请求、数据库操作、文件读写。一旦涉及这些,就会引入环境依赖,导致这层代码没法在纯前端环境或者测试环境里直接使用。
第三条规则:单一职责,一个文件只做一件事。dateUtils只处理日期,stringUtils只处理字符串,不要搞一个miscUtils出来。我见过太多项目里有个misc或者common文件,里面什么都有,最后谁都不敢改。
按照这三条规则,一个典型的通用工具层大概会有这些模块:
| 模块编号 | 模块名称 | 职责说明 | 典型函数 |
|---|---|---|---|
| t1 | dateUtils | 日期格式化、计算、比较 | formatDate、diffDays、addDays |
| t2 | stringUtils | 字符串处理 | truncate、camelCase、escapeHtml |
| t3 | validationUtils | 数据校验 | isEmail、isPhone、isIdCard |
| t4 | cryptoUtils | 加密解密 | md5、sha256、base64Encode |
| t5 | arrayUtils | 数组操作 | unique、groupBy、flatten |
这个表格里的编号就是"t3code"思路的体现。编号不是随便定的,而是按照使用频率和依赖关系来排的。dateUtils排第一是因为几乎每个项目都要处理日期,stringUtils排第二是因为字符串处理也很常用,validationUtils排第三是因为校验逻辑通常依赖前两个,cryptoUtils排第四是因为加密解密只在特定场景用,arrayUtils排第五是因为现代JavaScript已经有很多原生数组方法了。
3.2 业务逻辑层的组织方式
业务逻辑层比通用工具层复杂得多,因为它跟具体的业务场景绑定。我的做法是按业务域拆分,每个业务域内部再按功能细分。
假设你在做一个电商系统,业务逻辑层可以这样组织:
- b1-order:订单相关
- b1.1-orderStatus:订单状态流转
- b1.2-orderAmount:订单金额计算
- b1.3-orderValidation:订单校验
- b2-user:用户相关
- b2.1-userAuth:用户认证
- b2.2-userProfile:用户资料
- b2.3-userPermission:用户权限
- b3-product:商品相关
- b3.1-productSearch:商品搜索
- b3.2-productStock:商品库存
- b3.3-productPrice:商品价格
这里的编号规则是:b开头代表business,后面的数字代表业务域序号,点号后面的数字代表该业务域内的功能序号。这样命名的好处是,当你看到b2.3的时候,你立刻知道这是用户权限相关的代码,不需要打开文件看内容。
业务逻辑层有一个很重要的原则:业务逻辑层可以调用通用工具层,但通用工具层不能调用业务逻辑层。这个原则保证了依赖的单向性。如果哪天你发现通用工具层里引用了业务逻辑层的代码,那说明你的分层出问题了,需要把那个函数移到业务逻辑层去。
3.3 应用组装层的职责
应用组装层是最上面的一层,它的职责很简单:把下面两层的能力组合起来,形成一个完整的应用。这一层通常不需要复用,因为每个应用的组装方式都不一样。
但这一层也有一个容易犯的错误:把业务逻辑写到组装层里。比如在组装一个订单创建流程的时候,直接在组装层里写了订单金额的计算逻辑。这就导致这个计算逻辑没法被其他应用复用,而且测试起来也很麻烦。
正确的做法是,组装层只负责调用和编排,不负责计算和判断。订单金额怎么算,那是b1.2-orderAmount的事;订单状态怎么流转,那是b1.1-orderStatus的事。组装层只需要按照正确的顺序调用这些模块就行了。
4. 实操过程:从零搭建一套t3code体系
4.1 目录结构的初始化
搭建t3code体系的第一步是确定目录结构。我的建议是在项目根目录下建一个code文件夹,然后在里面按层级建子文件夹。为什么不直接放在src下面?因为src通常放的是应用代码,而t3code体系是跨项目复用的,放在一个独立的目录里更清晰。
目录结构大概长这样:
project-root/ ├── code/ │ ├── t1-dateUtils/ │ │ ├── index.js │ │ └── index.test.js │ ├── t2-stringUtils/ │ │ ├── index.js │ │ └── index.test.js │ ├── t3-validationUtils/ │ │ ├── index.js │ │ └── index.test.js │ ├── b1-order/ │ │ ├── b1.1-orderStatus.js │ │ ├── b1.2-orderAmount.js │ │ └── b1.3-orderValidation.js │ └── b2-user/ │ ├── b2.1-userAuth.js │ ├── b2.2-userProfile.js │ └── b2.3-userPermission.js ├── src/ │ └── app.js └── package.json每个模块文件夹里都有一个index.js作为入口文件,这样引用的时候只需要写require('./code/t1-dateUtils'),不需要写具体的文件名。测试文件跟源码放在一起,方便维护。
4.2 模块的编写规范
写t3code模块的时候,我给自己定了几条规矩,分享出来供参考:
第一条:每个模块只导出一个对象。不要在一个文件里导出多个函数,而是把它们组织成一个对象导出。比如dateUtils模块:
// code/t1-dateUtils/index.js const formatDate = (date, format = 'YYYY-MM-DD') => { // 实现略 }; const diffDays = (date1, date2) => { // 实现略 }; const addDays = (date, days) => { // 实现略 }; module.exports = { formatDate, diffDays, addDays };这样引用的地方可以写成const dateUtils = require('./code/t1-dateUtils'),然后dateUtils.formatDate(...)。比解构引用更清晰,也更容易看出这个函数是从哪个模块来的。
第二条:每个函数都要有注释,说明参数和返回值。注释不用很长,但至少要让人知道这个函数是干什么的。我习惯用JSDoc格式:
/** * 格式化日期 * @param {Date|string|number} date - 要格式化的日期 * @param {string} format - 格式模板,默认YYYY-MM-DD * @returns {string} 格式化后的日期字符串 */ const formatDate = (date, format = 'YYYY-MM-DD') => { // 实现略 };第三条:每个模块都要有测试。测试不需要覆盖所有边界情况,但至少要覆盖正常路径和几个典型的异常路径。测试文件命名为index.test.js,跟index.js放在同一个目录下。
4.3 跨项目复用的三种方式
t3code体系建好之后,怎么在多个项目之间复用?我试过三种方式,各有优劣:
方式一:直接复制。最简单粗暴,把code目录复制到新项目里就行。优点是零配置,缺点是版本不一致——A项目改了代码,B项目不知道。适合项目数量少、变动不频繁的场景。
方式二:git submodule。把code目录做成一个独立的git仓库,然后在各个项目里用submodule引用。优点是版本可控,每个项目可以锁定特定的commit。缺点是操作稍复杂,需要团队成员都熟悉submodule的用法。
方式三:monorepo。把所有项目放在一个仓库里,code目录作为共享包。优点是改一次所有项目都能用,缺点是仓库体积大,CI/CD配置复杂。适合项目数量多、团队规模大的场景。
我个人的选择是:项目少于3个用方式一,3到10个用方式二,10个以上用方式三。这个分界线不是绝对的,但可以作为一个参考。
5. 常见问题与排查技巧实录
5.1 模块循环依赖怎么破
循环依赖是t3code体系里最常见的问题。比如t1-dateUtils引用了t2-stringUtils,t2-stringUtils又引用了t1-dateUtils,这就形成了循环。在Node.js里,循环依赖会导致其中一个模块导出空对象,运行时报undefined错误。
排查循环依赖的方法很简单:画一张依赖关系图。把所有模块和它们之间的引用关系画出来,看看有没有环。如果依赖关系复杂,可以用madge这个工具自动检测:
npx madge --circular code/解决循环依赖的思路有三种:第一种是提取公共部分,把两个模块都依赖的代码抽到一个新的模块里;第二种是延迟引用,在函数内部才require,而不是在文件顶部require;第三种是调整分层,把其中一个模块移到更低的层级。
我个人的经验是,循环依赖往往说明分层没做好。如果t1和t2互相引用,那说明它们之间的边界不清晰,需要重新划分职责。
5.2 版本不一致导致的诡异bug
用复制方式复用代码的时候,最容易遇到版本不一致的问题。A项目里的dateUtils是上周的版本,B项目里的是这周的版本,两个版本的formatDate行为不一样,导致同样的输入在不同项目里输出不同结果。
排查这类问题的方法是:在每个模块的index.js里加一个版本号。
// code/t1-dateUtils/index.js const VERSION = '1.2.0'; module.exports = { VERSION, formatDate, diffDays, addDays };然后在应用启动的时候打印所有模块的版本号,或者写一个检查脚本,对比各个项目的模块版本是否一致。如果发现不一致,就统一更新到最新版本。
5.3 模块膨胀的治理
t3code体系用久了,模块会越来越多,最后变得难以管理。我见过一个项目,通用工具层有50多个模块,很多模块的功能是重叠的,比如dateUtils和timeUtils都在处理日期时间。
治理模块膨胀的方法是定期做模块审计。每个季度花半天时间,把所有模块过一遍,问三个问题:这个模块最近三个月被引用过吗?这个模块的功能跟其他模块有重叠吗?这个模块的职责还清晰吗?
如果某个模块三个月没被引用过,考虑删掉或者归档。如果两个模块功能重叠,考虑合并。如果某个模块的职责变得模糊了,考虑拆分或者重新划分边界。
我自己的项目里,通用工具层控制在15个模块以内,业务逻辑层控制在30个模块以内。超过这个数量,就说明需要做一次审计了。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 运行时报undefined | 循环依赖 | 用madge检测 | 提取公共部分或延迟引用 |
| 同样输入不同输出 | 版本不一致 | 检查各项目模块版本号 | 统一更新到最新版本 |
| 找不到模块 | 路径写错或模块被删 | 检查require路径 | 修正路径或恢复模块 |
| 测试跑不过 | 模块有副作用 | 检查模块是否依赖外部环境 | 把副作用移到应用层 |
| 模块越来越大 | 职责不清晰 | 统计模块代码行数和函数数量 | 拆分模块或重新划分边界 |
6. 我踩过的坑和总结的经验
6.1 不要过度设计
刚开始搞t3code体系的时候,我特别兴奋,恨不得把每个函数都拆成一个独立的模块。结果搞出来一个巨大的模块网络,引用关系复杂得要命,改一个地方要动好几个模块。后来我学乖了,模块的粒度要适中。一个模块里放5到10个相关的函数是比较合适的,太少会导致模块数量爆炸,太多会导致模块职责不清。
6.2 文档比代码更重要
t3code体系的核心价值在于复用,而复用的前提是别人知道你这个模块能干什么。所以文档比代码更重要。我现在的做法是,每个模块的index.js顶部写一段模块说明,包括这个模块的职责、典型用法、注意事项。然后在项目根目录下维护一个MODULES.md,列出所有模块的编号、名称、职责和负责人。
6.3 测试是复用的保险
一个没有测试的模块,别人是不敢用的。因为你不知道改了之后会不会出问题。所以每个要复用的模块都必须有测试。测试不需要很复杂,但至少要覆盖正常路径。我通常用Jest来写测试,配置简单,跑得快。
6.4 定期重构是必须的
t3code体系不是建好就一劳永逸的。业务在变,代码也要跟着变。我每个季度会花半天时间做一次模块审计,看看有没有需要合并、拆分、删除的模块。这个过程有点像整理衣柜,把不穿的捐掉,把过季的收起来,把常穿的放在顺手的位置。
6.5 团队共识比工具重要
最后说一点,t3code体系能不能跑起来,关键不在于工具多先进,而在于团队有没有共识。如果只有你一个人在用这套体系,其他人还是各写各的,那这套体系就形同虚设。所以推行t3code体系的时候,一定要先跟团队达成共识,明确规则,然后大家一起遵守。我自己的做法是先在一个小项目里试点,跑通了再推广到其他项目。
这套东西我用了大概两年多,从最开始的三五个模块,到现在几十个模块,中间踩了不少坑,也总结了一些经验。如果你也在做类似的事情,希望这些内容能帮你少走一些弯路。有什么问题或者想法,欢迎一起交流。