☰
t3code:TypeScript+Tailwind+tRPC的可维护全栈编码规范
2026/10/26 17:17:40 网站建设 项目流程

1. t3code立项:三个技术栈成员凑在一起想解决什么问题

1.1 从“能跑”到“能维护”的项目蜕变

我第一次意识到需要t3code这套东西,是在接一个半成品Next.js项目的时候。代码能跑,页面能开,tRPC接口调得通,但整个项目就像一间住了三年的出租屋——东西都在原来的位置,可新来的租客永远找不到插座在哪里。组件散落七八个文件夹,tRPC router把业务逻辑全部写在handler里,Tailwind类名随便堆,类型定义一半靠any兜底。两个维护者交接的时候,光是梳理“这段逻辑到底属于谁”就花了一周。

t3code这个项目,本质上不是为了发明新东西,而是把TypeScript、Tailwind CSS、tRPC这三套技术栈在真实业务里的协作方式沉淀成一套可复用的约定。它要回答的问题很朴素:一个项目从第一天开始就按统一规则生长,一年后往回看,为什么没有变成又一个技术债堆积场。这个问题比想象中难回答,因为三套技术栈各自都有自己的最佳实践,凑在一起时却经常互相打架。比如Tailwind的utility-first理念和组件封装之间的边界,比如tRPC的类型推断能力在React Query缓存层被稀释的问题,这些单看一边都合理,放在同一个项目里就需要有人出来定规矩。

1.2 t3code到底管什么:规范边界与不做什么

很多团队一听到“编码规范”就条件反射地想到ESLint配置和Prettier格式,觉得装上工具就完事了。t3code不是这个路子。它只做三件事:目录结构怎么划分、跨端类型怎么共享、业务代码怎么分层。

目录结构是第一道关卡。很多人觉得目录不过是文件夹摆放的问题,实际上它决定了团队所有人脑中的项目地图。在t3code里,我们约定按“路由域”划分页面代码,按“业务域”划分逻辑代码,组件库单独成域。听起来抽象,展开之后就是一套可以直接抄的文件夹骨架。

跨端类型共享是第二件事。t3code的核心痛点是:tRPC本来给了你全栈类型安全的能力,但大多数项目并没有利用好。前端需要知道后端返回什么类型,后端需要知道前端提交了什么数据,如果两头各写一份interface,就等于把tRPC最值钱的部分扔掉了。t3code里规定了数据验证层、类型导出层、API响应包装层各自的位置和写法,避免出现“前后端类型对不上”这种荒唐但又极其常见的事故。

业务代码分层是第三件事。tRPC太方便了,方便到大家很容易把数据库查询、权限校验、日志记录、第三方服务调用全部塞进一个handler。t3code里面用“processor-service-handler”三层来拆,让每个函数都能被测试、被复用、被替换。这也是整个规范里推行阻力最大的一项,因为初看确实多写不少样板代码,但三个月后回头看,收益会远远大于成本。

t3code不做什么也同样重要。它不规定组件库用什么,不强制monorepo,不碰CI流水线,不搞代码生成器。它的定位是一层薄薄的项目骨架约定,而不是一把抓的全能框架。加的东西越少,团队越愿意接受,落地成本越低。

2. 初始化一个t3code工程:目录划分和类型约定的第一道坎

2.1 创建项目时最容易忽略的tsconfig细节

按照t3code规范搭一个新项目,第一步不是mkdir,而是把tsconfig想清楚。很多脚手架默认生成的tsconfig在单个包场景下没有大问题,但t3code工程里有两类代码特别依赖编译器配置:跨端类型引用和路径别名。

先说路径别名。tRPC的类型从server目录延伸到client目录是家常便饭,如果只能用相对路径引用,代码里会出现一堆../../../server/trpc/router这种让人晕眩的字符串。t3code约定用@/前缀指向项目根目录下的src,用@server/、@client/、@shared/来区分三类代码。这样配置的意图不只是好看,而是让import路径直接反映代码所属的域,读代码的时候不用翻目录树就知道这个模块的职责范围。

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@server/*": ["src/server/*"], "@client/*": ["src/client/*"], "@shared/*": ["src/shared/*"] } } }

这里有个容易踩的坑:路径别名配好了,但tsconfig.json里没有同时配置"customConditions"或者"moduleResolution": "bundler",导致tRPC库的moduleResolution策略和你的别名解析方式冲突。实测下来,新项目直接使用"moduleResolution": "Bundler"最省事,它兼容ESM和CommonJS的模块解析策略,也适配Next.js的打包流程。如果你还在用"moduleResolution": "Node",大概率会在引入tRPC的server类型时遇到奇怪的模块找不到报错。

再说strict这个开关。t3code的底线是必须开启"strict": true,并且noUncheckedIndexedAccess也要打开。后者看起来严格得有点烦人,但它在数组和对象索引取值时强制要求你处理undefined,配合tRPC的运行时校验,可以把很多“明明不可能为空但实际就是空”的线上事故消灭在编译阶段。我们团队刚启用这个配置时平均每天多改二十多行代码,但上线后接口侧的TypeError减少了大约三分之一。

2.2 目录结构:按路由收敛还是按领域收敛

目录结构这个问题,t3code选择了一个折中方案:页面层按路由收敛,逻辑层按领域收敛。

以Next.js App Router项目为例,app目录下每一个路由文件夹就是一个业务入口,t3code约定路由文件只做三件事:排列页面组件、组装该页面需要的服务端依赖、定义页面级的数据加载逻辑。其他一切业务代码,都放在src/features目录里按领域组织。

src/ app/ (marketing)/ # 营销页路由域 (dashboard)/ # 控制台路由域 features/ auth/ api/ components/ types.ts billing/ api/ components/ types.ts shared/ components/ lib/ server/ db/ auth/ trpc/ router/ context.ts

为什么不在features里也按路由继续套一层?因为业务域是稳定的,路由是经常变的。一个认证模块今天在/login页面用,明天可能要在/settings/account弹窗里用。如果按路由放,迁移时要动整个文件夹;按领域放,迁移时只需要在页面层改import路径。这是我踩过最痛的一次坑:接手一个项目时所有代码都按页面路径组织,结果产品要调整信息架构,路由大面积变更,每个组件都要人工挪位置,还挪出了好几处import循环。换到按领域组织之后,这类重构基本变成了几分钟的问题。

组件层的规范也在这个目录结构里落地。features/auth/components下只放与认证业务相关的组件,跨业务复用的进shared/components。判断标准很简单:如果一个组件被两个以上业务域引用,就提升到shared;如果只为一个业务域服务,就留在features里。这个规则简单到不需要开会讨论,新人看一眼目录就能理解。

3. tRPC层代码怎么写才不翻车:路由、校验和错误处理

3.1 拆分router的心得:别让context变成一个超大对象

tRPC的router设计直接决定了API层的可维护性。t3code遵循一个铁律:一个接口一个procedure,一个业务域一个子router,顶层router只做聚合。

// src/trpc/router.ts import { createTRPCRouter } from "@server/trpc/trpc"; import { authRouter } from "@server/features/auth/api/router"; import { billingRouter } from "@server/features/billing/api/router"; export const appRouter = createTRPCRouter({ auth: authRouter, billing: billingRouter, }); export type AppRouter = typeof appRouter;

这个设计本身不稀奇,真正容易出问题的是context的写法。很多项目把所有需要用到的依赖都塞进context,session、db、redis、logger、第三方SDK全放一个对象里,表面上方便,实际上每个procedure都依赖全部context字段,导致任何一个中间件改动都会引发连锁反应。

t3code的做法是让context只保留请求级的数据,比如session、cookies、headers、请求ID,而把db、redis这类全局单例放在procedure的外部闭包里:

export const createTRPCContext = async ({ req }: { req: Request }) => { const session = await getServerSession(ctx.req); return { session, req, }; };

db这类依赖直接从模块导入,而不是挂在context上。好处有三个:第一,context变得更轻,序列化和测试更简单;第二,procedure的输入输出和全局依赖解耦,单元测试时不需要mock一个完整的context对象;第三,context里字段越少,越不容易在中间件里意外覆盖数据。这个调整看起来微不足道,但对我们项目来说,它让很多之前很别扭的测试写法变得自然起来。

3.2 输入校验和错误码:服务端与前端如何共享一套约定

tRPC在输入校验上推荐使用Zod,这几乎是目前最顺畅的组合。t3code的约定是每个procedure的入参schema必须独立命名并导出,而不是内联写一个匿名Zod对象。

export const updateProfileSchema = z.object({ username: z.string().min(3).max(20), avatarUrl: z.string().url().optional(), }); export const updateProfile = protectedProcedure .input(updateProfileSchema) .mutation(async ({ ctx, input }) => { // ... });

为什么要独立命名?因为前端要做完全相同的客户端校验,需要引用同一个schema。tRPC支持从server端导出类型,但运行时校验依然需要一份可执行的Zod对象。把schema单独导出后,前端可以直接import { updateProfileSchema } from "@server/features/auth/api/schema",这样前端拿到的校验规则和服务端完全一致,永远不会出现“前端允许空字符串,后端报错”这类双标准问题。

错误处理是t3code里另一块重点。tRPC的默认错误信息面向开发者,直接暴露给用户既不够友好又可能泄露内部细节。t3code约定了一套应用层错误码体系:在shared目录里定义一个AppError类和一个ErrorCode枚举,所有业务错误都通过TRPCError包装,但code字段统一从枚举里取。

export enum AppErrorCode { INVALID_INPUT = "INVALID_INPUT", UNAUTHORIZED = "UNAUTHORIZED", FORBIDDEN = "FORBIDDEN", NOT_FOUND = "NOT_FOUND", DUPLICATE_ENTRY = "DUPLICATE_ENTRY", RATE_LIMITED = "RATE_LIMITED", INTERNAL = "INTERNAL", } export const toTRPCError = (appError: AppError): TRPCError => { return new TRPCError({ code: mapErrorCode(appError.code), message: appError.message, cause: appError.cause, }); };

前端拿到错误后,根据AppErrorCode做映射,而不是parse后端传来的message字符串。这样前后端对“什么错误长什么样”达成一份契约,错误处理逻辑可以被抽取成统一的hook,而不是每个页面自己写一个try-catch分支。我在实际项目中见过太多团队在错误处理上各写各的,有的把后端message直接alert出来,有的把HTTP状态码当业务状态码用,这些混乱都源于缺一份共享的错误约定。t3code把这一层固定下来之后,新同学写的错误处理代码和老手的风格几乎完全一致。

4. Tailwind CSS在t3code里的落地姿势:从类名到设计令牌

4.1 类名排序和组合的团队约定

Tailwind在带来便利的同时,一个隐蔽的问题是类名越长,可读性越差。每个类名本身语义清楚,但多个类名堆在一起,视觉上就像一串密码。t3code规定了两条原则:用cn函数合并条件类名,按固定顺序排列类名。

// src/shared/lib/cn.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

clsx负责处理条件字符串、数组和对象形式的类名,tailwind-merge负责解决类名冲突。比如"bg-red-500 bg-blue-500"这种两个背景色同时存在的场景,tailwind-merge会保留后者,而不是像原生Tailwind那样靠CSS文件中的顺序决定哪个生效。整个函数没有任何魔法,但它在t3code项目里被用了上千次,是组件样式的唯一出口。

类名排序规则不搞穷举,只约定一个从外到内的阅读顺序:布局类(flex、grid、absolute)优先,然后是尺寸类(w、h、p、m),再是视觉类(bg、text、border、shadow),最后是交互和状态类(hover、focus、disabled)。这个顺序没有官方标准,但它贴近CSS盒模型的思考习惯:先决定元素怎么摆放,再决定它多大,再决定它长什么样。团队内部review代码的时候,一眼扫过去就知道这个元素的外层布局和状态交互分别是什么,不需要逐字读类名。

4.2 基于Tailwind配置实现主题化与类型安全的类名

t3code对Tailwind的第二个要求是设计令牌先行。很多项目直接用Tailwind默认配置,色值全写十六进制,一年后产品和设计换了一轮主题色,项目里到处是难以定位的颜色代码。

t3code在tailwind.config.ts里维护一套语义化的设计令牌,颜色只以语义命名暴露给业务代码:

import type { Config } from "tailwindcss"; const config: Config = { darkMode: "class", theme: { extend: { colors: { surface: { DEFAULT: "hsl(var(--surface))", muted: "hsl(var(--surface-muted))", strong: "hsl(var(--surface-strong))", }, content: { DEFAULT: "hsl(var(--content))", muted: "hsl(var(--content-muted))", inverse: "hsl(var(--content-inverse))", }, accent: { DEFAULT: "hsl(var(--accent))", hover: "hsl(var(--accent-hover))", }, }, }, }, };

这份配置本身不直接写死色值,而是通过CSS变量引用,这样暗色模式下只需要替换:root里的CSS变量,就能实现整体换肤。业务代码里永远不出现text-[#3b82f6]这类魔法值,统一写成text-accent。这样做的收益在接入暗色模式时最大:全站颜色切换变成一次CSS变量覆盖,而不是几十个文件的类名修改。

类型安全方面,Tailwind的自动补全和类型检查需要tailwindcss-animate和安装@types/tailwindcss等配套。t3code会在tsconfig.json中启用"plugins": [{ "name": "next" }]这种Next.js集成配置,确保Tailwind的类名不会被TypeScript当成普通字符串忽略。很多人不知道Tailwind v3.3之后提供了tw的模板字符串类型,t3code建议在关键接口组件的外部props上使用React.ComponentProps推导,让动态类名在编译期就能被捕捉到拼写错误。

动态类名拼接是另一个高频问题。Tailwind的JIT编译器运行时扫描源码里的完整类名字符串,如果代码里写bg-${color}-500这种动态拼接,编译器无法识别,样式就不会出现在产物CSS里。t3code的方式是全量映射表:把可能用到的颜色类和状态类枚举出来,运行时只做查表:

const badgeColorMap = { success: "bg-emerald-500 text-white", warning: "bg-amber-500 text-black", error: "bg-red-500 text-white", } as const; export const Badge = ({ variant }: { variant: keyof typeof badgeColorMap }) => { return <span className={cn("rounded px-2 py-1 text-sm", badgeColorMap[variant])}>{variant}</span>; };

这样既保证了类名可以被JIT正确扫描到,又保留了对业务层的语义抽象。

5. 三个曾经卡住团队的坑:类型推导、泛型和样式覆盖

5.1 tRPC infer类型在组件拆分后的失效问题

tRPC最诱人的特性是自动推导类型。useQuery返回的数据类型,理论上不需要你手动声明任何interface。但在真实项目中,这个推导很容易在组件拆分的过程中悄悄失守。

典型场景:页面组件里使用useQuery拿到数据后,把一整块内容交给子组件渲染。一开始子组件的props直接使用RouterOutput的某个字段类型,一切正常。后来需求迭代,这个子组件需要在另一个场景里复用,它接收的数据来源不再是同一个query,而可能是两个query合并后的结果。此时开发者的习惯性操作是“自己写一个类型”,于是手写interface,字段对不上时再加any。t3code的拦截规则很简单:任何组件的props类型,凡是和tRPC返回数据相关的,必须从RouterOutput显式派生,禁止手写同结构类型。

具体做法是定义一批常用的工具类型:

import type { AppRouter } from "@server/trpc/router"; import type { inferRouterOutputs } from "@trpc/server"; export type RouterOutput = inferRouterOutputs<AppRouter>; export type SessionUser = RouterOutput["auth"]["me"]; export type BillingInvoice = RouterOutput["billing"]["listInvoices"]["items"][number];

这样即使后端字段改名,前端编译立刻报错,不会等到运行时才发现数据结构对不上。这个约束表面上是“多写几行类型代码”,实际上是利用编译器做了全栈接口回归测试。我见过太多项目废掉tRPC的类型推导能力之后,又重新发明了一套手写DTO的流程,效率和安全性都远远不如原生推导。

5.2 Tailwind的purge机制与动态类名:为什么不能用字符串拼接

关于Tailwind动态类名的问题,上面已经提到JIT扫描的原理。实际操作中,团队里最容易踩雷的是条件渲染类名:

<div className={`p-4 ${isActive ? "bg-accent" : "bg-surface"}`}>

这看起来没有任何问题,因为两个类名都是完整字符串。但一旦有人为了简化代码写成bg-${isActive ? "accent" : "surface"},样式立刻消失。更隐蔽的是某些配置从数据库或CMS里读取颜色值,想在运行时把theme-color-${value}作为类名注入组件。这种写法Tailwind永远无法提前扫描到,产物里不会有对应样式。

t3code给出的统一解法是把所有条件变体收敛到variant语义,通过组件内部的映射表完成,配合上面提到的cn函数。这样不仅解决了purge问题,还让组件对外暴露的API保持简单:调用方只需要传一个语义化的variant="success",而不用关心底层色值。

5.3 Context闭环泄漏:handler拿不到session时的排查链路

最后一个坑属于tRPC的context闭包问题。t3code在protectedProcedure中要求必须校验session,但团队曾遇到一个诡异现象:同一个token下,部分接口能拿到session,部分接口拿不到,且报错信息指向session为空。

排查链路是这样的:先看client端请求头,发现Authorization被正常带上了;再看server端中间件顺序,发现getServerSession被放在一个较晚执行的hook里;最后检查的是Next.js的App Router模式下Request对象是否被中间件误吞。真正的原因很隐晦:我们在一处中间件里把headers对象做了浅拷贝,导致后续读取时丢失了部分自定义header。t3code最终在error handler里增加了请求快照日志,把header、cookies、session状态、输入参数全部记录下来,这类问题才从“玄学”变成可查的日志记录。

这个问题给团队的教训是:context构建是公共逻辑,任何中间件改动都可能影响所有procedure。t3code因此规定,凡是涉及context调整的PR,必须同时跑一遍全量集成测试,不能只测单个接口。也为这个坑单独写了一个测试用例,保证类似问题回归时能被立刻发现。

6. 关于t3code的落地节奏和后续扩展方向

如果你正准备把t3code这套规范引入自己的项目,我给的建议是不要一次全量切换,而是按“路由层 → API层 → 样式层”的顺序推进。先让目录结构和路径别名落地,这一步不会破坏现有代码,只是铺好了骨架;然后逐步把tRPC的router和schema迁移到新约定,同时引入错误码体系;最后再统一Tailwind样式规范。每一层稳定运行两周后再动下一层,团队的压力会小很多。

我在实际维护t3code的过程中最深的体会是:编码规范的价值不在于惩罚不遵守规则的人,而在于让新人从第一天起就走在正确的路径上。很多团队把规范当成一份需要背诵的文档,但t3code把规则变成了目录结构、类型工具函数和共享schema这样看得见摸得着的东西,遵守成本会低非常多。如果你的项目也同时使用Next.js、tRPC和Tailwind,可以试着按同样的思路,先把你最常写的那一类页面和接口梳理成模板,你会发现后续功能的开发节奏明显变快,而且code review的争论会少很多。

我后续打算把t3code的开源脚手架也补上,加入Phosphor Icons等常用依赖的默认配置,再配一套基于Playwright的端到端测试模板。技术栈的热度总会变,但“让上下文轻一点、让类型共享起来、让样式有边界”这三条原则,我觉得在很长一段时间内都不会过时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询