t3code是我自己给一套工程规范起的内部代号,说白了就是把TypeScript项目里的类型安全、测试套路、代码组织方式打包成一份统一清单。用了大半年之后,我觉得这玩意儿特别值得单独写一篇,因为真正让一个项目从“能跑”变成“耐跑”的,往往不是某个框架多先进,而是这些看不见的边界约定。这篇文章我会把t3code的由来、核心设计、落地步骤,以及踩过的坑全都拆开聊一遍,适合正在做全栈项目、或者对工程化规范感兴趣的朋友参考。
很多人听到t3code,第一反应是“这是不是T3 Stack的变体”。其实关系不大,T3 Stack那套端到端类型安全的思想确实给了我启发,但t3code的关注点不在框架绑定,而在“纪律”。它不要求你一定用Next.js还是Nuxt,也不限定你用tRPC还是REST,它只解决三个问题:类型怎么管、测试怎么铺、代码怎么摆。把这三点钉死,项目再膨胀也不会散架。
1. 整体设计与思路拆解
1.1 它到底要解决什么
先讲一个我自己的真实经历。有一段时间同时带三个前端项目,每个项目的代码风格都不一样:一个项目所有接口数据都写成any一把梭,另一个项目完全没写测试,第三个项目目录结构倒是分层了,但组件、工具函数、业务逻辑全塞在一起。那时候我每天干的活就是“翻译员”——把A项目的写法翻译成B项目的写法,再帮C项目把新同事不小心放进业务层里的请求代码挪到 API 层。
这种状态持续了大概两个月,我意识到问题的根源不是某个人懒,而是没有一套“项目层面的共识”。散落的口头约定在三个人以内还能维持,到了五个人、十个模块、三次迭代之后就彻底失效。t3code的定位就是一件工程外套:把那些容易被人忽略的规则,比如“API边界必须校验数据”“测试必须覆盖状态流转”“目录必须按业务域划分”,变成一条清晰的、可以在代码审查里被执行的标准。
1.2 三个T是怎么来的
t3code这个名字里藏着三个关键词,也是整套规范的三大支柱:Type-Safe、Test-Driven、Tidy-Delivery。
第一个T是Type-Safe,类型安全。我必须承认,很多人写TypeScript只是为了拿到编辑器里的自动补全,觉得一个interface写完就够了。但真正让类型安全值钱的,是它能把“数据形状变了”这件事在编译期直接拦下来,而不是等到线上用户操作时才报错。t3code把类型安全拆成两层:一层是编译器配置层面的严格模式,另一层是运行时校验——前后端数据交界的Schema。
第二个T是Test-Driven,测试驱动。这里说的不是大家理解的“先写断言再写实现”那种教条式TDD,而是测试必须跟着功能一起进入项目。没有测试的代码,在t3code里算未完成,不算写完。我在实践中发现,测试带来的最大收益不是减少bug,而是让人敢重构。一个函数有测试兜底,你改它的内部实现的时候心里不虚。
第三个T是Tidy-Delivery,整洁交付。这个说法有点自造词的意思,但它要表达的是:交付出去的代码,结构要干净、依赖要清晰、命名要一致。整洁交付的好处是让新成员也能在半天内找到该改的文件。它靠的不是强迫症,而是一套可预期的目录约定。
1.3 这套规范的适用边界
t3code不是银弹,它更适合中小型全栈项目、以及五到二十人的技术团队。太小的项目,比如两三天就交付的脚本,用这套规范反而会觉得重;太大的组织,比如多个部门共用一套代码库,还需要额外考虑更细的权限与发布策略。但对多数团队来说,t3code提供的是一条中间路线:它没有引入重量级框架,也不需要专门的架构师岗位来执行,只要团队里有一两个人熟悉约定,就能把规范带起来。
2. 核心细节解析与实操要点
2.1 类型安全落地的两个关键动作
类型安全在t3code里不是一个口号,是两个必须落地的关键动作:开启严格模式、在API边界放校验层。
先说严格模式。很多项目模板默认的tsconfig其实把strict关着,或者虽然开了strict,但代码里到处都是@ts-ignore。t3code的底线是:新代码不允许出现any,不允许用@ts-ignore。如果遇到一个确实没办法立刻定型的复杂嵌套数据,宁可先写一个明确的unknown,再用类型守卫逐步收窄,也不要直接any把类型系统糊弄过去。
来一段最基础但经常被写错的例子:
// 反面:千万不要这么写 const data: any = await getData(); console.log(data.user.name); // 正面:unknown + 收窄 const data: unknown = await getData(); const user = (data as { user: { name: string } }).user; console.log(user.name);这段代码虽然看着繁琐,但它强迫你在data形状未知的情况下先做出明确断言。说白了,类型安全做得好不好,看的就是代码里“形状说得不清不楚”的灰色地带多不多。
再说API边界校验。API边界指的是前端从后端拿数据、或者把数据提交给后端的那一刹那。数据一旦越过网络,你没法保证它长什么样子——后端升级、网关多包了一层、第三方回调改变了字段大小写,都可能让数据结构和你想象中的不一样。
t3code在这一层统一使用Schema校验,项目里我常用zod。它的好处是既能做运行时校验,又能顺便推导出TypeScript类型,一份Schema管两件事:
import { z } from 'zod'; export const UserSchema = z.object({ id: z.string().uuid(), email: z.string().email(), role: z.enum(['admin', 'user', 'guest']), }); export type User = z.infer<typeof UserSchema>;这样定义完之后,无论是前端拿到数据,还是后端起一个中间服务去验证外部请求,都可以直接调用UserSchema.parse(data)。校验不通过就立刻报错,而不是等到后面的业务代码因为user.role.toUpperCase()崩溃才发现问题。
2.2 工程化基座:ESLint和Prettier不是装饰品
t3code里的代码风格统一靠两条命令保证:eslint和prettier。这两者不是可有可无的“好看配置”,它们在干的其实是同一件事——消灭无谓争论。我在现实里见过不少团队为“缩进到底是两格还是四格”在代码评审里吵二十分钟,这种事一旦用工具固定住,大家的精力就重新回到逻辑本身。
具体的配置取舍上,我建议三条经验:
第一,ESLint的parserOptions.project一定要开启,这样才能让规则拿到类型信息,type-aware规则比如no-floating-promises、no-unsafe-argument才能真正生效。第二,Prettier的endOfLine设成auto,可以少一点跨平台提交差异。第三,如果项目用的是函数组件风格,可以单独配一条react-refresh/only-export-components规则,避免文件里同时混着组件和工具函数。
这套基座还有个关键点:把两个工具的检查脚本写进npm scripts,而不是依赖大家的编辑器插件。毕竟不是每个人都装了同样的IDE,也不是每个人都记得手动格式化。只要你把规则写在配置里,并且CI里跑一次,那大家的代码就会朝同一个方向收敛。
2.3 测试体系的分层设计
t3code对测试的处理不是让所有人都去写一堆UI快照测试,而是按成本把测试分成三层:单元、集成、端到端。
单元测试主要覆盖纯函数和工具库。这类函数不依赖真实环境,喂进什么就返回什么,跑起来最快,适合写大量边界用例。集成测试的目标是把“模块与模块之间怎么协作”验起来,常见做法是调用一个Service层函数的时候,把数据库调用替换成测试库,断言整个业务逻辑的状态流转。端到端测试我建议只留几条核心链路,比如“用户注册→收到邮件→登录成功”,别贪多,因为E2E的维护成本最高,动不动就受环境因素影响。
我想强调一个很多新手容易犯的错误:测试覆盖率不是越高越好。t3code把行覆盖率的目标设置在70%-80%之间,重点放在业务逻辑层和数据处理边界。UI组件内部哪些div被渲染出来,覆盖率其实不关心,关心的是点击按钮之后状态有没有变化、接口有没有被正确调用。
2.4 目录结构的划分原则
有了前三个基础之后,目录结构就是让团队“找得到文件”的最后拼图。t3code对目录的约定很明确:按业务域划分,而不是按文件类型划分。
很多初学的前端朋友会把src底下先建出components、utils、hooks三个大文件夹,然后所有页面的组件都塞进components,所有工具函数都塞进utils。这种划分方式在项目大了以后会非常难受,因为你想找“订单模块的上传按钮”时,得同时翻遍components下一百多个组件才能确定是哪一个。
t3code的做法是让业务域当主角:
src/ features/ auth/ api/ components/ hooks/ schema/ order/ api/ components/ hooks/ schema/ shared/ components/ hooks/ utils/每一个features下的子目录都是一个业务域,域内再根据自己的需要放api、components、hooks、schema。跨业务共享的、确实抽不出来的一般能力,才放到shared里。这样改一个订单相关的需求,你基本只需要打开features/order这一个目录,内部的边界一目了然。
3. 实操过程与核心环节实现
3.1 从零初始化一个t3code项目
说再多设计,不如带你走一遍。假设我们现在要从空目录搭一个前端小项目,按t3code的规范来做。
第一步,用Vite初始化一个React + TypeScript应用:
npm create vite@latest t3code-demo -- --template react-ts cd t3code-demo npm install第二步,装齐规范相关的依赖。这里我一条命令装完ESLint、Prettier、Zod、Vitest这几个主力:
npm install -D eslint prettier zod vitest @types/node npx eslint --init第三步,打开tsconfig.json,确认已经是严格模式。Vite自带的模板一般已经开了strict: true,但我要再加两个选项,一个是noUncheckedIndexedAccess,另一个是exactOptionalPropertyTypes。这两个选项能按得更细,前者会提醒你“数组下标访问可能返回undefined”,后者会要求可选的属性必须是明确的undefined而不是null混着用。刚开的时候项目里可能会冒出一堆类型报错,别慌,这正是把隐患浮出水面的时刻。
第四步,在项目根目录建一份.prettierrc.json:
{ "semi": true, "singleQuote": true, "quoteProps": "as-needed", "trailingComma": "all", "printWidth": 100 }3.2 串起一条用户创建流程
接下来我们把t3code里的“类型安全 + API校验”真正用起来。假设我们有个后端接口POST /api/users,前端要把用户表单提交上去,并拿到创建完成后的用户对象。
先定义Schema和类型:
// features/user/schema.ts import { z } from 'zod'; export const CreateUserInput = z.object({ email: z.string().email(), password: z.string().min(8), role: z.enum(['admin', 'user']).default('user'), }); export const UserDTO = z.object({ id: z.string().uuid(), email: z.string().email(), role: z.enum(['admin', 'user']), }); export type CreateUserInput = z.infer<typeof CreateUserInput>; export type UserDTO = z.infer<typeof UserDTO>;然后写一个请求函数。关键点在于:发送请求之前,先对入参做校验;拿到响应之后,再用UserDTO.parse把响应数据“洗”一遍。
// features/user/api/createUser.ts import { CreateUserInput, UserDTO } from '../schema'; export async function createUser(input: CreateUserInput) { const parsedInput = CreateUserInput.parse(input); const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parsedInput), }); if (!res.ok) { throw new Error(`创建用户失败: ${res.status}`); } const json: unknown = await res.json(); return UserDTO.parse(json); }看到这里你可能会有个疑问:既然入参已经定义了CreateUserInput这种TS类型,为什么还要用CreateUserInput.parse(input)再校验一遍?原因在于TypeScript的类型只是编译期的承诺,它管不到运行时。用户可能是从localStorage里读出来之后塞给你的,可能是URL参数解析出来的,这些数据不会自动遵守TS类型。Schema校验的意义就是把“编译期可信”和“运行期可信”连起来。
3.3 把测试接入进来
t3code要求每个核心业务逻辑至少有一组单元测试。我们接着上面的createUser来写测试,但这里有个技巧:不要直接调用会发实际网络请求的createUser,而是先抽取一个纯函数,比如把“校验用户输入”和“处理响应数据”都拆成可独立测试的纯函数。
一个简单的纯函数例子是这样的:
// features/user/utils/normalizeUserName.ts export function normalizeUserName(name: string) { return name.trim().replace(/\s+/g, ' '); }对应的测试文件长这样:
// features/user/utils/normalizeUserName.test.ts import { describe, it, expect } from 'vitest'; import { normalizeUserName } from './normalizeUserName'; describe('normalizeUserName', () => { it('去掉首尾空格', () => { expect(normalizeUserName(' 张三 ')).toBe('张三'); }); it('合并多个连续空格', () => { expect(normalizeUserName('张 三')).toBe('张三'); }); });然后,我们把test脚本写进package.json:
{ "scripts": { "test": "vitest run", "test:watch": "vitest", "lint": "eslint src --ext .ts,.tsx", "format": "prettier --write ." } }3.4 给CI加一道看得见的门禁
如果只在本地跑npm run lint,第二天又会有人跳过它。t3code真正生效的地方,是CI里有一道“过不了就合不了”的门禁。用GitHub Action的话,我习惯在项目里放一个精简的配置文件:
name: code-check on: [push, pull_request] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci - run: npm run lint - run: npm test - run: npm run build这里面看似简单,但每一步都有用意。npm ci是严格按锁文件安装,避免依赖漂移;npm run build放在最后,是为了让类型错误在构建阶段直接暴露。只要这条流水线稳定跑着,规范就不再是“靠自觉”,而是硬性的交付门槛。
4. 常见问题与排查技巧实录
4.1 问题速查表
下面是这大半年里我自己和团队在t3code落地时遇到频率最高的几个问题,做成一张速查表,方便排错时对照。
| 问题现象 | 根因 | 解决思路 |
|---|---|---|
开了strict后大量文件报错 | 历史代码里使用了过多的隐式any | 不至于一次性需要全部改干净;将报错文件按模块分组,逐次切换实现strict,先对核心业务路径应用 |
| API返回数据里多了一个字段,前端类型没报错 | 类型声明是手写的,和运行时数据脱节 | 用Schema推导类型,且必须parse数据后再使用 |
Zodparse抛错,错误消息不直观 | 校验失败后的默认提示是英文,也看不出是哪个字段的问题 | Zod支持自定义错误消息,给关键字段加上message,尽量写成中文、带字段名 |
| ESLint规则在CI里和本地结果不一致 | 本地用的是旧版本依赖,或者编辑器插件缓存了配置 | 第一时间先跑npm update -D eslint,并确认CI使用npm ci锁版本;本地重启ESLint服务 |
Vitest跑测试时报找不到@/路径别名 | tsconfig里的paths配置没有被Vitest读取 | 安装vite-tsconfig-paths插件,并在vitest.config.ts里注册 |
4.2 从报错信息反向定位问题的思路
有时候问题并不会直接表现出“我的类型写得不对”,而是以一种奇怪的方式暴露出来,比如“接口偶尔返回undefined”“线上偶尔抛500”。t3code的排查思路有一个铁律:先确认数据边界有没有被验证,再去追业务逻辑。
举个例子,有次团队反馈说某个列表页面偶尔是空的。我第一反应不是去看渲染逻辑,而是看这个列表数据在进入组件之前,有没有经过Schema校验。后来果然发现,后端在无数据时返回的是null,但前端的Schema写的是z.array(),前端把null当成数组之后,组件里的一处.map就直接崩了。表面上看是“空数据导致崩溃”,实际上是“边界校验没有拦住null”。
所以你在排查类似问题时,可以顺着这条链走:接口返回的原始数据长什么样 → Schema接受了什么 → 组件拿到的数据长什么样 → UI组件崩溃的下游点在哪儿。然后再做修复。
4.3 落地这套规范时,我几个亲测有效的避坑经验
最后说几个偏“经验”层面的东西。
如果你是第一次把这套东西塞进一个已有项目里,千万别想着“一天全改完”。比较合理的节奏是:先补CI门禁,让新代码遵守规范;再分模块处理历史债务,比如每周挑一个features目录做严格模式改造和Schema补齐;最后再处理目录结构。我见过有人第一天就把全项目的文件大挪移,结果合并冲突多到怀疑人生,还影响了两天的正常迭代。
还有一点是关于Zod错误处理的。入库之前统一定义一个safeParse的封装,把校验失败的路径和message拿出来,转成业务方容易读的格式,而不是把Zod的原生错误直接抛给用户。这不算复杂,但用完之后体验会好非常多。
再有,就是千万别迷信“类型安全了就不用写测试了”。Schema校验解决的是数据形状问题,测不过的是业务逻辑。两者是互补关系,不是替代关系。一个状态流转写错了,再严格的类型检查也发现不了,这个必须靠测试兜着。
回过头来看,维护t3code这件事本身也在倒逼我做很多工程决策。每当我准备往项目里塞一个新依赖,都会先问一句:它提供的类型是可信的吗?它定义的边界够清晰吗?它会不会破坏现有的测试分层?这些问题没有统一的标准答案,但它们帮我避开了很多后来需要还的技术债。
如果今天你手头正好有一个打算长期养下去的项目,我建议你也试试把“类型、测试、目录”这三样东西先用规则固定下来。刚开始可能觉得束手束脚,但养一段时间之后,你会发现自己改代码的速度反而变快了——因为每一次改动都有明确边界,风险看得见,心里就踏实。