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

t3code全栈开发实战:TypeScript类型闭环与tRPC最佳实践

t3code全栈开发实战:TypeScript类型闭环与tRPC最佳实践 ★ FEATURED ARTICLE
1. 从“t3code”这个名字说起它到底是什么第一次看到“t3code”这个词很多人会以为是某个新出的编程语言或者某个开源框架的缩写。实际上在开发者圈子里t3code 通常指向的是一套围绕 TypeScript 全栈开发的技术组合方案核心是把 T3 Stack 那一套东西——Next.js、tRPC、Prisma、Tailwind CSS、TypeScript——用在一个真实可跑的项目骨架里并且把“代码”这件事从端到端串起来。你可以把它理解成一份“从数据库到界面”的完整代码组织范式而不是一个具体的库。它解决的问题很实在全栈项目里前后端类型对不上、接口文档永远滞后、改一个字段要动五个文件、新人接手要花一周才能跑起来。t3code 这套思路的价值就在于用 TypeScript 的类型系统把整条链路焊死让编译器替你做接口联调。适合谁看如果你已经会写 React但对“怎么把前后端优雅地连起来”还停留在手写 fetch 加 any 的阶段那这套东西就是给你准备的。如果你是完全零基础的小白建议先把 TypeScript 基础类型和 React 组件通信过一遍再回来不然会有点吃力。我前后用这套结构落地过三个中小型项目踩过的坑不算少下面把整体设计、核心细节、实操过程和排查经验完整拆一遍。2. 整体架构设计与技术选型逻辑2.1 为什么是这套组合而不是别的先说选型的底层逻辑。全栈开发最痛的点从来不是“写不出来”而是“改不动”。一个字段从数据库改到界面传统做法要经过改表结构、改后端 DTO、改接口返回、改前端类型定义、改组件渲染五步里任何一步漏了运行时才报错。t3code 这套方案的核心目标就是把这五步压缩成一步——改 Prisma schema剩下的靠类型推导自动流转。具体来说Next.js 负责路由和渲染它同时提供 API Route 和页面省掉了单独起一个后端服务的运维成本。tRPC 负责前后端通信它的杀手锏是“没有 schema 文件”后端函数的输入输出类型直接被前端 import 使用改后端签名前端立刻飘红。Prisma 负责数据库访问schema 即类型来源生成的 Client 自带完整类型。Tailwind 负责样式省去 CSS 文件切换。TypeScript 是粘合剂把上面所有东西串成一个类型闭环。对比一下其他方案REST Swagger 需要维护额外的接口文档文档和代码不同步是常态GraphQL 类型安全但要写 schema 和 resolver样板代码多纯 Serverless 函数拆分细但本地调试麻烦。t3code 这套在“中小团队快速迭代”这个场景下综合成本最低。2.2 目录结构背后的取舍一个典型的 t3code 项目目录大概长这样src/ server/ api/ routers/ user.ts post.ts root.ts trpc.ts db.ts pages/ 或 app/ api/trpc/[trpc].ts index.tsx utils/ api.ts components/ prisma/ schema.prisma这个结构的关键在于server和pages的边界。所有数据库和业务逻辑都锁在server里前端只能通过utils/api.ts导出的 hooks 访问。这样做的好处是你永远不会在组件里不小心 import 了 Prisma Client 导致打包体积爆炸——因为 Prisma 只在 server 侧被引用。我见过太多项目把数据库查询写在组件里最后 bundle 里塞进了整个数据库驱动首屏加载慢得离谱。root.ts是路由聚合点把所有子 router 合并成一个appRouter。这个文件同时导出AppRouter类型前端utils/api.ts就靠这个类型拿到全部接口签名。整个类型闭环的入口就在这里所以每次新增 router 记得在 root 里注册否则前端拿不到类型。2.3 类型安全的边界在哪里需要清醒认识到tRPC 的类型安全只覆盖“前端调用后端”这一段。后端到数据库这一段Prisma 保证了 schema 和查询结果的类型但如果你在 resolver 里手动拼了一个对象返回那个对象的类型是你自己声明的编译器不会帮你验证它和数据库真实字段是否一致。所以真正的类型闭环是Prisma schema → Prisma Client 类型 → resolver 返回类型 → tRPC 推导 → 前端 hooks。中间任何一环你手动as any闭环就断了。我的经验是resolver 里尽量直接返回 Prisma 查询结果不要手动重组对象。如果确实需要裁剪字段用 Prisma 的select而不是在 JS 里解构这样类型推导能一路跟到底。3. 核心细节解析与实操要点3.1 Prisma schema 是整个项目的类型源头很多人把 schema.prisma 当成“数据库配置文件”这是最大的误解。在这套体系里它是整个项目类型的唯一真相来源。你在这里定义的每一个 model、每一个字段类型、每一个可选标记都会一路传导到前端组件的 props 上。举个例子定义一个 Post 模型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()) }注意content String?这个问号。它意味着这个字段在数据库里可为空Prisma Client 生成的类型里它就是string | null这个 null 会一路传到前端。如果你前端组件写的是post.content.length编译器直接报错逼你处理 null 情况。这就是类型闭环的价值——它在编译期就帮你发现了潜在的运行时崩溃。实操要点每次改完 schema必须跑npx prisma migrate dev --name xxx生成迁移并重新生成 Client。我踩过的坑是改了 schema 忘了 migrate本地 Client 还是旧的前端类型对不上排查半天才发现是没重新生成。建议把prisma generate加到postinstall脚本里团队协作时别人拉代码后自动生成。3.2 tRPC router 的写法与常见陷阱一个典型的 router 长这样import { z } from zod; import { router, publicProcedure, protectedProcedure } from ../trpc; export const postRouter router({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ where: { published: true }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), });这里有几个关键点。第一input用 zod 定义它同时承担运行时校验和类型推导两个职责。前端调用时传错类型编译期就报错传了非法值运行期 zod 拦截。第二ctx里挂着db和session这是通过 context 注入的不要在 router 里直接 import db否则测试时没法 mock。第三protectedProcedure和publicProcedure的区别在于是否校验登录态这个中间件逻辑写在trpc.ts里。常见陷阱在 query 里做写操作。tRPC 的 query 默认可能被并发调用和缓存写操作必须用 mutation。我见过有人在 query 里更新访问计数结果 React StrictMode 下双调用导致计数翻倍。记住 query 只读mutation 才写。3.3 前端 hooks 的自动生成与使用前端不需要手写任何请求代码。utils/api.ts里通常只有几行import { createTRPCNext } from trpc/next; import type { AppRouter } from ../server/api/root; export const api createTRPCNextAppRouter({ /* config */ });之后在组件里const { data: posts, isLoading } api.post.list.useQuery(); const createPost api.post.create.useMutation({ onSuccess: () utils.post.list.invalidate(), });api.post.list这个路径是自动补全的因为AppRouter类型里包含了所有 router 和 procedure 的名字。改后端 router 名字前端这里立刻报错。useMutation的onSuccess里调invalidate是刷新缓存的标配不写的话创建完列表不更新用户以为没成功。注意事项useQuery的依赖数组和enabled选项要会用。比如详情页要根据路由参数查数据得写api.post.byId.useQuery({ id }, { enabled: !!id })否则 id 还没拿到就发请求后端收到 undefined 报错。4. 完整实操流程从零跑起来一个 t3code 项目4.1 环境准备与初始化先确认本地 Node 版本在 18 以上包管理器我用 pnpm速度快且对 monorepo 友好。初始化命令pnpm create t3-applatest my-app交互式选项里TypeScript 必选Next.js App Router 和 Pages Router 按习惯选我一般选 Pages Router 因为生态资料多。tRPC、Prisma、Tailwind、NextAuth 全勾上。生成后进入目录配置.envDATABASE_URLpostgresql://user:passlocalhost:5432/mydb NEXTAUTH_SECRET随便一串随机字符串数据库我推荐本地用 Docker 起一个 Postgres一条命令搞定docker run --name t3db -e POSTGRES_PASSWORDpass -p 5432:5432 -d postgres:15然后pnpm prisma migrate dev建表pnpm dev启动。这一步如果卡在数据库连接九成是 DATABASE_URL 里的用户名密码或端口写错了用docker ps确认容器在跑用psql或 TablePlus 试连一下。4.2 新增一个完整业务模块的步骤假设要加一个“评论”功能完整流程如下。第一步改 schemamodel Comment { id String id default(cuid()) text String postId String post Post relation(fields: [postId], references: [id], onDelete: Cascade) authorId String createdAt DateTime default(now()) }同时在 Post 模型里加comments Comment[]反向关系。跑pnpm prisma migrate dev --name add_comment。第二步写 routerexport const commentRouter router({ byPost: publicProcedure .input(z.object({ postId: z.string() })) .query(({ ctx, input }) ctx.db.comment.findMany({ where: { postId: input.postId }, orderBy: { createdAt: asc }, }) ), add: protectedProcedure .input(z.object({ postId: z.string(), text: z.string().min(1) })) .mutation(({ ctx, input }) ctx.db.comment.create({ data: { ...input, authorId: ctx.session.user.id }, }) ), });第三步在 root.ts 注册comment: commentRouter。第四步前端组件里直接用api.comment.byPost.useQuery({ postId })和api.comment.add.useMutation()。整个过程不需要写一行接口定义、不需要写一行请求封装、不需要手动声明返回类型。这就是这套方案的核心效率来源。我实测下来一个标准 CRUD 模块从 schema 到界面跑通熟练后 20 分钟内能完成。4.3 参数校验与错误处理的实际写法zod 的校验规则要写细。比如text: z.string().min(1).max(500)前端和后端共用同一套规则前端可以在提交前先本地校验后端再兜底。错误处理上tRPC 抛出的TRPCError会带 code前端useMutation的onError里能拿到onError: (err) { if (err.data?.code UNAUTHORIZED) router.push(/login); else toast.error(err.message); }我习惯在trpc.ts里统一处理常见错误比如 session 过期跳登录避免每个组件重复写。另外 zod 的报错信息默认是英文的要中文提示得用z.string().min(1, 不能为空)这样逐条写 message或者用z.setErrorMap全局配置。5. 常见问题与排查技巧实录5.1 类型报错排查速查表报错现象大概率原因解决方式前端api.xxx没有补全root.ts 没注册该 router在 root 里加一行注册类型显示any某处用了as any或没导出类型全局搜as any逐个消除Prisma Client 类型过期改了 schema 没 generate跑pnpm prisma generatectx.session为 null用了 publicProcedure换成 protectedProcedure打包报 Prisma 相关错误前端误 import 了 db检查组件里是否直接引 server 代码5.2 几个我踩过的深坑第一个坑循环依赖。root.ts引 routerrouter 引trpc.tstrpc.ts又引root.ts拿类型一不小心就循环。解决办法是把 context 类型单独放一个文件trpc.ts只引类型不引值。第二个坑Next.js 的 SSR 和 tRPC 缓存不同步。服务端渲染时拿到的数据和客户端 hydration 后的数据如果不一致React 会警告。用createTRPCNext的ssr: true配置并在_app.tsx里正确包裹api.withTRPC能解决大部分问题。第三个坑Prisma 在 Serverless 环境下的连接数爆炸。每个函数实例都开一个连接池并发一高数据库就拒绝连接。解决办法是在db.ts里用全局单例模式const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个模式在开发环境热重载时避免重复创建 Client生产环境配合连接池参数调优。5.3 性能相关的注意事项tRPC 的 query 默认会做请求去重和缓存但useQuery的staleTime默认是 0意味着组件重新挂载就重新请求。列表页这种不常变的数据设staleTime: 30 * 1000能明显减少请求。另外invalidate要精确到具体 query不要全局 invalidate否则一个 mutation 触发全站刷新体验很差。数据库层面Prisma 的findMany如果不加select会返回所有字段包括大文本字段。列表页只展示标题和摘要的话用select: { id: true, title: true }能省不少带宽。我有个项目列表页没加 select每条记录带了几 KB 的 content一页 20 条就是几十 KB 的无用传输。6. 这套方案适合与不适合的场景t3code 这套结构在“团队规模 2 到 10 人、需要快速迭代、前后端同一批人写”的场景下几乎是最优解。类型闭环带来的重构信心是实打实的——改一个字段名编译器把所有需要改的地方列出来挨个改完就能跑不用全局搜索字符串。但它不适合的场景也要说清楚。如果后端要同时服务多个客户端iOS、Android、第三方tRPC 的类型优势只对 TypeScript 客户端有效其他语言还是得走 REST 或 GraphQL这时候硬上 tRPC 反而多一层。如果团队里有大量非 TS 背景的后端Prisma 和 tRPC 的学习曲线会拖慢进度。还有就是超大型项目单个AppRouter类型膨胀到几千个 procedure 时IDE 的类型推导会变慢这时候需要按领域拆分多个 tRPC 实例。我个人在实际操作中的体会是这套东西最大的价值不在“写得快”而在“改得稳”。项目上线半年后回头加功能打开编辑器类型系统会告诉你哪里会受影响这种安全感是手写 REST 加 any 永远给不了的。最后分享一个小技巧把常用的 zod schema 抽到src/server/validators目录下前后端共用表单校验和接口校验用同一份规则能省掉大量重复代码也避免了“前端校验过了后端没过”的尴尬。
阅读完成 · 觉得有帮助?
咨询建站