从代码腐化到 t3code:TypeScript 工程化编码规范实践
先说个背景。年初我接手了一个跑了快两年的 TypeScript 全栈项目,代码量四万多行,看起来一片繁荣。真正开始迭代才发现,处处都是any、隐式类型、三四种命名风格共存,一个组件里塞了两千行业务逻辑。最离谱的是,同一个数据获取逻辑在不同页面里写了五遍,每一遍还微调得不一样。代码评审时大家不是在卷逻辑,而是在争论“你为什么要用这种写法”。那时候我就意识到,这个项目缺的不是技术,是一个统一的编码标准。
后来我花了差不多三周时间,把我们踩过的坑、评审时吵过的架、重构时拆过的雷全部梳理了一遍,定了一套规范,项目代号就叫t3code。t3 不是版本号,它是三个 T:Type-first(类型优先)、Test-driven(测试驱动)、Team-friendly(团队友好)。这篇文章就把 t3code 从思路到落地完整拆一遍,包括技术选型、目录结构、核心编码规则、完整实操流程和踩坑记录,适合正在做 TypeScript 中大型项目的团队参考,也适合个人开发者想提前建立良好工程习惯时抄作业。
1. 为什么需要 t3code:三个 T 是怎么来的
1.1 先从一次典型事故说起
有一次我们上线了一个营销活动页面,运行半小时后用户集中反馈“页面白屏”。查日志发现是接口返回数据的某个字段从string变成了null,而代码里直接对这个字段调用了.split()。老代码里这个字段从来没空过,所以没人处理。生产故障修了半小时,本质原因就一句话:没有类型约束,就没有人知道这个字段可能为空。
这其实是所有动态语言和弱类型习惯带进来的老毛病。JavaScript 项目跑得越久,运行时错误越难追溯,因为错误不是在你写错的那一行暴露的,而是在用户使用到那条路径时才爆发。TypeScript 的价值不在于“用上了”三个字,而在于把边界、空值、联合类型这些信息在编译期就暴露出来。t3code 的第一条原则 Type-first,就是从这个教训里长出来的。
1.2 linter 管不住“风格病”
你可能觉得,不是有 ESLint 和 Prettier 吗?它们确实能帮我们把分号、引号、缩进统一掉,但统一不了更深层的风格:状态该放哪儿、组件该怎么拆、副作用怎么收敛、接口返回怎么处理。这些是 linter 的盲区,却恰恰是代码腐化的开始。
t3code 做的事情,是在 lint 之上补一层约定层。它不只告诉团队“不能用any”,还告诉团队“这个场景下你应该怎么写、为什么这么写、写完之后长什么样”。三层结构大概是:
- Lint 层:ESLint 预设规则加严格 TS 规则,自动钳制。
- 约定层:t3code 的编码规范,人肉遵守 + 评审检查。
- 模板层:项目骨架、目录结构、脚手架,让正确做法成为默认做法。
1.3 三个 T 回答了三个问题
- Type-first回答的是“这个数据的形状是什么”:接口入参出参、组件 props、全局状态,一律先定义类型再写实现。
- Test-driven回答的是“凭什么相信这段逻辑没问题”:核心逻辑先写测试,测试不是附属品,是设计工具。
- Team-friendly回答的是“别人接手时能不能看懂”:命名语义化、组件单一职责、数据流收敛,让代码自己会说话。
这三个词听起来像是老生常谈,但真正把它们拆成可检查的条条框框,再去落地,你会发现很多项目连第一条都没做到。T3code 就是把这三点做成了 checklist。
2. 项目骨架与技术选型:让正确写法成为默认
2.1 技术栈选择逻辑
t3code 不是一个框架,但为了方便落地,我基于当时团队最熟悉的生态搭了一套参考底座。选型逻辑只有一句话:选那些能天然约束代码行为的工具,而不是需要靠自觉的工具。
前后端我用的是 Next.js 14(App Router)+ TypeScript 严格模式 + Prisma + PostgreSQL。有人可能会问,为什么不用别的数据库或 ORM?因为 t3code 的核心诉求是“类型安全贯穿全链路”。Prisma 能从数据库表结构直接生成类型,这个特性让 Type-first 在数据层落地变得几乎零成本。前端调用接口时如果用的 tRPC,连手写 API 类型都不用,类型直接从服务端函数推导到客户端。这条链路对 TypeScript 项目来说,是目前我想得到的“类型穿透力”最强的组合。
如果你项目里暂时不能换技术栈,也没关系。t3code 的规范是技术栈无关的,你可以继续用 REST + Axios + 手写类型,规范照常适用。
2.2 目录结构:按模块聚合,不按技术分层
传统项目喜欢按技术分层,components/、hooks/、utils/、services/各一大文件夹。代码少的时候看着清爽,代码量上来之后就完蛋了:一个业务功能涉及的文件分散在四个目录里,改一个需求要跨五个文件夹。
t3code 推荐的是按功能模块聚合。一个完整的业务模块,自带它的组件、逻辑、类型、测试和路由,目录长这样:
src/ modules/ auth/ api.ts # 接口调用与类型映射 auth.type.ts # 数据模型类型 components/ # 模块专属组件 hooks.ts # 模块逻辑 auth.test.ts # 单元测试 todo/ api.ts todo.type.ts components/ hooks.ts todo.test.ts shared/ # 跨模块共享的原子组件/工具 config/ # 环境与全局配置 app/ # 路由与布局这个结构的好处是:改动代码时,所有相关文件都在附近。删一个模块时,直接删一个文件夹,不会留下孤儿文件。共享代码会被谨慎对待,因为放shared/是需要评审的,避免“共享区”变成一个垃圾桶。
2.3 TypeScript 严格配置:第一步就要关掉的坑
很多项目的 tsconfig 是从模板抄来的,strict没开或者开了但配了一堆豁免。t3code 的底线是strict: true,并且明确禁止下面这几项豁免:
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true } }noUncheckedIndexedAccess是很多人不知道的好东西。开着它之后,访问数组元素和对象索引的返回值类型会带上undefined,这逼着你处理越界和缺键的情况。刚开始会很不习惯,觉得代码啰嗦,但跑一段时间你会发现,线上 "cannot read properties of undefined" 这类错误会显著减少。
exactOptionalPropertyTypes更严格:可选属性一旦传进来,就不能显式传undefined。这个选项能消除一类很隐蔽的类型漂移问题,也让接口的契约感更强。
3. 核心规则解析:命名、组件、数据流、错误处理
3.1 命名规范:语义先于缩写
命名问题是评审里最容易吵起来的。t3code 定了几条硬规矩:
- 组件文件名一律用PascalCase(如
TodoCard.tsx),普通工具文件用camelCase。 - 类型名用 PascalCase,接口用
I前缀?不,不用前缀。TS 的接口和类型别名在语义上没有区别,加I前缀是没意义的。 - 布尔变量用
is、has、can开头:isLoading、hasError、canSubmit。看一眼名字就知道数据类型。 - 函数名用动词 + 名词:
fetchTodoList比getData好,handleSubmit比submit在回调场景里更能表明身份。 - 避免
data、result、list这种无名氏变量,至少得是todoListData、createResult。范围越大,名字要越长。
有一条特别的建议:不要用any作为类型。如果暂时不知道类型,用unknown然后做收窄。any等于把类型系统一票否决,而unknown逼着你在使用前先证明它是什么,哪怕改起来麻烦一点。麻烦是好事的开始。
3.2 组件设计:单一且薄
组件这块是重灾区。t3code 给组件定义了三个特征:薄、单、纯。
- 薄:组件文件原则上不超过 250 行。超过就拆。一个组件既管表格渲染又管弹窗编辑又管权限判断,这不是组件,是意大利面。
- 单:一个组件只做一件事。渲染列表的,只渲染列表;处理编辑弹窗的,只处理弹窗。
- 纯:组件内部尽量不直接发请求、不直接读写 localStorage。这些副作用一律放进
hooks里,组件只负责“拿数据 + 渲染 UI + 回调通知”。测试的时候,mock hooks 比 mock 一堆 DOM 事件简单得多。
举个例子,一个列表页由三层组成:
// TodoListPage.tsx —— 页面层,组合下面两层 function TodoListPage() { const { todoList, isLoading, toggleTodo } = useTodoList(); return <TodoListView list={todoList} loading={isLoading} onToggle={toggleTodo} />; }// TodoListView.tsx —— 纯展示层,不感知数据来源 function TodoListView({ list, loading, onToggle }: TodoListViewProps) { if (loading) return <Skeleton />; return ( <ul> {list.map((todo) => ( <TodoItem key={todo.id} todo={todo} onToggle={onToggle} /> ))} </ul> ); }// useTodoList.ts —— 逻辑层,数据获取与管理 function useTodoList() { const [todoList, setTodoList] = useState<TodoItem[]>([]); const [isLoading, setIsLoading] = useState(true); // fetch + toggle 逻辑... return { todoList, isLoading, toggleTodo }; }页面组件几乎不写业务逻辑,数据逻辑不掺 UI 渲染。改 UI 不动逻辑,改逻辑不碰 UI,这才是组件化的意义。
3.3 数据流:状态不要满天飞
团队协作中很容易出现一种病:props一层层往下钻,钻到第五层才被用到,中间层组件被迫带上没意义的props;或者状态哪个组件想用就useState一份,不同组件各存各的副本,数据一不同步就开始互相甩锅。
t3code 对状态管理有几条判断准则:
- 状态放在离使用者最近的公共祖先。如果只是父子组件共享,放在父组件;如果多个页面共享,放进全局 store;如果是服务端数据,优先用请求层缓存(React Query / SWR),而不是自己维护副本。
- 派生状态不要重复存。比如
filteredList是list和filter的派生值,直接用函数计算,不要新增 state 去同步。存了两份就总有不同步的一天。 - 不可变更新是底线。
state.todos.push(newTodo)这种写法直接判负。更新状态一律用新的数组/对象引用,这不仅是 React 渲染机制的需要,也是可调试性的保障。
数据流规整之后,团队里最常出现的“这个数据怎么变了”这类问题会少一半以上。
3.4 错误处理:不吞异常,不裸抛
项目中错误处理的混乱程度远超想象。常见的烂片情节是:try/catch捕获异常后,console.error一下就当无事发生;或者接口返回{ code: 1, msg: 'fail' },前端拿到之后不判断code直接渲染,页面就给你看原始 JSON。
t3code 规定了几点:
- 不吞异常。
catch块里至少要做一个用户可见的反馈,哪怕只是 toast;纯静默处理的 catch 必须在代码评审时被质疑。 - 不确定的异常类型要收窄。
catch (e)拿到的不是Error的保证。使用前先做判断:e instanceof Error,否则String(e)。 - 统一错误处理出口。数据请求层把接口错误转换为业务错误码,UI 层只面向错误码做展示,不层层嵌套 try/catch。
错误处理这块在我做评审 checklist 的时候是重点加分项。它不是功能逻辑,但决定了一个系统在异常时刻的表现是稳定还是崩盘。
4. 实操过程:拿“待办事项”模块走一遍完整流程
光说规则没用,我拿一个真实模块完整走一遍 t3code 的标准流程。这个模块就做一件事:拉取待办列表、勾选完成、删除待办。
4.1 类型先行:先写 schema
t3code 里,开始写任何实现前,最先落地的文件是todo.type.ts。这一步相当于给整个模块画图纸。
// todo.type.ts export type TodoStatus = 'active' | 'completed'; export interface TodoItem { id: string; title: string; status: TodoStatus; priority: 'low' | 'medium' | 'high'; createdAt: string; updatedAt: string; } export interface TodoListParams { page: number; pageSize: number; status?: TodoStatus; } export type CreateTodoInput = Pick<TodoItem, 'title' | 'priority'>; export type UpdateTodoInput = Pick<TodoItem, 'id'> & Partial<Pick<TodoItem, 'status' | 'priority' | 'title'>>;注意几个细节:status没有用string而是用了联合类型'active' | 'completed',这个字段的可选值就锁死了;CreateTodoInput用Pick表达了“创建时只能传这两项”;UpdateTodoInput用Partial表达了“更新时每一项都可选”。这些信息写出来,后端接口的契约也顺便清楚了。
4.2 测试先行:先写测试
类型定了,写实现之前先写一个核心逻辑的测试。这里选一个足够复杂的逻辑:对待办列表做优先级排序并分组。
// todo.test.ts import { describe, expect, it } from 'vitest'; import { groupTodoByPriority, sortTodos } from './todo.utils'; const mockTodos: TodoItem[] = [ { id: '1', title: 'a', status: 'active', priority: 'low', createdAt: '', updatedAt: '' }, { id: '2', title: 'b', status: 'active', priority: 'high', createdAt: '', updatedAt: '' }, { id: '3', title: 'c', status: 'completed', priority: 'medium', createdAt: '', updatedAt: '' }, ]; describe('sortTodos', () => { it('should sort by priority: high > medium > low', () => { const sorted = sortTodos(mockTodos); expect(sorted[0].id).toBe('2'); expect(sorted[1].id).toBe('3'); expect(sorted[2].id).toBe('1'); }); }); describe('groupTodoByPriority', () => { it('should group active todos by priority', () => { const grouped = groupTodoByPriority(mockTodos); expect(grouped.high).toHaveLength(1); expect(grouped.low).toHaveLength(1); expect(grouped.medium).toHaveLength(0); }); });先写测试的好处是,你会被迫在写实现之前考虑清楚“输入是什么、输出是什么、边界怎么处理”。测试跑红了,然后去写实现,直到跑绿。这个过程看起来多花了一点时间,省下来的却是未来重构时手动点页面的时间。
4.3 分层实现:从工具函数到 Hooks 再到组件
测试定义好行为后,实现按三层来:
工具函数层是最纯粹的逻辑,直接实现排序和分组。
// todo.utils.ts const priorityRank: Record<TodoItem['priority'], number> = { high: 3, medium: 2, low: 1 }; export function sortTodos(todos: TodoItem[]): TodoItem[] { return [...todos].sort((a, b) => priorityRank[b.priority] - priorityRank[a.priority]); } export function groupTodoByPriority(todos: TodoItem[]): Record<TodoItem['priority'], TodoItem[]> { return todos.reduce((acc, todo) => { if (todo.status === 'completed') return acc; acc[todo.priority].push(todo); return acc; }, { high: [], medium: [], low: [] }); }逻辑层是一个自定义 Hook,负责请求、状态、回调。
// useTodoList.ts export function useTodoList() { const [todoList, setTodoList] = useState<TodoItem[]>([]); const [isLoading, setIsLoading] = useState(true); const fetchTodoList = useCallback(async () => { try { setIsLoading(true); const data = await fetchTodoListApi(); setTodoList(sortTodos(data)); } catch (error) { toast.error('待办加载失败'); } finally { setIsLoading(false); } }, []); const toggleTodo = useCallback(async (id: string) => { const target = todoList.find((item) => item.id === id); if (!target) return; const nextStatus: TodoStatus = target.status === 'active' ? 'completed' : 'active'; await updateTodoApi(id, { status: nextStatus }); setTodoList((prev) => prev.map((todo) => (todo.id === id ? { ...todo, status: nextStatus } : todo))); }, [todoList]); useEffect(() => { fetchTodoList(); }, [fetchTodoList]); return { todoList, isLoading, toggleTodo, refetch: fetchTodoList }; }注意这个 hook 里没有直接碰 DOM,没有直接渲染,所有副作用都被封装起来,组件拿到的是一组稳定的数据和回调。
展示层组件的核心就是消费 hook 返回的数据,做渲染。这部分相对最简单,也是 t3code 觉得越简单越好的地方。
4.4 自动检查与评审 Checklist
模块写完后,要过三层检查:
- 格式化与 Lint:
eslint . --fix和prettier --write .,项目里直接内置规则,不给人纠结分号到底打不打的机会。 - 类型检查:
tsc --noEmit,确保全项目类型干净。 - 代码评审:按 t3code 的 checklist 逐条过。
评审 pressure 最大的是这几条:
- 有没有任何类型的
any漏网? - 有没有组件超过 250 行?
- 有没有 catch 块是空手道(吞异常)的?
- 有没有状态存在本可以计算出来的重复项?
- Props 有没有出现“透传黑洞”(组件传了 8 个 props 只为了再传给子组件)?
将这套 check 固化到 CI 的pnpm lint && pnpm type-check && pnpm test里以后,评审的重点可以从“风格争吵”转回“业务正确性”,团队氛围都改善了不少。
5. 实践中的常见问题与避坑实录
5.1 类型太深,反而拖慢开发
Type-first 推到一个极端,会出现一个反噬:类型定义一个文件写两百行,一个通用类型套三重泛型,改动一个业务字段要在十个文件里同步修改。这种过度设计会让团队悄悄绕开类型系统,开始出现as unknown as Foo这种妖孽写法。
我的建议是,类型要服务于数据边界,而不是服务于炫技。有实际意义的联合类型、满足业务边界的Pick/Omit、保证空值安全的收窄,这是值得写的。嵌套十层的工具类型、为了消除一个报错硬写的映射类型,不写也罢。代码评审时如果看到“读不懂的类型”,我会直接要求作者讲清楚它的业务含义,讲不清楚就拆掉。
5.2 组件抽象过度:为复用而复用
有一次我们为了省代码,把按钮、弹窗、下拉框的具体业务样式全抽到了一个BaseForm组件里,结果人人都在传巨大无比的 config 对象,组件内部的判断分支十几条,改动一个模块的需求时天天担心影响另外五个模块。后来把BaseForm拆回各个业务模块自己的组件,虽然每个模块多写了一百行,但再也没有那种牵一发动全身的恐惧。
t3code 对组件抽象的态度是:只抽被三个以上场景验证过的复用,不抽为了少写五行代码的复用。第一次需要直接复制代码,第二次你可以考虑抽,第三次再抽那才是成熟的时机。
5.3 测试写着写着变成了实现副本
有的测试写出来之后,如果改了实现,测试就必挂;但测试本身没断言几件业务该做的事。这种测试我把它叫“实现复读机”。比如组件里调了 setState 三次,测试就断言 setState 被调了三次;超时时间从 1000ms 改成 2000ms,测试也跟着改。这类测试没有守护业务,只会拖累重构。
正确姿势是测试行为而非实现。断言“点击按钮后,待办列表出现了新的项”,而不是断言“点击按钮后调用了某个函数两次”。行为不变,重构随便改,测试依然绿,这才是测试真正该有的价值。
5.4 团队里有人不遵守规范怎么办
这是最现实的问题,光有文档没有执行力,规范等于废纸。我的经验是先把工具钳制做到位:rules 直接写死在 ESLint 配置里,不给你单文件注释豁免的机会。然后,新项目或者新模块必须从模板脚手架生成,不手动从零搭,让目录结构和默认配置从一开始就符合规范。最后,评审意见只对事不对人,拿着 checklist 说话,不搞个人审美。
还有一个容易被忽略的点:渐进式落地。老项目上万行存量代码不可能一次性全改完。t3code 的做法是“新代码必须合规,老代码按模块逐批重构”。先挑一个核心模块做样板,跑顺了再铺开。不要一开始就开全量技改,那只会让大家抵触。
5.5 一个自查清单模板
这是我项目里实际在用的精简版自查清单,放在PR_TEMPLATE.md里,每次提 PR 都要过一遍:
- 类型定义是否完整表达了数据边界?
- 是否存在
any或绕过类型检查的断言? - 组件是否符合“薄、单、纯”?
- 所有异步路径是否都有错误处理?
- 状态是否存放在最合理的层级?
- 测试是否覆盖了核心业务行为而非实现细节?
- 是否引入了多余的依赖或过度抽象?
这七条过完,PR 的质量基本就有底了。
这套 t3code 规范在团队里跑了三个多月,最大的变化不是代码变“标准”了什么,而是评审时大家终于开始聊业务逻辑和性能,而不是纠结写法。新同学入职看模板比看文档学得快,老同学也不用靠记忆和感觉去判断一个 PR 是否合格。我个人在实际使用中最深的体会是:编码规范的本质不是限制自由,而是把那些反复踩坑的地方用约定焊死,让大家把有限的精力花在真正需要创造力的事情上。如果你也在为团队代码风格发散、评审扯皮、重构战战兢兢而头疼,与其装一堆插件,不如从定一套自己的 t3code 开始。