资讯详情

资讯详情

t3code工程实践:T3技术栈全链路类型安全与项目结构指南

1. 从“t3code”这个代号说起它到底指什么第一次看到“t3code”这个词很多人会一头雾水。它不像“Python入门”或“React教程”那样直白更像是一个圈内人才懂的暗号。我最初接触这个词是在一个技术社群里有人发了一句“t3code跑通了”底下跟了一串“求配置”。当时我翻遍了主流搜索引擎能找到的有效信息非常零散大部分是零碎的代码片段和语焉不详的讨论。后来花了不少时间梳理才慢慢拼出它的全貌。简单来说t3code是一套围绕“T3技术栈”展开的代码组织方式与工程实践约定。这里的“T3”通常指向一个特定的全栈开发组合——TypeScript、tRPC、Tailwind CSS再加上一个现代前端框架常见的是Next.js。这套组合在近几年的独立开发者和中小团队中流行度上升得很快原因很直接它把类型安全从数据库一路贯穿到前端UI省掉了大量手写接口类型和联调扯皮的时间。而“code”这个后缀强调的是落地层面的代码结构、目录约定和复用模式而不是单纯的技术选型清单。你可能会问为什么非要给这套东西起个代号我的理解是当一套技术组合的实践足够成熟、足够有辨识度时社区自然会需要一个简短的标签来指代它。就像“LAMP”代表LinuxApacheMySQLPHP一样t3code代表的是“用T3栈写出来的、经过工程化沉淀的代码形态”。它解决的核心问题是在保证端到端类型安全的前提下如何让项目结构清晰、模块边界明确、新人能快速上手。这篇文章适合谁看如果你正在用或打算用TypeScript做全栈项目如果你厌倦了前后端类型对不齐的烦恼如果你想知道一套成熟的T3项目到底该怎么组织目录、怎么划分模块、怎么处理认证和数据库那接下来的内容应该对你有用。我会从核心原理讲起然后拆解目录结构、关键配置、常见坑点最后分享一些我在实际项目里踩出来的经验。全文基于我对这类项目的理解和实践不涉及任何特定平台的绑定。2. T3栈的底层逻辑为什么这套组合能解决类型安全问题2.1 类型安全的全链路是怎么打通的要理解t3code的价值得先搞清楚一个根本问题传统全栈开发里类型信息是在哪里断掉的。假设你用React写前端用Node.js写后端数据库用PostgreSQL。前端定义了一个User接口后端也定义了一个User接口数据库里还有一张users表。这三处的“User”看起来一样实际上各写各的。后端改了一个字段名前端不知道直到运行时接口报错才发现。这就是典型的“类型断裂”。T3栈的思路是让类型信息从数据库schema自动流向API层再自动流向客户端调用处。具体怎么做到的数据库层用Prisma或Drizzle这类ORM它们能从schema生成TypeScript类型。API层用tRPC它利用TypeScript的泛型推导把后端函数的输入输出类型直接暴露给前端。前端调用时编辑器能实时提示参数类型和返回值结构写错了立刻标红。整个过程不需要手写任何接口类型定义也不需要生成中间文件。我举个具体的例子。假设后端有一个getUserById的tRPC procedure输入是{ id: string }返回是User类型。前端这样调用const { data } trpc.user.getUserById.useQuery({ id: abc123 });此时data的类型会被自动推导为User | undefined。如果你把id写成数字编辑器会直接报错。如果后端把返回类型改了前端所有调用处会立刻出现类型错误提示。这种“改一处、全链路感知”的体验是T3栈最核心的卖点。2.2 tRPC相比REST和GraphQL的取舍有人会问GraphQL也能做到类型安全为什么还要用tRPC这里涉及一个关键的取舍。GraphQL的类型安全依赖于schema定义和代码生成步骤你需要维护一份SDL然后跑codegen生成前端类型。多了一个中间环节就多了一层维护成本。而tRPC完全基于TypeScript的类型推导没有schema文件没有代码生成后端函数即接口定义。对于TypeScript monorepo项目来说这种“零中间层”的体验非常顺滑。当然tRPC也有明显的局限。它天然绑定TypeScript如果你的客户端是Swift或Kotlin就用不了。它也不适合对外暴露公共API因为它的类型推导依赖服务端代码。所以t3code的适用场景很明确前后端都是TypeScript、团队规模不大、迭代速度要求高、不需要对外开放API。在这个边界内它的开发效率优势非常明显。2.3 Tailwind和Next.js在其中的角色Tailwind CSS在T3栈里的作用经常被低估。很多人觉得它只是个CSS框架但在t3code的语境下它承担了“样式与组件同构”的职责。因为Tailwind的类名直接写在JSX里组件的样式和逻辑是绑定在一起的复制一个组件到另一个项目样式不会丢。这对于快速搭建UI、保持设计一致性很有帮助。Next.js则提供了全栈的运行时基础。它的API Routes或Server Actions可以承载tRPC的handler它的文件路由系统让页面组织变得直观它的SSR/SSG能力让首屏性能有保障。更重要的是Next.js的app目录模式支持React Server Components这意味着部分数据获取可以放在服务端完成减少客户端的请求瀑布。T3栈和Next.js的结合本质上是在“类型安全”之外再叠加一层“渲染性能”的优化。3. 一个标准t3code项目的目录骨架长什么样3.1 顶层目录的划分逻辑我见过不少T3项目目录结构五花八门但跑得顺的项目往往遵循一些共同的约定。下面这个结构是我自己反复调整后觉得比较顺手的版本├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── app/ │ │ ├── (auth)/ │ │ ├── (main)/ │ │ └── api/ │ ├── components/ │ │ ├── ui/ │ │ └── features/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth/ │ │ └── db/ │ ├── lib/ │ ├── styles/ │ └── types/ ├── public/ ├── .env ├── next.config.js ├── tailwind.config.ts └── tsconfig.json这个结构的核心思路是按职责分层而不是按文件类型分层。src/server下面集中放所有服务端代码包括tRPC路由、认证逻辑、数据库客户端。src/app是Next.js的页面和API入口。src/components放React组件其中ui放通用基础组件features放业务组件。src/lib放工具函数和共享逻辑。为什么要把server和app分开因为Next.js的app目录里既有服务端代码也有客户端代码边界容易模糊。把tRPC路由和数据库逻辑单独抽到server目录能强制形成一道心理边界这里的代码只在服务端运行不要在前端组件里直接import。3.2 tRPC路由的嵌套与合并tRPC的路由组织是t3code里最需要花心思的部分。一个常见的做法是每个业务领域一个router文件然后在root.ts里合并。// src/server/api/routers/user.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const userRouter createTRPCRouter({ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.db.user.findUnique({ where: { id: input.id } }); }), updateProfile: protectedProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.user.update({ where: { id: ctx.session.user.id }, data: { name: input.name }, }); }), });然后在root.ts里合并// src/server/api/root.ts import { createTRPCRouter } from ./trpc; import { userRouter } from ./routers/user; import { postRouter } from ./routers/post; export const appRouter createTRPCRouter({ user: userRouter, post: postRouter, }); export type AppRouter typeof appRouter;这里的关键点是AppRouter类型的导出。前端通过import type { AppRouter } from ~/server/api/root拿到这个类型tRPC的client就能自动推导出所有路由的输入输出类型。这个类型导入是纯类型导入不会把服务端代码打包进客户端。3.3 组件目录的拆分策略components目录的拆分我踩过坑。一开始我把所有组件都放在一个components文件夹里结果很快就乱了。后来改成两层ui放无业务逻辑的通用组件比如Button、Input、Modal、Cardfeatures放带业务逻辑的组件比如UserProfile、PostList、CommentForm。这样拆的好处是ui组件可以跨项目复用它们只依赖props不依赖tRPC或数据库。features组件可以自由调用tRPC hooks因为它们本来就是业务的一部分。当你想把某个功能模块抽成独立包时features下的目录可以直接搬走依赖关系清晰。还有一个细节features下的组件尽量按业务领域建子目录比如features/user/、features/post/。每个子目录里可以放组件、hooks、工具函数形成一个自包含的模块。这样当业务增长时你不会在一个巨大的components文件夹里大海捞针。4. 环境搭建与核心配置从零跑通第一个t3code项目4.1 初始化项目时最容易忽略的三个配置现在社区里有现成的脚手架工具可以一键生成T3项目但自动生成的东西往往让人知其然不知其所以然。我建议至少手动过一遍关键配置这样出了问题才知道去哪里找。第一个容易忽略的是tsconfig.json里的路径别名。T3项目通常用~或指向src目录这样import路径不会出现../../..这种反人类写法。配置大概长这样{ compilerOptions: { baseUrl: ., paths: { ~/*: [./src/*] } } }配完之后import { Button } from ~/components/ui/button就能正常解析。但要注意Next.js的app目录和src目录的关系需要确认清楚如果app在src下面路径别名要相应调整。第二个是环境变量的类型声明。T3项目通常用t3-oss/env-nextjs这个包来管理环境变量它能在构建时校验变量是否存在、格式是否正确。配置在src/env.js里import { createEnv } from t3-oss/env-nextjs; import { z } from zod; export const env createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, });这样做的好处是如果忘了配某个环境变量项目启动时会直接报错而不是等到运行时才出现莫名其妙的undefined。第三个是Prisma的客户端单例模式。在开发环境下Next.js的热重载会反复执行模块代码如果每次都new PrismaClient()数据库连接数会迅速耗尽。标准做法是在src/server/db.ts里做全局单例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error, warn] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个模式在Prisma官方文档里有详细说明但很多新手会忽略导致开发时数据库连接报错。4.2 tRPC上下文与认证的衔接方式tRPC的context是连接认证系统和数据库的桥梁。在src/server/api/trpc.ts里你需要定义createTRPCContext函数它接收每个请求的原始信息返回一个包含db和session的对象。import { initTRPC, TRPCError } from trpc/server; import { type Session } from next-auth; import { db } from ../db; interface CreateContextOptions { session: Session | null; } export const createTRPCContext async (opts: CreateContextOptions) { return { db, session: opts.session, }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const createTRPCRouter t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这里的关键设计是protectedProcedure。它通过中间件检查session是否存在如果不存在就抛UNAUTHORIZED错误。所有需要登录的接口都用protectedProcedure定义这样认证逻辑只写一次所有路由自动继承。认证系统本身通常用NextAuth现在叫Auth.js它和tRPC的集成点在于NextAuth负责处理登录流程和session管理tRPC的context从NextAuth的getServerSession里拿session。两者通过createTRPCContext的调用处衔接通常在Next.js的API handler里完成。4.3 数据库schema设计中的类型推导技巧Prisma schema是类型链路的起点。你在schema里定义的每个modelPrisma都会生成对应的TypeScript类型。这些类型会通过tRPC的返回类型自动流向客户端。model Post { id String id default(cuid()) title String content String? published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }这个Postmodel会生成Post类型包含所有字段。当你在tRPC的postRouter里返回一个Post对象时前端拿到的data类型就是Post。如果你在schema里加了一个字段Prisma重新生成后前端所有用到这个类型的地方都会自动更新。有一个技巧值得分享用Prisma的select和include来精确控制返回类型。比如你不想把用户的密码哈希返回给前端可以在查询时用select排除掉。这样生成的类型里就不会包含敏感字段前端也无法访问。const user await ctx.db.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, email: true, image: true }, });这样返回的类型就是{ id: string; name: string | null; email: string; image: string | null }不包含passwordHash。类型安全在这里不仅是防错也是防泄露。5. 实际开发中那些文档不会告诉你的坑5.1 类型推导在复杂嵌套路由下的性能问题tRPC的类型推导很强大但当路由数量增长到几十个、每个路由的输入输出类型都很复杂时TypeScript的类型检查速度会明显下降。我遇到过一次项目里有大概四十多个procedure编辑器里每次保存文件都要等三四秒才能出类型提示。后来排查发现问题出在AppRouter类型的递归推导上。解决办法有两个。一是拆分router把不相关的业务路由分到不同的子router里减少单个router的复杂度。二是避免在procedure的返回类型里使用过深的嵌套对象如果某个接口返回的数据结构特别复杂考虑用zod定义明确的输出schema而不是让TypeScript去推导。还有一个相关的坑在monorepo里如果前端和后端在不同的package里AppRouter类型的导入路径会变得很长TypeScript需要跨package解析类型。这种情况下建议在共享package里放一个types.ts只导出AppRouter类型前端从那里导入减少解析开销。5.2 客户端组件与服务端组件的边界混淆Next.js的app目录引入了React Server Components的概念这给t3code项目带来了新的复杂度。默认情况下app目录下的组件是Server Component它们不能使用useState、useEffect也不能调用tRPC的useQueryhook。如果你不小心在Server Component里写了客户端代码会看到一堆莫名其妙的报错。我的经验是在文件顶部明确标注use client并且尽量把客户端组件推到叶子节点。比如一个页面整体是Server Component负责数据预取和布局只有需要交互的部分比如一个点赞按钮、一个表单才抽成Client Component。这样既能享受Server Component的性能优势又能保留交互能力。tRPC在Server Component里的用法和Client Component不同。Server Component里不能直接用trpc.xxx.useQuery()而是要用createCaller或者api.xxx()的方式直接调用服务端函数。这个切换点容易搞混建议在项目初期就定好规范哪些页面用Server Component哪些用Client ComponenttRPC的调用方式分别是什么。5.3 数据库迁移与类型同步的时序问题Prisma的迁移流程是改schema → 跑prisma migrate dev→ 生成新客户端 → TypeScript类型更新。这个链条里有一个时序问题如果你改了schema但忘了跑迁移或者迁移失败了但客户端已经生成类型和数据库实际结构就会不一致。我踩过一次坑在schema里加了一个可选字段跑迁移时因为数据库里有脏数据导致迁移失败但Prisma客户端已经生成了包含新字段的类型。结果代码里访问这个字段时类型检查通过运行时却报错说列不存在。排查了半天才发现是迁移没成功。避免这个问题的办法是每次改schema后先确认迁移成功再重新生成客户端。可以在package.json里加一个脚本把迁移和生成绑在一起{ scripts: { db:migrate: prisma migrate dev prisma generate } }另外在CI流程里要加一步检查确保迁移文件和schema是同步的。Prisma提供了prisma migrate diff命令可以对比schema和数据库的实际差异适合在CI里做校验。5.4 生产环境构建时的类型检查策略Next.js的next build默认会跑TypeScript类型检查。在t3code项目里这个检查可能会很慢因为tRPC的类型推导本身就耗时。如果每次构建都全量检查CI时间会拉得很长。我的做法是在本地开发时用tsc --noEmit --watch做增量检查在CI里用next build做全量检查但把类型检查单独抽成一个job。这样构建和类型检查可以并行节省时间。另外可以在next.config.js里配置typescript.ignoreBuildErrors为false默认就是false确保构建时不会跳过类型错误。还有一个细节如果项目里用了zod做输入校验确保zod的版本在前后端一致。我曾经遇到过前端用zod 3.21、后端用zod 3.22导致某些校验行为不一致类型推导也出现了偏差。把zod固定在同一个版本能避免这类问题。6. 从能跑到好用t3code项目的优化与扩展思路6.1 用中间件统一处理日志和错误tRPC的中间件机制很适合做横切关注点。除了认证还可以加日志中间件、错误上报中间件、性能计时中间件。比如一个简单的日志中间件const loggerMiddleware t.middleware(async ({ path, type, next }) { const start Date.now(); const result await next(); const duration Date.now() - start; console.log([trpc] ${type} ${path} - ${duration}ms); return result; }); export const loggedProcedure t.procedure.use(loggerMiddleware);然后在需要的地方用loggedProcedure替代publicProcedure。这样每个请求的耗时和路径都会被记录排查性能问题时很有用。错误处理也可以用中间件统一格式化。tRPC抛出的TRPCError会带着错误码和消息传到前端前端可以用error.message展示用户友好的提示。但要注意不要把服务端的堆栈信息暴露给前端中间件里可以做一层过滤。6.2 共享逻辑的抽离与复用模式t3code项目里有些逻辑是前后端都要用的比如数据校验规则、常量定义、工具函数。这些代码应该放在src/lib或一个独立的packages/shared里前后端都从这里导入。一个典型的例子是zod schema。后端用zod校验输入前端也可以用同一个schema做表单校验。这样校验规则只写一次前后端行为一致。// src/lib/validators/post.ts import { z } from zod; export const createPostSchema z.object({ title: z.string().min(1).max(100), content: z.string().max(5000).optional(), }); export type CreatePostInput z.infertypeof createPostSchema;后端在tRPC procedure里用createPostSchema做input校验前端在表单提交前用同一个schema做校验。类型CreatePostInput也可以前后端共用。这种复用模式能显著减少重复代码和不一致问题。6.3 性能监控与类型安全的平衡类型安全不是免费的它需要TypeScript在编译时做大量推导。在大型项目里这个开销会体现在构建时间和编辑器响应速度上。我的经验是在核心业务路径上保持严格的类型安全在边缘逻辑上适当放宽。比如对于数据库查询和API接口坚持用Prisma和tRPC的完整类型推导。但对于一些纯展示组件、工具函数可以用更宽松的类型甚至用any做临时过渡但要加注释说明原因。关键是找到一个平衡点既不让类型系统拖慢开发速度也不让类型漏洞导致运行时错误。另外可以用tsc --diagnostics查看类型检查的耗时分布找出最耗时的文件或模块针对性优化。常见的手段包括减少泛型嵌套、避免循环引用、拆分大型类型定义。6.4 团队协作中的代码规范约定t3code项目在团队协作时最大的挑战不是技术本身而是约定的一致性。如果每个人对目录结构、命名方式、路由划分的理解不同项目很快就会变得难以维护。我建议在项目初期就定好这几条规范tRPC路由按业务领域划分每个router文件不超过200行组件按ui和features分层features下按业务建子目录所有数据库操作必须通过ctx.db不允许在组件里直接import Prisma客户端环境变量必须通过env.js访问不允许直接读process.env。这些规范看起来琐碎但能避免很多后期重构的痛苦。可以用ESLint的no-restricted-imports规则来强制某些约定比如禁止从prisma/client直接导入到src/app目录。工具能做的事就不要靠人的自觉。7. 关于t3code的一些个人体会我在几个中小型项目里用过T3栈最大的感受是它把“类型安全”从一个口号变成了日常开发中能感知到的便利。以前改一个字段名要全局搜索替换现在编辑器直接标红以前前后端联调要对着文档对参数现在类型提示直接告诉你该传什么。这种体验上的提升用久了就回不去了。但它也不是银弹。tRPC的生态相对年轻遇到一些边缘场景时文档和社区答案不如REST那么丰富。Next.js的版本迭代很快app目录和pages目录的混用有时会让人困惑。Prisma在某些复杂查询场景下生成的SQL不够优化需要手动调优。这些问题都需要在实际项目中慢慢积累经验。如果你打算开始一个T3项目我的建议是先用脚手架跑通一个最小可用的demo然后花时间理解每个配置项的作用再逐步替换成自己的业务逻辑。不要一上来就追求完美的目录结构先让项目跑起来然后在迭代中调整。类型安全的价值在于长期维护而不是一开始就完美无缺。最后分享一个小技巧在package.json里加一个typecheck脚本用tsc --noEmit做全量类型检查然后在提交代码前跑一遍。这个习惯能帮你提前发现很多类型问题避免把错误带到CI阶段。配合编辑器的实时提示基本上能做到“写的时候就知道对不对”。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →