t3code三层架构实战:轻量代码复用与分层约定避坑指南 1. 项目缘起一个被名字耽误的代码工具第一次看到t3code这个名字我下意识以为是某个新出的终端工具或者编辑器插件。翻了一圈社区讨论和零散的项目描述之后才反应过来这其实是一个挺有意思的东西——它把代码和极简三层结构这两个概念揉在了一起核心思路是用一套轻量的约定把项目里那些重复、琐碎、容易写错的代码片段收敛成可复用、可组合的模块。说白了t3code 想解决的问题很朴素同一个项目里同一类逻辑被反复手写改一处漏三处。它不追求大而全的框架也不搞复杂的抽象层而是用三层这个约束逼着开发者把代码拆成职责清晰的三个部分。这个思路听起来简单但真正落地的时候坑比想象中多。我拿它在一个中小型项目里跑了两周从最初的这不就是换皮模板到后来确实省了不少重复劳动中间踩的坑、改的配置、总结出来的经验都在这篇里了。如果你手头有那种业务逻辑不算复杂但重复度极高的项目或者你正在找一个轻量的代码组织方案这篇应该能帮你少走点弯路。哪怕你只是好奇三层到底怎么分、为什么这么分往下看也有收获。2. 核心设计思路为什么是三层而不是两层或四层2.1 三层结构的本质职责隔离而非物理分层很多人一听到三层第一反应是 MVC、是 Controller-Service-DAO 那一套。t3code 的三层跟那个不是一回事。它的三层更偏向代码生成和复用视角的划分我把它理解为定义层、组合层、消费层。定义层放的是最原子的东西——数据结构、常量、类型声明、基础工具函数。这一层的特点是没有依赖或者说只依赖语言本身和极少数基础库。组合层把定义层的东西拼起来形成有业务含义的单元。比如一个订单金额计算函数它依赖定义层里的金额类型和税率常量但自己不关心这些值从哪来。消费层真正被外部调用的入口。API 路由、页面组件、命令行入口都属于这一层。它负责把组合层的能力暴露出去同时处理输入输出、错误边界。为什么是三层而不是两层我试过把定义层和组合层合并结果就是工具函数和业务函数混在一起找东西全靠搜索。也试过拆成四层多出来的那层很快就变成了什么都往里塞的垃圾桶。三层刚好卡在一个平衡点上足够区分职责又不至于让人纠结某个函数该放哪。2.2 与常见方案的对比它到底省了什么方案重复代码处理学习成本适合场景纯手写全靠复制粘贴低一次性脚本重型框架靠框架约定高大型团队协作代码模板靠模板引擎中固定模式生成t3code靠三层约定片段复用低到中中小项目、重复逻辑多t3code 的定位很明确它不解决架构问题解决的是同一类代码写太多遍的问题。比如你项目里有十几个类似的表单校验、二十几个类似的 API 响应封装t3code 的思路是让你把这些共性抽到定义层和组合层消费层只写差异部分。2.3 一个容易被忽略的设计取舍约定优于配置t3code 没有配置文件。这是它最反直觉也最容易被吐槽的地方。我一开始也不适应觉得连个 config 都没有怎么管。但用下来发现没有配置反而逼着你遵守目录约定定义层就放defs/组合层就放compose/消费层就放entry/。目录即配置。这个取舍的好处是新人进来不用读一堆配置文档看目录结构就知道代码怎么组织的。坏处是如果你的项目结构跟约定不一致要么改项目要么改约定没有中间地带。我的建议是新项目直接按约定来老项目先局部试点别一上来就全量迁移。3. 核心细节拆解三层各自该放什么、不该放什么3.1 定义层只放不会变的东西定义层的判断标准很简单这个东西在未来半年内大概率不会因为业务需求变化而修改。类型声明、枚举、常量、纯函数工具都符合这个标准。反过来任何涉及业务规则的东西都不该放这里。我踩过的一个坑把税率放进了定义层结果业务调整税率的时候定义层被改动了导致所有依赖它的组合层都要重新验证。后来我把税率挪到了组合层的一个配置模块里定义层只保留税率类型这个声明。定义层应该是稳定的一旦它开始频繁变动说明你的分层出问题了。// defs/money.ts —— 定义层示例 export type Currency CNY | USD; export interface Money { amount: number; currency: Currency; } export const CURRENCY_SYMBOL: RecordCurrency, string { CNY: ¥, USD: $, };注意这里没有任何计算逻辑只有类型和映射。计算逻辑属于组合层。3.2 组合层业务逻辑的主战场组合层是三层里最厚的一层也是最能体现 t3code 价值的地方。它的核心任务是把定义层的原子能力组合成有业务含义的单元。一个组合层函数应该满足输入输出明确、不依赖外部状态、可单独测试。我习惯给组合层的每个函数写一句注释格式是做什么 依赖什么 返回什么。比如// compose/order.ts // 计算订单总价依赖 Money 类型和税率常量返回含税总价 export function calcOrderTotal(items: Money[], taxRate: number): Money { const subtotal items.reduce((sum, item) sum item.amount, 0); return { amount: subtotal * (1 taxRate), currency: items[0]?.currency ?? CNY }; }这里有个细节taxRate是参数而不是从定义层直接读的。为什么因为组合层不应该知道税率从哪来它只负责算。税率从数据库读还是从配置读那是消费层的事。这个边界一旦模糊组合层就会变成什么都管的泥潭。3.3 消费层薄到极致只做三件事消费层最容易写胖。很多人写着写着就把业务逻辑塞进来了结果组合层形同虚设。我的经验是消费层只做三件事取输入、调组合层、返输出。任何超过十行的消费层函数都值得怀疑是不是该往下沉。// entry/api.ts import { calcOrderTotal } from ../compose/order; import { getTaxRate } from ../compose/config; export async function handleOrderTotal(req: Request) { const items await req.json(); const taxRate await getTaxRate(); return Response.json(calcOrderTotal(items, taxRate)); }你看消费层没有任何计算只是把取税率和算总价串起来。这样改业务规则的时候只需要动组合层消费层纹丝不动。3.4 三层之间的依赖方向单向且不可逆这是 t3code 最硬的规则消费层可以依赖组合层组合层可以依赖定义层反过来绝对不行。定义层不能 import 组合层组合层不能 import 消费层。一旦出现反向依赖整个结构就塌了。我见过有人为了方便在定义层里直接调了一个组合层的函数结果就是循环依赖构建工具报错排查了半天。这条规则没有例外如果觉得需要反向依赖说明你的分层划分有问题该调整的是划分方式不是规则本身。4. 实操落地从零搭一个 t3code 风格的项目4.1 目录结构约定就是一切t3code 没有脚手架目录得自己建。我用的结构是这样的project/ ├── defs/ # 定义层 │ ├── types.ts │ ├── constants.ts │ └── utils.ts ├── compose/ # 组合层 │ ├── order.ts │ ├── user.ts │ └── config.ts ├── entry/ # 消费层 │ ├── api.ts │ └── cli.ts └── tests/ ├── compose/ └── defs/注意tests/只测定义层和组合层消费层不写单测它太薄了测它性价比低。这个结构我用了两周最大的感受是找文件不用想要改类型去 defs要改逻辑去 compose要改入口去 entry。4.2 参数计算实例税率与折扣的叠加光说结构太虚拿一个真实场景走一遍。假设要做订单总价 商品小计 × (1 税率) - 折扣折扣规则是满 100 减 10。定义层先声明类型和常量// defs/order.ts export interface OrderItem { price: number; quantity: number; } export const DISCOUNT_THRESHOLD 100; export const DISCOUNT_AMOUNT 10;组合层写计算逻辑注意这里把满减和含税拆成两个函数而不是揉在一起// compose/order.ts import { OrderItem, DISCOUNT_THRESHOLD, DISCOUNT_AMOUNT } from ../defs/order; export function calcSubtotal(items: OrderItem[]): number { return items.reduce((sum, item) sum item.price * item.quantity, 0); } export function applyDiscount(subtotal: number): number { return subtotal DISCOUNT_THRESHOLD ? subtotal - DISCOUNT_AMOUNT : subtotal; } export function applyTax(amount: number, taxRate: number): number { return amount * (1 taxRate); }消费层串起来// entry/checkout.ts import { calcSubtotal, applyDiscount, applyTax } from ../compose/order; export function checkout(items: OrderItem[], taxRate: number): number { const subtotal calcSubtotal(items); const discounted applyDiscount(subtotal); return applyTax(discounted, taxRate); }这个拆法的好处是折扣规则变了只改 applyDiscount税率逻辑变了只改 applyTax互不影响。我试过把它们写成一个函数结果每次改规则都要重新读一遍整个函数生怕改错地方。4.3 实操现场记录迁移一个老模块我拿一个老项目里的用户权限校验模块做了迁移。原代码是一个 200 多行的文件校验逻辑、常量、入口混在一起。迁移步骤先把所有常量角色列表、权限映射抽到defs/permission.ts。再把纯校验逻辑这个角色有没有这个权限抽到compose/permission.ts。最后把原来的入口函数改成只调组合层放到entry/auth.ts。迁移完原文件从 200 行降到 30 行而且组合层的校验函数可以单独测试了。迁移过程中最大的坑是老代码里有隐式的全局状态比如某个校验依赖了一个全局变量。这种必须先把它显式化成参数否则组合层没法做到无副作用。5. 常见问题与排查技巧实录5.1 三层划分争议速查表症状可能原因处理方式定义层频繁改动业务规则混入定义层把规则下沉到组合层组合层函数太长职责没拆干净按输入-处理-输出再拆消费层超过 20 行逻辑没下沉把计算部分移到组合层出现循环依赖反向 import检查依赖方向调整划分不知道某函数放哪职责边界模糊问自己它依赖谁、被谁依赖5.2 独家避坑技巧技巧一定义层禁止 import 任何东西。这条我执行得最严。一旦定义层开始 import它就不再稳定了。如果确实需要外部类型用类型声明的方式引入而不是直接 import 实现。技巧二组合层函数尽量纯。纯函数好测试、好复用、好推理。如果某个组合层函数必须访问外部资源数据库、文件把它拆成取数据和算数据两部分取数据放消费层算数据留组合层。技巧三消费层不写 if-else 业务分支。消费层的 if-else 只应该处理输入格式不对这类边界业务分支一律下沉。我见过消费层里写了五层嵌套 if 的那基本等于没分层。注意t3code 的约定不是银弹。如果你的项目本身就是一个脚本干一件事硬套三层只会增加负担。它的价值在重复逻辑多、需要长期维护的场景里才体现得出来。5.3 一个真实的排查案例有次构建报错提示循环依赖。我查了半天发现是compose/config.ts里 import 了entry/env.ts的一个常量。这就是典型的反向依赖。解决方式很简单把那个常量挪到defs/constants.ts两边都从定义层取。排查循环依赖的通用思路是从报错文件出发顺着 import 链往上找第一个跨层反向的 import 就是元凶。6. 工具选型与扩展t3code 之外还能怎么玩6.1 配套工具的选择逻辑t3code 本身不管构建、不管测试所以工具得自己配。我的选择逻辑是构建工具选快的测试工具选简单的lint 工具选能强制目录约定的。构建Vite 或 esbuild启动快配置少。测试Vitest跟 Vite 同源配置成本低。LintESLint 加一条import/no-restricted-paths规则强制定义层不能 import 组合层这类约束。这条 lint 规则是我后来加的加完之后反向依赖的问题基本绝迹了。配置大概长这样// .eslintrc.js rules: { import/no-restricted-paths: [error, { zones: [ { target: ./defs, from: ./compose }, { target: ./defs, from: ./entry }, { target: ./compose, from: ./entry }, ], }], }6.2 后续可以怎么扩展三层结构稳定之后我试过两个扩展方向。一个是在定义层加版本标记给每个类型和常量标注引入版本方便追踪变更。另一个是在组合层加能力标签比如给函数打上pure、async标签消费层可以根据标签做不同的错误处理。这两个扩展都还在试验阶段但思路是通的三层是骨架扩展是血肉骨架稳了血肉怎么长都不会乱。我个人在实际操作中的体会是t3code 最大的价值不是它提供了什么新东西而是它用三层这个简单约束把代码该放哪这个老问题变得可回答了。它不完美遇到复杂项目也会有力不从心的时候但对于那些重复逻辑多、团队规模不大、又不想上重型框架的场景它确实是个务实的选择。最后分享一个小技巧刚开始用的时候别急着全量迁移先拿一个模块试点跑顺了再推广这样踩坑的成本最低。