1. 项目概述:先弄明白 t3code 到底在干什么
t3code 是我在梳理一个横跨两个团队、五千多个文件的订单中台项目时随手起的代号。当时手里的代码库已经乱了整整三个迭代,没人说得清哪些模块是核心业务、哪些是试验性功能、哪些早就没人调用。我试过直接重构,结果拆到一半发现有个“工具函数”被十几个文件依赖,谁也不敢动;我也试过让团队成员各自整理自己负责的模块,结果整理完反而比以前更乱。折腾到最后,我沉淀下来的就是 t3code 这套三步走的工作流:Tag(打标签)、Trim(裁剪)、Transfer(转移)。
它不是框架,不是工具库,代码里找不到任何 t3code 相关的依赖,它本质上是一套“代码收纳术”。解决的核心问题也很朴素:代码不是写出来的,是长出来的,长着长着就分不清哪根枝干什么用了。t3code 要做的就是先看清这棵树长成了什么样,再修剪掉枯枝,最后把果子按位置归好,让后来的人能站在树底下抬头就知道哪个枝头该摘哪个果。
这套方法解决的是我在项目里频繁踩到的三个痛点:一是需求交接时说不清楚模块边界,新同事光熟悉代码就要两周;二是想重构却不敢下手,因为改一行代码会牵出一堆隐式依赖;三是 AI 辅助编程越来越普遍,但给 AI 描述“帮我改这块逻辑”时,自己也说不清“这块”到底是哪块。适合用它的人群包括:正在维护老项目但不知道从哪下手的开发者、准备把混乱模块拆分成微服务的团队、需要给新同学建立代码地图的技术负责人,以及想让 AI 更听话地改代码的人。下面我把这套方法的细节、实操过程和踩过的坑一次讲清楚。
1.1 名字怎么来的:三个 T 各指什么
t3code 的 t3 不是某个版本号,而是三个 T 开头的英文单词组合:Tag、Trim、Transfer。这三个词对应代码整理中三个完全不同性质的动作,我刻意把它们压缩到一个代号里,方便团队内部沟通时快速对齐。
| 阶段 | 核心动作 | 要解决的问题 | 主要产出物 |
|---|---|---|---|
| Tag | 给代码块打职责标签 | 这段代码到底在干什么?属于业务规则还是数据访问? | 注释标记、依赖草图、模块地图 |
| Trim | 裁剪冗余与死代码 | 哪些代码可以删?哪些逻辑重复了? | 精简后的函数、删除清单 |
| Transfer | 将代码转移到正确位置 | 这段代码应该住在哪个模块、哪一层? | 文件目录调整、依赖方向修正 |
Tag 阶段只动眼睛不动刀,任务是“读明白”;Trim 阶段开始动刀,任务是“删干净”;Transfer 阶段动筋骨,任务是“搬到位”。三个阶段风险递增,收益也递增。后面还会详细展开每一步的操作细节,这里先记住一个原则:顺序不能乱。
1.2 这套方法适合什么场景,不适合什么场景
明确边界很重要,t3code 不是万能药。我在实际使用中总结了一套判断标准:如果你手头的代码符合下面任意一条,t3code 能帮上大忙。
- 模块之间经常出现复制粘贴的相似逻辑,但没人确定能不能合并
- 某个函数动辄一两百行,if 嵌套四五层,注释比代码还多
- 你想给某个模块做重构,但一查调用链发现根本理不清有多少地方依赖它
- 团队新成员入职后对代码结构完全没有头绪,只能靠老同事口口相传
- 给 AI 发需求时描述不清“项目的现有结构”,导致生成的代码风格和现有工程完全脱节
反过来,这些场景就不太适合用 t3code:一次性跑完就删的脚本、纯探索性原型、没有测试覆盖且马上要上线的紧急修复。原因很简单,t3code 的第一步 Tag 需要花时间读代码,如果这段代码生命周期只有几个小时,投入产出比太低;紧急修复时先动刀风险太大。记住这一点,能帮你省下很多不必要的纠结。
2. 整体设计与思路拆解:为什么必须是 Tag、Trim、Transfer
很多人在听到 t3code 的三步走之后,第一反应都是“这不就是重构吗,老生常谈了”。说实话,我第一次整理这套方法时也是这么想的,但真正执行了几轮之后我才发现,t3code 的重点不是“怎么重构”,而是“重构前必须按什么顺序做哪些准备”。顺序本身就是最大的设计,这就是它和其他重构手法的根本区别。
2.1 顺序本身就是最大的设计:为什么不能先删再搬
我先用一个生活里的例子解释顺序问题。假设你要收拾一间堆满旧物的房间,房间里有些东西要扔,有些要搬到另一个房间,还有些要重新归位。如果进屋直接开始扔,大概率会把要搬走的东西一起扔掉;如果直接开始搬,屋里就会又多出一堆本来已经不需要的杂物。正确做法一定是先站在门口把每个角落看一遍,心里有张清单,再动手扔,最后重新布置。t3code 的三个阶段就是对照这个流程设计的。
先做 Tag,是因为它零风险:你只是读代码、标注代码、记录依赖关系,不改任何行为。这一步的主要目标是建立“代码地图”,搞清楚每一段代码在系统里扮演什么角色。拿到地图之后再做 Trim,你删除代码时就知道哪些是真正的枯枝,哪些是虽然难看但还挂在大树主干上的关节。跳过 Tag 直接 Trim 的后果我吃过不少次亏:看某个函数觉得变量多余,删掉之后一路报错,追根溯源才发现那个变量被一个不起眼的闭包引用着。
Trim 之后再 Transfer,也是强逻辑关系。包含大量死代码、重复逻辑的模块直接搬到新位置,等于把垃圾和家具一起搬进新家,只会让新结构从诞生那天就开始腐烂。而且 Transfer 阶段最容易引发循环依赖和隐性依赖断裂,如果代码里还留着大量没删干净的历史包袱,排查起来会雪上加霜。所以我的经验是:宁可前两个阶段多花时间,也绝不让脏代码转移到新容器里。
2.2 三个阶段各自的边界、风险和验收标准
每个阶段怎么算结束,需要有明确验收标准,否则团队执行时会变成“你觉得好了我觉得还差得远”。我整理过一张对照表,一直贴在我们项目文档里,这里也分享出来。
| 阶段 | 输入条件 | 主要风险 | 验收标准 |
|---|---|---|---|
| Tag | 一段待整理的代码模块 | 标注人自身理解有误,导致后续方向错误 | 每个函数都能用一句话说明职责;依赖关系图画完且能讲给同事听 |
| Trim | 带完整职责标注的代码模块 | 误删运行所需的逻辑,破坏行为 | 功能测试全部通过;删除的代码都能在 git 历史里找到 |
| Transfer | 已精简并确认职责的模块 | 模块循环依赖、调用链断裂、配置遗漏 | 新目录结构清晰;编译构建通过;集成测试通过;依赖方向无环 |
每个阶段的边界一旦确立,就不要来回横跳。比如 Tag 阶段发现某个函数显然要删,手痒顺手删了,那就等于同时做了两个阶段的事,一旦出问题很难定位是哪一步引入的 bug。我给自己定的规矩是:Tag 阶段只允许加注释、画图,连变量名都尽量不改。
2.3 它不是替代重构,而是重构前的“地图绘制与火力侦察”
经典的重构书里讲过很多具体手法:提取函数、搬移函数、拆分模块、以查询取代派生变量等等。t3code 并不排斥这些手法,反而是它们的前置步骤。打个比方,重构手法是外科手术的刀法,t3code 则是手术前的影像检查和术前方案。
在实践中,尤其是在老项目里,最大的障碍往往不是不知道怎么提取函数,而是不知道哪些函数能安全提取、提取之后放哪。t3code 通过 Tag 阶段把代码的职责边界画清楚,通过 Trim 阶段把干扰项去掉,到了 Transfer 阶段,你可以直接用教科书里的经典重构手法来完成搬家,试错成本会大幅下降。
另外还有个容易被忽略的点:t3code 里的“代码”不限于编程语言写的文件,也适用于配置、数据库脚本、文档里的接口规范,甚至 CI 流程里的步骤。因为这套方法的内核是“认知整理”,跟具体语言和载体无关。后面我会单独讲它的扩展用法。
3. 实操过程:用 t3code 整理一段订单处理函数
理论说再多,不如直接拿一段真实代码走一遍流程。下面这个例子是我从一个内部项目中脱敏后简化出来的,它集中了我见过的大部分“脏代码病”。
3.1 从一段“典型脏代码”开始:又臭又长又不敢动
这是重构前的代码,一个订单处理函数。注意看它的结构:数据访问、业务规则、副作用、日志埋点全塞在一个函数里,甚至连优惠券的使用状态都在计算阶段顺手改了,这其实给后面埋了一个大雷。
// order-handler.js async function handleOrder(order, user) { // 1. 获取商品信息 const products = []; for (const item of order.items) { const product = await db.findProductById(item.productId); products.push(product); } // 2. 校验库存 for (let i = 0; i < products.length; i++) { const product = products[i]; const item = order.items[i]; if (product.stock < item.count) { throw new Error('库存不足'); } } // 3. 计算优惠 let discount = 0; if (order.couponId) { const coupon = await db.findCouponById(order.couponId); discount = coupon.amount; // 这里顺带更新了coupon使用状态,其实应该放到支付完成之后再做的 await db.markCouponUsed(order.couponId); } // 4. 抵扣用户余额 let finalPrice = computeTotalPrice(order.items, products, discount); if (user.wallet < finalPrice) { throw new Error('余额不足'); } user.wallet -= finalPrice; await db.updateUserWallet(user.id, user.wallet); // 5. 扣减库存 for (const item of order.items) { await db.decreaseStock(item.productId, item.count); } // 6. 保存订单 const orderDoc = { userId: user.id, items: order.items, totalPrice: finalPrice, discount, status: 'paid', createTime: Date.now() }; const saved = await db.createOrder(orderDoc); // 7. 发通知 await sendSms(user.phone, '下单成功'); // 8. 写日志 + 埋点 logger.info('order created', { orderId: saved.id }); track('order_created', { userId: user.id, price: finalPrice }); return saved; }这段代码存在的问题很多,但最致命的是职责没有边界:如果产品经理说“下单时改成不发短信,只在用户手动触发时发送”,你根本找不到一个清晰的扩展点;如果运营说“优惠券状态需要在支付回调里再更新”,你又得在多个地方同步改。我曾见过类似代码流转到第三个维护者手里时,已经没人敢碰了,因为每个看似独立的部分都耦合着别的状态。
3.2 第一遍 Tag:不着急删,先把“身份标”贴明白
Tag 阶段我的做法是打开编辑器,把函数从头到尾读一遍,在每一段逻辑开头加注释,标明这段的职责类型。注意,只加注释和画图,不碰代码。
// order-handler.js async function handleOrder(order, user) { // [数据访问] 按订单项批量查询商品 // [业务规则] 校验每个订单项的库存是否充足 // [业务规则] 查找优惠券并计算折扣金额 // [副作用] 把优惠券标记为已使用 // [业务规则] 计算最终支付金额 // [业务规则] 校验余额是否充足 // [数据访问] 扣减用户余额 // [数据访问] 扣减库存 // [数据访问] 创建订单 // [副作用] 发送短信通知 // [副作用] 记录日志与埋点 }打完标签之后,整个函数的职责分布一眼就能看清。数据访问和业务规则交织在一起,还有两个副作用混在中间。我还会顺手画一张粗糙的依赖草图:handleOrder 依赖 db、sendSms、logger、track,其中 db 又散落在四个不同的访问点上。这张图不需要画得多精美,自己看得懂就行。
Tag 阶段有三个细节非常值得注意。第一,不要过度追求“每行都打标签”,粒度控制到“一段连续同职逻辑”即可,否则注释会淹没代码;第二,尽量使用统一的标签词表,比如[数据访问]、[业务规则]、[副作用]、[表现层],这样后续整理和检索都方便;第三,如果发现自己给某段代码打不出单一职责标签,说明它应该被拆开,把这个可疑点记录下来,这就是后面 Trim 和 Transfer 的主要依据。
3.3 第二遍 Trim:敢删、真删、删干净
标签打完之后,我根据职责标注和依赖草图开始做减法。这个例子里可以动刀的地方不少。
先从最明显的入手。第一处是user.wallet -= finalPrice这行,它修改了内存中的 user 对象,但后续保存订单用的是db.updateUserWallet和user.id,这个减法赋值其实没有任何持久化意义,属于逻辑误导,删掉。第二处是第 2 步库存校验时,明明可以直接通过订单项关联商品,没必要先把整个 products 数组查出来再配对索引,这里可以改成一次性查询并组装成 Map,代码会更短。第三处是几个重复的 try-catch 结构,原始代码里每个 db 调用外面都包了一层几乎一样的异常处理,可以直接收敛到统一的数据访问层。还有那个把优惠券标记为已使用的逻辑,虽然在计算阶段执行本身不对,但既然后面 Transfer 时它会挪走,这里先不删,注释标记为“待迁移”。
Trim 之后,函数的骨架微缩成了下面这样,注意我只是列出了结构,还没正式移动代码位置:
// order-handler.js (Trim 后,结构示意) async function handleOrder(order, user) { const products = await getProductsWithMap(order.items); assertStockEnough(products, order.items); const { discount, finalPrice } = await calcFinalPrice(order, products); await payByWallet(user.id, finalPrice); await decreaseStock(order.items); const saved = await createOrder(order, finalPrice, discount); await notify(saved); await logAndTrack(saved, user, finalPrice); }Trim 阶段最需要警惕的就是“舍不得删”。我刚带团队做这个练习时,有人指着自己写的注释说“这段解释很重要,删了以后看不懂了”。我的回应很简单:代码的作用是解释行为,如果代码本身足够清晰,注释就是噪音;如果代码不清晰,注释只会让你误以为自己理解了。真正的意图应该通过函数名、变量名和测试用例来表达,而不是靠注释。做完这一步,一定要跑一遍现有测试用例,确认行为没变。
3.4 第三遍 Transfer:让代码回到它该待的位置
Trim 之后才轮到真正的搬家。我的拆分思路基本是:数据访问搬到 repository,业务规则搬到 service,副作用搬到独立的 notification 或 event 模块,日志埋点放到一个共享的横切位置。方向上参考的是分层架构和领域驱动设计的边界思想,但在落地时我不会为了“纯粹”而过度设计,够用就行。
src/ domain/order/ order.service.js // 订单主流程、库存校验、价格计算 order.repository.js // 查商品、扣库存、创建订单、更新余额 order.entity.js // 订单实体和状态枚举 domain/coupon/ coupon.repository.js // 查找优惠券 infra/sms/ sms-notification.js // 发短信的封装 shared/ logger.js // 日志封装 tracker.js // 埋点封装搬完之后,新的调用链非常直观:order.service依赖order.repository和coupon.repository,这两个 repository 不依赖任何 service;sms-notification和logger、tracker都是基础设施,直接被 service 层调用,但反过来不成立。这就是 Transfer 阶段要追求的核心结果:依赖方向始终从业务层指向基础设施层,而不是两边互相指。
搬家结束之后不能默认万事大吉。我建议按这三步做验证:先编译看有没有遗漏的引用;再跑单元测试和集测,重点看数据库状态、短信发送这类外部依赖有没有被重复调用;最后看一遍 git diff,确认没有不该出现的格式变动。做完这些之后,这段代码就可以放心交给任何人维护了,新同事看目录就能猜个八九不离十。
4. 常见问题与排查技巧实录
说实话,t3code 这套方法理论上并不复杂,真正难的是实际操作中各种“看着对但做起来不对劲”的局面。下面这些问题是我们在推行过程中真实遇到过的,我把解决思路和排查技巧一并记录下来。
4.1 打标签时发现函数“什么都干”,拆不动怎么办
这是我在 Tag 阶段最常碰到的问题:一个函数里既有数据处理又有 UI 渲染还有状态变更,标签根本打不到一个点上。这种情况我发现硬拆反而不容易落地,比较稳的路线是先按“数据访问、业务规则、副作用”这三层拆出候选函数,不为追求完美,只求每个候选函数能起一个准确的名字。
举个例子,一个函数内部有查询订单列表、过滤已支付订单、渲染表格三件事,那就先拆成queryPaidOrders和renderOrderTable两个函数,把调用关系理顺。拆完可能还会发现有些变量在两个函数间传递略显啰嗦,但不要急着优化参数结构,等跑通之后再在 Transfer 阶段做变量收拢。最怕的是在 Tag 阶段就开始改函数签名,一旦改到一半失去耐心,很容易把整个模块搞得更乱。
4.2 Trim 时手抖删了不该删的代码怎么办
这个问题听起来很基础,但在实际操作中相当容易发生,尤其是当代码里存在动态调用、反射、全局替换的时候。我经历过一次事故:清理某个工具函数时,我用 IDE 的全局搜索确认没有外部引用,就直接删了,结果上线后用户反馈某个页面白屏,追查半天才发现是另一个模块通过字符串拼接的方式动态引用了这个函数。
后来我给自己定了一条硬规矩:做 Trim 之前,所有候选删除代码必须先用相对独立的提交保存,至少保证每个阶段有一个可回滚点。如果发现删错了,直接用 git 找回,而不是靠记忆手写补回去。另外,删除逻辑多但范围分散时,建议先git stash暂存工作区,跑一遍完整测试再提交,可以最大程度避免多删漏删。
4.3 Transfer 之后出现循环依赖,怎么排查
循环依赖是我在 Transfer 阶段遇到最多的坑,几乎所有模块搬家都会碰到一两回。表现往往是项目启动时模块初始化报错,或者某些文件在导入阶段出现 undefined。排查起来不要漫无目的地在代码里找,我推荐先画一张模块依赖表,列出每个模块 import 了谁、被谁 import。有环的情况下依赖表里一定能找出一条闭合回路。
找到环之后,处理办法通常是两个:一个是把双方共同依赖的常量、枚举、类型定义抽到一个更底层的公共模块,打破直接互引;另一个是把其中一个方向的引用从编译期依赖改为注入式依赖,让模块在运行时才拿到对方的能力。在项目早期发现循环依赖越早越好,等模块体积大了再拆,成本会成倍上升。
4.4 团队成员不配合“先打标签”怎么办
这个问题与其说是技术问题,不如说是节奏问题。直接跟团队说“以后每段代码都要先打标签再改”基本等于没说,不仅枯燥而且难以坚持。我后来用的方式是把它简化到 Code Review 的流程里:每个 PR 必须回答三个问题,改了什么、为什么这么改、放在这个位置是否合适。当团队习惯了用这套逻辑表达时,Tag 带来的思考密度其实已经融进去了。
另外我还发现一个比较有效的方法是控制单次 PR 的范围,禁止一个 PR 里同时出现“删代码 + 搬文件 + 加新功能”这三类变更。我甚至会在 PR 模板里加一个“本次变更属于 t3code 的哪个阶段”的选项,Tag 的话就只允许出现注释与文档变化,Trim 就只允许出现删除与简化,Transfer 就只允许出现移动与调整目录。这样分工清晰,责任也清晰,出了问题能精确回溯到阶段。
| 问题 | 通用处理思路 | 我的经验备注 |
|---|---|---|
| 拆不动 | 先按职责拆候选函数,允许暂时不完美 | 把可疑点写进待办,下一阶段再处理 |
| 删错代码 | 先用 git 保存可回滚点,再做删除提交 | 不要相信记忆,用版本工具找回 |
| 循环依赖 | 画依赖表找环,抽公共模块或注入解决 | 越早发现越好,别拖到模块膨胀 |
| 团队不配合 | 把方法论揉进评审流程与 PR 模板 | 每个 PR 只做单一阶段,便于沉淀习惯 |
5. 扩展应用:t3code 不止能收拾代码
这套三步法我用了几年之后,发现它其实已经变成了我的通用思维习惯。很多和代码无关的工作也能套上 Tag、Trim、Transfer 的框架,效果还挺不错。
5.1 写技术方案和文档时也能用
写文档最大的问题是容易“什么都想写,最后什么都没写清楚”。我会先用 Tag 把文档当大纲打标签:背景、目标、方案选型、技术要点、风险与回滚、执行计划。然后 Trim 阶段负责删掉与主题无关的背景铺陈、复述其他文档的重复内容。最后 Transfer 阶段把核心结论挪到最关键的位置,让读者扫一眼标题和首段就能理解重点。以前我写方案喜欢从项目起源讲起,五页纸里三页都是铺垫,现在砍掉一半内容之后,评审效率反而高了很多。
5.2 整理个人知识库和笔记
知识管理上的应用更直接。我在收集资料时先用 Tag 给内容贴上主题标签(比如“Go 并发模型”“接口设计规范”“数据库索引”),然后定时做 Trim 把收藏里不再有价值的内容清掉,把重复主题的笔记合并。最后 Transfer 阶段把整理后的内容归档到个人知识库的对应板块,形成可检索的卡片。很多人收藏一百篇干货却一篇都不看,就是因为前两步没做,知识堆积太多导致打开笔记软件就头晕。
5.3 和 AI 辅助编程配合的用法
这个扩展是我最近用得最频繁的。现在大家都会让 AI 生成代码,但很多人反馈生成的代码风格和项目格格不入,或者 AI 会擅自改动无关逻辑。我按 t3code 的方式调整了 prompt 结构,准确率高了不少。
给 AI 的 prompt 可以拆成三段:先 Tag,描述项目目录结构、技术栈、现有代码风格,给出具体文件路径和约定;再 Trim,明确告诉它“只改这个函数 / 只动某个目录,其他文件不要碰”;最后 Transfer,指定输出位置,比如“把新代码写到src/domain/order/下,不要新增全局变量”。这其实就是把人的认知整理逻辑复制给 AI,让它先看懂上下文再做精准修改。
我自己体会最深的一点是:t3code 最难的一步永远是 Tag,因为它需要你诚实面对自己的代码——敢承认有些模块你已经不太懂、有些地方写了几年也不知道为什么存在,才是整理的开始。前几次执行这套流程时,我总想跳过 Tag 直接开改,觉得“我已经够熟了”,结果每次都会在两个小时之后回到起点重新画图。后来我干脆把 Tag 当成一种冥想式的动作,戴上耳机逐行读代码,不急着删除、不急着重构,反而让许多隐蔽问题自行暴露了出来。如果你也想在混乱的老项目里找到一点安全感,可以从今天开始,挑一个最让你头疼的函数,只做 Tag,不做别的。