首页 / 资讯中心 / 文章详情

T3 Stack实战:用TypeScript、tRPC与Next.js打造类型安全的全栈应用

T3 Stack实战:用TypeScript、tRPC与Next.js打造类型安全的全栈应用 ★ FEATURED ARTICLE
t3code这个名字如果只看表面可能会以为又是一套编码规范手册。但实际接触下来你会发现它更像是我最近在一个全栈项目里使用的一组技术栈代号——TypeScript、Tailwind CSS、tRPC再加上Next.js作为应用框架社区通常把这一套叫做T3 Stack而t3code就是这个实践项目的工程名。这篇文章我想用一个完整的任务管理小应用作为例子把这套技术栈从初始化、写接口、做页面到最终部署的全过程拆开讲包括每个选型背后的理由、类型安全到底是怎么贯穿前后端的以及那些文档里根本不会写的坑。如果你也想找一个能让自己快速上手且类型安全做到极致的现代全栈方案或者已经在用Next.js但总觉得前后端接口维护麻烦那么这套T3组合应该能给你一些启发。我下面说的每一步都是按真实开发流程来的不会只贴概念。1. 项目全貌与“T3”的选型逻辑1.1 t3code要解决的到底是什么问题很多前端团队做一个小应用时最常见的痛点是接口层写起来啰嗦先在服务端定义REST接口再写路由再在客户端封装请求函数最后还要手动维护类型定义。一旦接口字段改了往往会出现前端已经改完但类型还停留在旧版本的尴尬情况运气不好就得等运行时才发现报错。t3code这个项目想解决的就是这么一件看似小事但特别磨人的事——让前端调用后端接口的时候像调用本地函数一样自然而且类型从头到尾是通的。它不是某种银弹框架而是一种工程组合拳Next.js负责页面和路由TypeScript负责全链路类型约束tRPC负责给前后端之间“架一条类型安全的天桥”Tailwind则让样式不用离开组件就能写完。做这个项目前我在几个小工具项目里试过纯REST方案也试过GraphQL都有自己的问题。REST写起来简单但类型维护费劲GraphQL灵活但太多概念需要学。t3code采用的这套组合核心思路是把“跨网络的函数调用”这件事做好让业务代码专注于逻辑本身而不是纠结接口协议。1.2 为什么是TypeScript、Tailwind、tRPC这三块拼图先说TypeScript这个没什么争议。现代前端项目如果没有类型系统代码规模一大就容易失控尤其多人协作时一个函数的参数定义不清晰后面接手的人只能靠猜。t3code选择TypeScript不是为了赶时髦而是后面tRPC能成立的前提——只有类型系统足够强才能让类型从服务端自动“流”到客户端。Tailwind CSS在很多人眼里只是一套原子化CSS工具但真正用起来会发现它的价值不只是“不用写CSS文件”而是让团队里每个人写样式的方式都收敛到同一套设计令牌上。我用Tailwind做了t3code的所有页面几乎没有写过独立的CSS文件组件代码里直接类名标样式很少出现样式覆盖导致的诡异问题。它和组件思维非常契合。tRPC是这套组合里最核心也最容易被低估的部分。它不是一个Web框架不替代Next.js的路由它的工作是在TypeScript项目里自动帮你生成类型安全的远程调用协议。前端不需要知道后端接口的URL结构只需要引入一个在服务端定义的router就能以近乎本地函数调用的方式发起查询和变更。类型如果对不上编译期就报错了不用等到联调才发现字段名写错。1.3 这套组合的适用范围与边界有人问我t3code是不是万能模板我的回答是它最适合中小型全栈应用尤其是内部工具、管理系统、内容不太复杂的业务站点。因为Next.js的服务端渲染和API能力足够强tRPC又让前后端的调用成本很低一个人或一个小团队就能把整条链路吃下来。但如果你要做的是一个开放API平台需要给第三方开发者提供完整、规范的REST或GraphQL接口那么tRPC作为内部通信层仍然可以用但对外接口还是得另起一层。再比如项目涉及大量非TypeScript的第三方服务或者服务端和前端完全分离部署、团队人数很多这种时候t3code的优势会打折扣。选型前最好先问一句我是不是真的需要这么强的端到端类型安全如果答案是否定的传统REST方案其实也够用。2. 从零初始化t3code的第一版代码骨架2.1 用create-t3-app快速搭起工程我当时并没有手动拼装这些依赖而是直接用了官方脚手架。打开终端在目标目录下执行这条命令npx create-t3-applatest t3code这个命令会进入一个交互式界面让你选择想要集成的东西。我的建议是Next.js选择App Router模式因为这是目前的主力方向TypeScript直接启用严格模式数据库层我选了PrismaORM语法直观迁移工具也好用tRPC必须选Tailwind必须选NextAuth这种鉴权方案如果第一期用不到可以先不勾后面需要再加也不算麻烦。这样一个基础项目就出来了。它会自动生成一套包括服务端路由、客户端Provider、Prisma client封装、Tailwind配置在内的骨架代码。我第一次跑完这个初始化之后最大的感受是以前自己搭环境少说要一天现在十分钟就能有一个全栈工程起步而且各个模块之间的代码已经帮你接好了。2.2 初始化后目录结构逐个拆解初始化完成后不能马上就开始写业务代码先把目录结构认清。t3code的目录大致是这样src/app— Next.js App Router的页面文件首页、布局、路由页面都在这。src/server/api/trpc.ts— tRPC服务端的初始化入口负责创建router和procedure。src/server/api/routers— 具体的业务路由模块都放这里。src/server/db.ts— Prisma客户端实例的封装。src/trpc— 客户端调用tRPC所需的Provider和封装。prisma— 数据库schema文件和迁移记录。tailwind.config.ts— Tailwind的配置。这里最需要理解的是服务端API和页面之间的关系。App Router下页面组件默认在服务端执行但如果要用tRPC就必须把相关组件用use client标记再通过api.todo.getAll.useQuery()这种React Hooks方式调用。一开始我在这个点上有点绕——以为服务端组件也能直接调tRPC实际上tRPC的查询最好在客户端组件里做否则拿不到响应式的数据和加载状态。2.3 启动前必须处理的环境变量与数据库跑起来之前有一个不能跳过的步骤配置数据库连接。create-t3-app默认会让你在.env里填数据库地址DATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code本地开发我用了PostgreSQL如果你只是想快速体验也可以先临时用SQLite。但要注意后面如果部署线上数据库地址必须换成云数据库的而且不要在代码里硬编码数据库地址只通过环境变量注入就行。改完之后执行npm run db:push npx prisma generate这两步的作用是把schema同步到数据库并生成Prisma客户端类型。第一次启动前最好把这两条命令跑通否则后续写代码时编辑器里不会出现prisma.todo这些字段的提示。我当时因为忽略了db:push写了一晚上代码发现运行时一直报表不存在白白折腾了半小时。3. 核心功能落地给t3code加上任务管理模块3.1 用Prisma定义Todo模型并完成迁移t3code既然要演示完整链路就得有实际业务模型。我选择了最简单的任务管理场景在prisma/schema.prisma里定义一张表model Todo { id String id default(cuid()) title String done Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里用String类型做主键而不是自增整数是因为分布式环境下字符串ID更好生成不需要担心自增主键的冲突问题。cuid算法生成的ID短小且有序适合给用户看到。字段类型确定后执行npx prisma migrate dev --name init它会生成一次迁移记录并更新数据库结构。这里要注意的是迁移文件应该提交到代码仓库里这样其他同事clone下来后只需要执行npx prisma migrate deploy就能在本地还原数据库结构而不是每个人都手工跑一次db:push。3.2 编写tRPC路由让接口像本地函数一样被调用表结构定义完下一步就是写接口。t3code的做法不是在pages/api里写REST接口而是在src/server/api/routers/todo.ts里定义tRPC路由import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return await ctx.db.todo.findMany({ orderBy: { createdAt: desc }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) { return await ctx.db.todo.create({ data: { title: input.title }, }); }), toggle: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const item await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!item) throw new Error(Todo not found); return await ctx.db.todo.update({ where: { id: input.id }, data: { done: !item.done }, }); }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return await ctx.db.todo.delete({ where: { id: input.id }, }); }), });这段代码里有个关键点每个procedure的输入都用zod做了运行时校验。tRPC和zod的配合就像民航飞机配了降落伞——TypeScript提供编译期检查zod提供运行时的最后一道防线。即使有人绕过前端直接拿工具调接口非法参数也会在进入业务代码前被拦下来。写完后要把这个router挂到根router上。在src/server/api/root.ts里加一行export const appRouter createTRPCRouter({ todo: todoRouter, });这样前端调用时路径就是api.todo.getAll、api.todo.create语义非常清晰。我特别喜欢这种路由组织方式它让接口名和模块名一一对应后期要看项目有哪些能力只需要看routers目录下有哪些文件。3.3 在React组件里消费tRPC接口服务端接口写好后前端接起来极其顺。在任务列表组件里不需要手动写fetch、不需要定义接口返回的interface直接引入封装好的api对象use client; import { api } from ~/trpc/react; export function TodoList() { const { data: todos, refetch, isLoading } api.todo.getAll.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: () refetch(), }); const toggleTodo api.todo.toggle.useMutation({ onSuccess: () refetch(), }); const deleteTodo api.todo.delete.useMutation({ onSuccess: () refetch(), }); if (isLoading) return div加载中.../div; return ( div classNamespace-y-2 {todos?.map((todo) ( div key{todo.id} classNameflex items-center gap-2 input typecheckbox checked{todo.done} onChange{() toggleTodo.mutate({ id: todo.id })} / span className{todo.done ? line-through : }{todo.title}/span button onClick{() deleteTodo.mutate({ id: todo.id })} 删除 /button /div ))} /div ); }看到useMutation和useQuery这些Hooks用React Query的人会觉得眼熟。实际上tRPC的客户端底层就是套了一层React Query所以你在前端能拿到loading、error、refetch这些现成状态不需要再引入额外的请求状态管理库。这也是t3code的一大特点——用一个库同时解决了接口调用和请求状态管理。这里有个容易被忽略的细节onSuccess: () refetch()意思是每次增删改成功后就重新拉取列表。这个写法看似笨拙但对小项目来说非常省心不需要手动做本地状态修改和同步等数据真正改变后再刷新逻辑简单不容易出错。3.4 类型安全从服务端到客户端是怎么流动的解释一下为什么上面那个组件里todos自动就有了{ id, title, done, createdAt, updatedAt }的类型。关键在于客户端引入的api对象是从服务端router类型推导出来的// src/trpc/react.ts import { type inferRouterOutputs } from trpc/server; import { type AppRouter } from ~/server/api/root; type RouterOutput inferRouterOutputsAppRouter;TypeScript会把AppRouter的类型信息转成客户端的调用签名。也就是说后端改了返回结构比如给Todo加了priority字段前端编译时立刻能看到类型缺了什么后端改了入参校验规则前端也会被强制匹配新的入参类型。这种“改一处另一处编译报错”的开发体验长期维护项目时真的救命。一个类比就是以前前后端对接像是两边各拿一张手写地图地图版本经常对不上t3code则像同一个地图软件里的实时导航你走到哪路线就更新到哪。4. 部署上线t3code从开发机到生产环境4.1 生产环境数据库与连接配置开发环境搞定到了部署环节。t3code常见的部署方式是Vercel加托管数据库。Vercel上部署非常省心导入Git仓库后只需要配置环境变量就行其中最重要的是DATABASE_URL。我这里用的是支持连接池的PostgreSQL托管服务。为什么强调连接池因为Vercel的无服务器函数是按请求动态扩容的每个请求可能启动一个独立实例。如果数据库连接数是固定的并发一上来就可能连接数耗尽。很多托管数据库会提供一个pooler模式的连接串部署时尽量选那个否则上线后容易突然出现“Too many connections”的报错。4.2 构建、SSR与静态化需要注意的细节在部署前本地先跑一遍正式构建npm run build这一步会暴露很多开发时不明显的错误。比如服务端组件试图访问浏览器对象、某个数据库查询在构建阶段就被执行等。t3code的Next.js默认会做静态生成优化如果有页面完全不需要动态数据构建时会被预渲染成静态HTML访问速度会很快。但任务管理这种依赖用户数据的页面一般需要动态渲染所以我在页面里明确标注了动态渲染策略避免构建时去拿一个还不存在的数据。还有一个坑tRPC的端点路径/api/trpc/*必须能被Next.js识别为动态API路由还好create-t3-app已经默认配置好了。部署完之后在浏览器里访问一下/api/trpc/todo.getAll?batch1能看到JSON返回就说明服务端链路没断。4.3 常见问题速查表实际用下来t3code开发中问题集中在下面几个地方。我整理成一张表可以直接照着排查现象可能原因解决办法调用mutation报400 BAD_REQUESTzod输入校验失败传了空值或异常字段检查前端传参是否完全匹配zod schema页面能打开但数据一直加载中tRPC客户端Provider没有正确包裹组件确认根布局里包了TRPCReactProvider类型提示缺失或方法找不到prisma generate忘了执行重跑npx prisma generate并重启编辑器上线后数据库连接失败环境变量没配置或用了非pooler连接更新Vercel环境变量并重新部署build时报TS类型错误严格模式下某字段可能为null/undefined给查询结果做非空判断或调整类型收窄这些坑我基本都踩过一轮尤其是数据库连接那个开发时用localhost好好的一上生产就翻车后来才意识到是环境变量和连接池的问题。5. 我在t3code开发中积累的经验与惯用做法5.1 团队协作时t3code最容易出现的分歧点代码结构上t3code的约束已经比较强了但有两个地方仍有分歧空间。一个是zod schema放在哪里——有人习惯直接写在router文件里我建议单独建一个src/server/schemas目录因为同一个schema可能被多个router复用集中管理不容易重复定义。另一个分歧是查询粒度。tRPC让你很容易在客户端发起各种查询但如果一个页面同时调用五六个useQuery会造成大量请求。我的惯用做法是优先按页面维度组织查询一个页面尽量只用一个getXxxPageData这样的聚合接口减少网络往返。tRPC确实方便但别因为它方便就把请求拆得稀碎前后端通信成本仍然存在。还有一点经验t3code里Prisma的查询默认返回所有字段如果某个列表接口只需要id和title可以在Prisma查询里用select做字段裁剪。这能显著降低响应体大小尤其是列表数据量大了之后效果很明显。5.2 几个值得坚持的小习惯先说环境变量管理。t3code项目里通常有两个文件.env和.env.example。.env填真实配置且不提交到Git.env.example只保留变量名和示例值。这样新成员加入时复制一下就能跑起来不会出现“代码在我电脑上能跑在你电脑上跑不动”的经典烂摊子。再说日志。开发时tRPC会默认记录请求日志但生产环境可能被日志淹没。我会在部署前关掉或降级这些日志只保留错误级别的输出。这一步对排查线上问题很重要因为无服务器环境的日志是按请求计费的调试时频繁刷新页面会产生大量无价值日志。最后一个小技巧在服务端使用Prisma时我习惯把客户端实例单独缓存。create-t3-app默认就做了这件事const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个写法的意义是避免开发环境热重载时反复创建Prisma实例最终把数据库连接数打满。这个坑很隐蔽自己从零搭项目时非常容易遇到直接沿用模板能省心很多。我自己的体会是像t3code这样把TypeScript的静态能力发挥到极致的全栈方案第一次用的时候需要花点时间习惯“代码尚未运行编译器已帮你检查完一大半问题”的节奏但一旦适应了再回头写那种前后端类型各管各的项目会觉得特别别扭。如果你也打算搭建一个中小型全栈应用不妨按这条路子试一次把前后端之间的类型缝隙先填上后面你会省出大量本要花在联调上的时间。
阅读完成 · 觉得有帮助?
咨询建站