大概半年前,我在重构团队一个内部项目时,被接二连三的“能跑但不敢动”问题折磨得够呛:改一行数据模型,UI层跟着报错;想换掉一个底层请求库,几乎要把整个业务代码翻一遍;新同事接手后的第一周,有一半时间花在搞清楚“这块逻辑到底该放哪”上。也就是在那段时间,我抽空把过去几年在多个项目里沉淀下来的那套代码组织方式整理成了一个小型开源项目,名字就叫t3code。取意很直白:第三代的 TypeScript 工程化代码范式,也是 Three-Tier Code(三层代码)的缩写。它不依赖任何重量级框架,也没发明任何新语言特性,本质就是一套“怎么把代码放得让人好改、好查、好删”的落地约定。
这篇文章想把这些实践经验完整摊开来讲,包括当初为什么这么设计,三层边界到底怎么划,最小工程怎么搭,三个核心模块各自解决什么问题,以及我在压测和排错过程中整理的真实排查链路。无论你是正在维护一个开始失控的中型前端项目,还是想给自己的 React/Vue 代码库补上工程化约束,这套思路都有可以直接抄走的部分。
1. 这个项目到底在解决什么问题
1.1 从一个反直觉的痛点切入:代码能跑不代表可维护
大多数项目在初期都是很快乐的。需求变化快,团队小,随手写也是高效。可一旦代码量跨过某个阈值,比如业务模块超过二十个、参与人数超过四个,就会突然出现一批很难量化的症状:没人敢动公共类型定义,因为不知道还有谁在用;新功能不知道该加在哪个目录,于是先放在就近的地方,最后到处都是“就近”的代码;两个同事改同一个文件,git 冲突一半是功能冲突,另一半是“我们都觉得该放在这里”。
t3code 想做的不是再发明一套目录规范,而是把“依赖方向”这个核心问题掐死。代码混乱的本质几乎都是依赖方向乱了:UI 直接依赖了某个请求函数的返回细节,业务逻辑直接引用了一个组件的内部状态,数据层偷偷知道了视图层的数据结构。这些混乱在 Code Review 里不容易被抓住,因为“能跑”和“合格”之间本来就有很大灰色地带。
1.2 t3code 的命名拆解:Third、Tier、Tool
先说名字。很多人第一次看到 t3code 会以为它跟什么 AI 或者区块链有关,其实没有。三个含义分别是:
- Third-generation:第一代是裸写 JavaScript,回调满天飞;第二代的标志是 TypeScript 普及,但大多数项目的类型和运行时是“两张皮”;第三代则是让类型体系真正成为设计工具,用类型去约束模块边界,让错误的依赖在编译期就暴露出来。
- Three-Tier:代码从职责上只保留三层——数据层、逻辑层、视图层。这和传统后端的三层架构不同,它更关心的是数据从接口、从本地缓存、从状态管理进到视图这一路上,每层各自能碰什么、不能碰什么。
- Toolchain:它附带一套非常轻量的命令行工具,用来做三件事:检查依赖方向、生成类型边界报告、执行构建前的三重校验。
拆解这些之后你就明白,t3code 不是一个开箱即用的脚手架,它更像一个“规范内核”。你可以在已有的 Vite、Next.js、Taro 项目里逐步引入,不用推翻重来。
1.3 三个核心设计目标:三端一致、三模块解耦、三命令交付
具体项目落地时,我给自己定了三个可以量化的目标。
三端一致:同一套领域类型定义,在 Web 端、小程序端和 Node 脚本端要保持一致。过去常见的问题是三端各维护一份模型,改接口字段时漏改一处,线上就出 bug。t3code 的做法是把领域模型放在一个独立的 package 里,三端都依赖它,禁止各自复制。
三模块解耦:整个项目运行时由三个核心模块协作——配置注册表(Config Registry)、任务调度器(Task Scheduler)、渲染桥(Render Bridge)。每个模块只通过公开的类型接口与其他模块通信,模块内部怎么实现,其他模块一概不关心。
三命令交付:t3code check(依赖方向检查)、t3code type(生成类型边界报告)、t3code build(三重校验后构建)。这三条命令把“代码能不能合并”的标准从“测试过了”提升为“结构上确实没问题”。
2. 分层不是银弹:三层边界究竟怎么划
2.1 数据层 / 逻辑层 / 视图层:职责边界和依赖规则
很多人听到分层第一反应是“不就是分目录吗?”其实分目录是最容易的部分,难的是定清楚依赖规则,并且让工具能守住这些规则。
t3code 的三层定义是这样的:
- 数据层(data):负责与外部世界通信,包括 HTTP 请求、WebSocket、localStorage、IndexedDB、Cookie。这一层不允许 import 任何 UI 组件或业务逻辑模块,它只导出“数据访问方法”和“数据模型类型”。
- 逻辑层(domain):负责业务规则,比如表单校验、价格计算、权限判断、状态流转。这一层不允许 import 任何视图层代码,但可以自由引用数据层暴露的方法和类型。
- 视图层(view):负责渲染和用户交互,组件、页面、路由配置都在这层。它不允许直接调用数据层的底层 API,比如不允许在组件里直接 fetch,必须通过逻辑层暴露的“用例方法”间接访问数据。
这四条规则值得每个人刻在脑子里:view 不能碰 data 的底层实现、domain 不能碰 view 的任何东西、data 不能反向依赖任何上层、类型定义的方向永远和运行时依赖方向一致。
2.2 一套类型系统贯穿三层:领域模型如何一路流转
这里涉及到整个 t3code 里我最看重的一个设计:从数据库或后端接口返回的原始结构,到前端视图真正消费的结构,中间必须经过显式的“映射层”,而不是把后端字段直接用透传到组件里。
我举个例子。后端返回的用户对象是:
// 这是后端返回的原始类型,定义在 data 层 export interface UserDTO { user_id: number; user_name: string; user_status: 0 | 1 | 2; register_at: string; last_login_at: string | null; }前端业务里更关心的是isActive、displayName这类语义化字段。如果视图层直接拿user_status === 1去判断,那这个判断逻辑就会散布在多个组件里。t3code 要求在 domain 层定义领域模型,并显式转换:
// 逻辑层定义领域模型 export interface User { id: number; name: string; status: 'active' | 'disabled' | 'banned'; registeredAt: Date; lastLoginAt: Date | null; } // 逻辑层负责转换 export function toDomainUser(dto: UserDTO): User { const statusMap = { 0: 'disabled', 1: 'active', 2: 'banned' } as const; return { id: dto.user_id, name: dto.user_name, status: statusMap[dto.user_status], registeredAt: new Date(dto.register_at), lastLoginAt: dto.last_login_at ? new Date(dto.last_login_at) : null, }; }这样改后端字段名时,data 层内部的改动只要收敛在 mapper 一个文件里。视图层永远不知道后端长什么样,这是个极大的认知负担减免。
2.3 我在这里踩过的坑:跨层传递响应式状态的代价
分层最容易被“响应式状态”击穿。很多团队用的是 Pinia 或 Zustand,一开始状态仓库放在 view 层,后来业务逻辑也塞进了 store 里,再后来连请求也直接在 store 里发。store 变成了一个没有边界的黑洞。
t3code 的约束是:store 文件本身属于逻辑层,但 store 里不准出现 fetch 调用,也不准出现组件实例。store 需要拿数据时,调用 data 层暴露的 service 方法;视图订阅 store 的状态变化,但不直接改 store。
这个规则我在早期没严格执行,结果出现过一个很经典的 bug:某个 store 里直接调用了fetchUserList,与此同时另一个组件也调用同一个请求函数,两处分别维护 loading 状态,结果一个页面里出现两个 loading 转圈,且数据刷新时机完全不一致。后来统一改成“逻辑层提供 loadUserList 用例,内部管理请求去重和缓存失效”,这个问题才彻底消失。
3. 搭建一个可复现的 t3code 最小工程
3.1 环境准备与工程初始化
我不会重新发明轮子,t3code 工程是在 Vite 生态之上建起来的。推荐环境是 Node 18+,包管理器用的 pnpm。初始化命令很简单:
pnpm create vite t3code-demo --template react-ts cd t3code-demo pnpm add -D t3code安装完后,在项目根目录执行一次初始化命令,它会生成一套标准的目录骨架和配置文件:
pnpm t3code init生成完后,你的 src 目录下会多出三个文件夹:src/data、src/domain、src/view。如果你是从老项目迁移过来,不用急着把所有代码都挪进去,可以先把新写的代码按这个结构放,旧的利用周末慢慢消化。t3code 的检查命令支持忽略路径配置,过渡期把src/legacy放进白名单即可。
3.2 核心目录结构与配置清单
这是我用下来的一个标准工程结构,可以直接参考:
src/ data/ http/ # 请求实例封装(拦截器、鉴权等) apis/ # 按业务域拆分的接口函数 schemas/ # 与后端约定的 DTO 类型 mappers/ # DTO 到领域模型的转换函数 domain/ models/ # 领域模型类型 services/ # 跨模块的用例方法 stores/ # 状态管理(仅业务状态,不含请求) utils/ # 纯函数工具(无副作用,不碰存储) view/ components/ # 通用组件 pages/ # 页面级组件 layouts/ # 布局组件 routes/ # 路由配置 shared/ # 跨层都允许用的类型、常量、工具(不依赖任何层)这里有个重要的地方:shared不是第四层,它是一个“无依赖区域”。它里面不能放业务逻辑,只能放类似formatDate、isMobile、AppEnv枚举这类东西。一旦有人把业务代码放进去,shared 就会变成新的垃圾堆。
3.3 三行命令的交付链路:check / type / build
环境配置好以后,日常开发最核心的就是三条命令:
pnpm t3code check pnpm t3code type pnpm t3code buildcheck会扫描所有 import 语句,判断是否存在跨层越权依赖。检测到view层直接 import 了data/apis下的文件时,会报错并给出从 A 到 B 的具体路径。type命令会生成一份type-report.json,里面记录了每个模块对外暴露的类型数量、被引用次数和模块耦合指数。这个报告不是给人手动看的,是给 CI 用的,可以设置阈值,比如某个模块的耦合指数连续三个版本上涨,CI 就会警告。build则是把 lint、check、类型检查和构建串在一起的组合命令,保证合并进主干的代码至少在结构上是干净的。
4. 三个核心模块的实现细节:注册表、调度器、渲染桥
4.1 配置注册表:类型安全的模块配置方案
t3code 的运行时里,第一个核心模块是配置注册表(Config Registry)。它的职责是管理“功能模块”的注册和读取。在典型场景下,我们希望一个业务模块被启用或禁用时,不需要改动一堆 if-else,只需要注册或注销一个配置项即可。
它的类型设计长这样:
// 定义模块元信息的结构 export interface ModuleMeta { name: string; version: string; enabled: boolean; dependencies?: string[]; setup?: (ctx: ModuleContext) => void | Promise<void>; } // 注册表的实现(简化版) const registry = new Map<string, ModuleMeta>(); export function registerModule(meta: ModuleMeta) { registry.set(meta.name, meta); } export function resolveDependencies(name: string): string[] { const meta = registry.get(name); if (!meta) return []; return meta.dependencies ?? []; }严格来说这个模块本身不复杂,但它解决了很实际的痛点:以前一个功能上线,需要在好几个地方加开关,现在的功能开关集中沉淀在模块注册表里,并且每个依赖关系都是显式的。module A 依赖 module B,没注册 B 就不让 A 启用,这个校验在setup阶段就能抛错。
4.2 无侵入的调度器:业务任务编排的默认答案
第二个核心模块是任务调度器(Task Scheduler)。它做的是把一串异步业务步骤按顺序、按条件组织起来,并统一处理失败重试和取消。
我从很多项目里观察到,业务代码里最乱的地方就是“流程代码”:先请求用户信息,再判断权限,再并行请求菜单和列表,其中一个失败还要回滚状态。这些流程如果散落在组件useEffect里,就会变成一场灾难。
调度器的接口设计非常克制:
type Task = { id: string; run: (ctx: TaskContext) => Promise<void>; retry?: number; timeout?: number; }; export function createScheduler() { let cancelled = false; return { async run(tasks: Task[]) { const results = []; for (const task of tasks) { if (cancelled) break; try { const result = await execWithTimeout(task, task.timeout ?? 10000); results.push(result); } catch (err) { if (task.retry && task.retry > 0) { // 走重试逻辑,最多 retry 次 } else { throw err; } } } return results; }, cancel() { cancelled = true; }, }; }调度的好处不是性能,而是“编排的可见性”。我把每次调度的任务列表和执行顺序打印到日志里,线上问题回溯变得非常轻松:用户点了某个按钮,触发哪几个任务,哪一步超时,一目了然。
4.3 渲染桥:双端共存的渲染适配层
第三个模块渲染桥(Render Bridge)是针对“同一套业务代码要在 Web 和跨端框架里都能跑”的场景。t3code 本身不绑定 UI 框架,但通过渲染桥,业务代码不直接 import 任何react、vue或小程序组件,而是依赖一个统一的渲染接口。
最简单的渲染桥可以这样理解:
// 渲染桥定义组件接口 export interface RenderBridge { createElement(type: string, props: Record<string, unknown>, children?: unknown[]): unknown; render(container: unknown, element: unknown): void; unmount(container: unknown): void; }Web 端注册 React 实现,小程序端注册小程序实现,业务组件拿到的是一个抽象的渲染能力。日常开发里,你不一定要把渲染桥做得很重,但它会倒逼你写业务时避免“UI 与副作用”纠缠不清。组件只表达“我要渲染什么”,至于数据怎么来、用户操作后怎么处理,全部在组件外部通过逻辑层注入。
5. 实际压测和调优记录:t3code 在真实场景里的表现
5.1 基准数据:冷启动、热更新、打包体积
我拿一个真实的中型项目做了一轮对比。这个项目有 42 个页面、160 多个 API、状态管理使用 Zustand,改造前整个 src 里没有任何分层约束。
改造前 vs 改造后的数据:
| 指标 | 改造前 | 应用 t3code 后 |
|---|---|---|
| 冷启动(dev server) | 约 3.2s | 约 2.8s |
| 热更新(单文件变更) | 约 300ms | 约 260ms |
| 产物体积(gzip) | 268KB | 245KB |
| 可摇树优化程度 | 有大量重复依赖 | 依赖关系清晰,摇树更充分 |
数字本身不算惊艳,但方向很明确:约束依赖方向之后,构建工具能更精确地做 tree-shaking,因为模块间的循环依赖少了。实际收益最大的不是体积,而是“改一个类型定义后产生连锁报错”的次数明显下降。
5.2 性能瓶颈定位:从火焰图到代码改动的闭环
我在调优时用了一个很简单但有效的流程:先在 Performance 面板录制一段用户完整操作(大概是“列表页进入 → 搜条件 → 翻页 → 点详情”),生成火焰图,然后找最宽最长的那个橘黄色条。那次定位到的瓶颈是一个列表页组件在 mount 后串行 await 了三个互不依赖的请求,总耗时 840ms。
修复方案完全符合 t3code 逻辑:把三个请求收敛到一个loadOverviewData用例里,内部用Promise.all并发执行;同时给两个次要请求增加优先级标记,在数据到达前展示骨架屏而非阻塞整个页面。改造后操作总耗时降到 420ms 左右。这个优化不需要 React.memo 这类微优化,把请求编排的模式改对,收益最大。
5.3 资源占用与内存泄漏排查
还有一个很值得记录的现象:状态管理里经常存在“只被少量页面使用但永远不清空”的全局数据。用户的 token 是应该全局留着的,但“当前筛选条件”“上次浏览位置”这类 UI 状态如果也放在全局 store 里,页面切换后数据还在,内存无限增长。
t3code 规定:UI 型状态尽量留在页面组件内,实在要跨页共享时才提升到 store,stock 中的临时数据必须注册清理时机。我用一个自定义 hook 模拟了“页面卸载时清除 store 中指定 key”的能力,改造后多次测试结果正常,Tab 切换 20 次后内存占用与首次渲染基本持平。我强烈建议每个用全局 store 的团队都审视一下:你的 store 里有没有“页面卸载后其实已经没人关心”的数据。
6. 常见报错与排错链路:让后来者少走弯路
6.1 TypeScript 类型递归过深:一次真实的生产事故
t3code 鼓励用类型约束模块边界,但也容易写出一些“看起来很聪明”的复杂类型。我们有一次在类型层面定义配置项的深度可选结构:
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };当配置对象嵌套超过 5 层,且同时在多个文件里互相引用时,TS 编译器直接报出Type instantiation is excessively deep and possibly infinite。排查链路可以复现一下:
- 先看报错文件是哪个模块,发现是 registry 的类型定义,但真正被引用的源头是业务模块里一个嵌套了 8 层的配置对象。
- 用
tsc --traceResolution跑一遍,发现类型实例化次数暴增。 - 把
DeepPartial改成浅层处理,或限制递归深度上限(比如只支持两层展开)。 - 反复编译确认报错消失,并且其它模块的类型没有丢失。
教训很简单:类型是用来约束的,不是用来炫技的。如果你的类型定义让 IDE 的提示卡顿超过两秒,就该考虑简化。
6.2 依赖方向检查的误报与白名单处理
t3code check 刚上线那几周,团队反馈最多的就是“误报”。最常见的情况是视图层 import 了一个 domain 层的类型,完全合理,但 check 命令因为默认规则太严格,把这种“类型引用”也当成运行时依赖处理了。
后来在检查工具里加入了“类型导入”和“值导入”的区分。import type { User }属于纯类型引用,跨层是允许的;import { fetchUser }这种值导入,跨层才是禁止的。如果你的项目也存在很多第三方库的类型被误判为本地模块,也可以给检查器加一个externalModules配置,把node_modules里的类型引用默认放行。
白名单本身不是坏事,它的目的不是“放水”,而是让团队先把最核心的运行时依赖方向管住,类型层面的优化可以往后放,一次性全堵住会让大家连写的欲望都没了。
6.3 构建缓存导致的三端不一致
另一个非常隐蔽的坑是构建缓存。三端共用同一个领域模型 package,但在小程序端构建时,因为持久化缓存命中了旧版本的模型编译产物,导致新加的字段在小程序端不可见,而 Web 端已经生效。当时排查了很久,最后是清空.turbo和.cache目录才解决。
现在的做法是在 CI 流程里对领域模型 package 的改动做强制全量重建,并且给cacheKey加上依赖树 hash。如果你的项目是多端共用模型的架构,务必把缓存的一致性检查放进发布流水线里,否则线上会出现“两端行为不一致但是查不到原因”的灵异事件。
7. 从 t3code 这个项目里得到的一些体会
项目推到现在的版本,我最深的感受是:所谓的“工程化”,不是装几个工具、跑几条命令,而是把一个团队对“代码应该长什么样”的共识,变成具体可见、可执行、可检查的规则。t3code 的价值恰恰在于它把很多“软性的共识”固化成了“硬性的检查”,比如依赖方向、类型边界、模块引用关系。即使团队里所有人都会写代码,没有这些硬规则,三个月后照样会退化。
如果你也想在自己的项目里尝试这套思路,可以从最小切口开始:只加一个src/domain目录,定一条规则“组件里不许直接写 API 调用,必须走 domain 层”,然后靠代码审查把这条规则守住。等大家在合作中吃到甜头,再逐步引入目录分层、依赖检查工具和构建校验链路。我个人还有一个建议是,别把这条路一次性走完,分三个里程碑:第一个月只做 data 和 view 分离;第三个月再引入 domain;第六个月再把检查命令接进 CI。循序渐进,团队抵触情绪会小很多,最终落地的概率才高。
如果你试过之后有什么有趣的踩坑记录,或者对某一处的设计有不同意见,也可以直接来聊。这类工程化的话题,从来不是一家之言能定论的,真正重要的是一起把“好代码”从口号变成可执行的东西。