ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

T3 Stack 全栈类型安全实战:从 Next.js 到 tRPC 的代码骨架设计

T3 Stack 全栈类型安全实战:从 Next.js 到 tRPC 的代码骨架设计 我前后端一起搬砖也有十来年了习惯性地会把每套新项目沉淀成一套可复用的代码骨架t3code就是这么来的。这东西核心就一句话把 T3 Stack 全栈类型安全那套玩法固化成一个开箱即用的代码模板你拿来直接做业务不用再纠结技术选型怎么搭、类型怎么打通。如果你是第一次接触全栈开发或者玩过 Next.js 但总觉得前后端类型是两套体系、改起来费劲那这篇文章刚好适合你。我会从技术选型逻辑、目录结构、核心链路到实战踩坑完整拆一遍。1. 拆解t3code为什么偏偏走全栈类型安全这条路1.1 全栈类型安全到底解决什么痛点传统的前后端分离开发前端和后端各有一套数据模型你往往得手动维护一份接口文档去对齐字段。后端改了 userName前端没同步就是线上炸锅。t3code选型时首先解决的问题就是让一个 TypeScript 类型从数据库一路穿透到页面 UI中间不落地重复定义。具体落地是这么一套组合Next.js 承担渲染和路由tRPC 承担接口通信Prisma 承担数据库映射NextAuth 负责鉴权全程 TypeScript 强制约束。tRPC 最值得聊它不生成 REST 风格的 URL而是像一个本地函数一样直接从前端调用后端的查询和变更类型推导全靠 TypeScript 的声明文件自动完成。懂行的人会说这不就是 RPC 吗对但 T3 的精华在于类型贯通你后端返回的类型变了前端编辑器立刻给你标红从机制上解决接口不同步的问题而不是靠自觉。1.2 四个核心库背后的选型取舍选 Next.js 而不是 Vite Express最直接的原因是既有服务端渲染、又有 API 路由这样前后端可以住在同一个代码仓库里部署时一个单元就能搞定。Tailwind 则是顺手解决样式快速迭代的问题原子化类名修改起来不用来回切文件。Prisma 在那套 ORM 里相比 TypeORM 更顺手schema.prisma定义模型后一执行migrate数据库表和类型就同步生成光这一点就能省掉手写数据表文件的无数烦恼。NextAuth 做第三方登录和凭证登录都很快和 tRPC 配合时通过getServerSession就能识别请求上下文里的会话状态鉴权逻辑可以收敛到 tRPC 的 protectedProcedure 里业务代码根本不用关心“这个接口要不要登录”这类琐事。注意T3 Stack 不等于只用这四个库而是默认这套组合最省心。你要是项目里需要消息队列、缓存层照样可以按需加模板不限制你扩展自由。2. 项目骨架与关键目录代码是怎么组织起来的2.1 一个能直接跑起来的目录结构t3code的目录组织承接了 T3 App 的规范但比官方脚手架多了一些业务侧的约定。核心结构大致如下t3code/ ├── prisma/ │ ├── schema.prisma # 数据模型定义 │ └── migrations/ # 数据库迁移记录 ├── src/ │ ├── pages/ │ │ ├── api/ # Next.js API 路由 │ │ │ └── trpc/ # tRPC 入口 │ │ └── index.tsx # 首页示例 │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ # 按业务拆分的 router │ │ ├── db.ts # Prisma Client 单例 │ │ └── auth.ts # NextAuth 配置 │ ├── styles/ │ └── utils/ │ └── trpc.ts # 前端 tRPC 客户端封装 ├── .env # 环境变量 └── package.json按业务拆 Router 是个容易被忽略的细节。很多人一上来把所有接口都写在root.ts里项目一膨胀就没法看。t3code里我会固定把routers目录当作业务模块的边界比如post.ts、user.ts然后在root.ts里统一合并导出。这样每加一个业务域就是新增一个文件不干扰已有代码。2.2 环境变量与类型生成配置模板里.env默认配置了DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL这是三个最容易坑新手的变量。DATABASE_URL别直接用默认的本地连接串Postgres 带?schemapublic参数有时候会跟连接池配置打架。NEXTAUTH_SECRET务必换成一段足够长的随机字符串别用默认值不然部署上去登录态会诡异失效。NEXTAUTH_URL在本地一般写http://localhost:3000生产环境记得改成真实域名。类型生成这一步建议直接固化到脚本里。模板的package.json里会有{ scripts: { postinstall: prisma generate, dev: next dev } }prisma generate会在安装依赖后自动执行保证PrismaClient的模型类型和数据库 schema 同步。如果团队有人改完 schema 忘记跑生成接口侧的类型还是旧版这种问题在模板里用钩子机制直接规避掉比靠人自觉好使。我第一次跑这个流程时也被坑过明明数据库表已经建好了代码里查了一个新字段却一直报类型不存在最后发现就是没执行生成命令。2.3 前端 tRPC 封装里的关键细节utils/trpc.ts是连接前后端的桥梁。官方模板生成的写法里核心是类型导出import { createTRPCNext } from trpc/next; import type { AppRouter } from /server/api/root; export const trpc createTRPCNextAppRouter({ config() { return { links: [ httpBatchLink({ url: /api/trpc, }), ], }; }, });很多资料会忽略httpBatchLink这个细节。它能把同一个事件循环里发起的多个 tRPC 请求自动合并成一个 HTTP 请求减少网络开销。你要是看到模板里没有显式写请求地址那是因为它默认走相对路径/api/trpc这在部署到子路径的时候才会暴露问题平时开发完全不影响。前后端共享类型还有个容易忽略的设计要点AppRouter是从 server 目录导出的类型前端代码能拿到它做推导是因为纯类型导入在编译后会被擦除不会把服务端代码注入到客户端 bundle。理解了这一点你就明白为什么import type比import更安全也更能减少打包体积。我在给团队做代码评审时只要看到有人把import type顺手写成了import都会提醒改回来否则在 SSR 场景可能触发服务端模块被打进客户端包的问题。3. 核心链路实操从模型定义到页面渲染的完整一棒3.1 数据模型定义与迁移先想清楚关系再动手t3code里做了一个带标签的文章系统作为参考业务既能演示列表查询、详情查询又能演示带鉴权的创建操作。先在schema.prisma里定义模型model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] } model Post { id String id default(cuid()) title String content String createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) tags Tag[] } model Tag { id String id default(cuid()) name String unique posts Post[] } model TagOnPost { post Post relation(fields: [postId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) postId String tagId String id([postId, tagId]) }标签和文章的多对多关系Prisma 会自动生成关联表但如果你需要额外字段比如“这个标签在这篇文章里被引用了多少次”那就得像这里一样显式定义中间模型TagOnPost。这一步看起来增加了一点代码量但后续扩展非常灵活不会因为关系升级而改表结构。写好后执行npx prisma migrate dev --name init_post_tag它做的事情是对比现有 schema 和数据库结构生成一份迁移 SQL然后自动执行到本地数据库同时更新 Prisma Client 的类型。线上环境不要直接用migrate dev改用prisma migrate deploy避免在部署流程中意外修改表结构。实操建议preview 环境的数据表命名和本地保持一致否则切分支跑不同迁移时会看到各种字段对不上。我习惯在本地固定用同一个数据库实例但不同的 schema 来隔离配合DATABASE_URL?schemadev_xxx就能做到互不干扰。3.2 在 tRPC Router 中写查询与变更定义好模型后进入src/server/api/routers/post.ts。先看一个查询import { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from /server/api/trpc; export const postRouter createTRPCRouter({ list: publicProcedure .input(z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) { const items await ctx.db.post.findMany({ take: input.limit 1, orderBy: { createdAt: desc }, cursor: input.cursor ? { id: input.cursor } : undefined, include: { author: { select: { name: true } }, tags: { include: { tag: true } } }, }); let nextCursor: string | null null; if (items.length input.limit) { const nextItem items.pop(); nextCursor nextItem!.id; } return { items, nextCursor }; }), });这段代码如果你想直接在本地业务用基本上是可以照搬的。zod 的 input 声明不是为了摆设它会在请求进入时自动做数据校验和类型收窄。cursor 分页比 offset 在表数据量大时躲开深分页扫描的性能问题又因为 t3code 里 Post 的 id 是 cuid 字符串天然有序所以 cursor 分页实现起来不费劲。再看一个带鉴权的创建create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), tagNames: z.array(z.string()).default([]) })) .mutation(async ({ ctx, input }) { const authorId ctx.session.user.id; const post await ctx.db.post.create({ data: { title: input.title, content: input.content, authorId, tags: { create: input.tagNames.map((name) ({ tag: { connectOrCreate: { where: { name }, create: { name } } }, })), }, }, include: { author: { select: { name: true } }, tags: { include: { tag: true } } }, }); return post; });protectedProcedure和publicProcedure的区别在哪看一下src/server/api/trpc.ts的定义就清楚了export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } }, }); });未登录请求直接抛 UNAUTHORIZED 错误前端可以统一拦截到错误提示。千万别再每个 mutation 里写一遍“if 没有 session 就 return”模板把这层逻辑收敛到 procedure 级别业务代码只管处理数据安全边界由上层的流程统一把控。3.3 页面集成服务端预取与客户端交互类型贯通到最后一步就是页面。t3code里列表页习惯用服务端预取页面加载时减少首屏白屏和闪烁import { createServerSideHelpers } from trpc/react-query/server; import { appRouter } from /server/api/root; import { createInnerTRPCContext } from /server/api/trpc; import superjson from superjson; export const getStaticProps async () { const helpers createServerSideHelpers({ router: appRouter, ctx: await createInnerTRPCContext(), transformer: superjson, }); await helpers.post.list.prefetch({ limit: 10 }); return { props: { trpcState: helpers.dehydrate(), }, }; };dehydrate可以把服务端已经查询到的数据序列化后传给客户端这样首屏渲染时数据就已经在页面上不需要再发一次请求。客户端组件里就可以直接用const listQuery trpc.post.list.useQuery({ limit: 10 }); const createMutation trpc.post.create.useMutation(); async function handleSubmit() { await createMutation.mutateAsync({ title: 新文章, content: 内容, tagNames: [T3], }); }你注意到没有整个流程下来你不需要手写 API 类型、不需要手写请求方法、不需要手写接口文档从 Prisma 模型到页面调用之间类型没有断片过。这就是t3code整套设计的核心导向让你把精力全部放在业务上。这里有个容易踩的坑服务端预取后如果页面是动态渲染比如要求每次访问都拿到最新数据不要用getStaticProps要用getServerSideProps否则你在预取里做的数据可能被缓存成静态页面业务更新不生效。模板默认示例用静态生成是为了演示真实业务按需切换即可。3.4 数据校验与错误处理的约定tRPC 加 zod 的整合理念是前端传参、后端入参都要校验但代码只写一处。input 的 zod schema 因为类型共享到前端编辑器里你能看到后端要求的字段和约束传参错误会在运行时得到明确的校验错误而不是中间的 500。有个细节要说清楚zod 校验默认只做浅层验证嵌套对象得单独写refine或者用z.object嵌套声明。模板里用默认配置时遇到复杂入参建议显式声明完整 schema别图省事只在最外层写一个z.any()那就等于把校验关了。4. 常见问题与排查技巧拿这些坑当垫脚石4.1 类型同步不生效改了 Prisma Schema 但代码没提示这个问题十有八九是没跑prisma generate。我在本地会直接将生成命令挂进postinstall但多人协作时总有人的 node_modules 是旧缓存导致类型不同步。排查时先执行npx prisma generate如果还不行检查schema.prisma里的 generator 配置。generator client { provider prisma-client-js }多数情况不是配置问题而是 IDE 的 TypeScript 服务没刷新。重启 TS Server 或重启编辑器就能解决。另一个隐蔽坑和cuid有关Prisma 的default(cuid())生成的主键是字符串如果你在代码里用Number类型去构造 where 条件类型定义会直接报错但要是你写的是any那就是运行时才炸。所以模板里所有主键统一用string这也是类型安全带来的约束效力。4.2 tRPC 请求不断报 404 或 500先确认 Next.js 的 API 路由文件存在且路径正确src/pages/api/trpc/[trpc].ts里调用了createNextApiHandler。404 最常见的原因是把项目放在子路径部署但请求地址写死了/api/trpc这时改用相对路径或者根据环境变量拼接。500 则优先看服务端日志tRPC 的错误默认传给前端的信息很有限如果 zod 校验没过会看到PARSE_ERROR或者BAD_REQUEST这时候往前端代码找一下是不是传了多余字段或者缺少必填字段。4.3 使用 NextAuth 后 session 在 tRPC 上下文里拿不到做过 T3 项目的几乎都会遇到这个问题。归根到底是getServerSession的调用时机和配置对象必须与 NextAuth 初始化保持一致。模板里src/server/auth.ts导出authOptions然后在createInnerTRPCContext里export const createInnerTRPCContext async (opts: { session: Session | null }) { return { session: opts.session, db: prisma, }; }; export const createTRPCContext async (opts: { req: NextApiRequest; res: NextApiResponse }) { const session await getServerSession({ req: opts.req, res: opts.res }, authOptions); return createInnerTRPCContext({ session }); };注意这里传的是{ req, res }不能只传一个 req否则拿不到 session。还有一个容易忽略的地方NEXTAUTH_SECRET和NEXTAUTH_URL在多个环境中不一致会导致 session 加密解密失败表现就是一会儿能登录一会儿被踢下线。排查时先打印 session如果为空再看环境变量是否在部署平台上正确配置。4.4 生产环境构建时 Prisma 找不到数据库构建机器一般没有生产数据库的网络权限而 Next.js 构建时可能会执行 tRPC 的预取逻辑。对这种场景建议在构建阶段把涉及数据库的预取代码放到条件渲染里或者在next.config.js中用env区分但更稳妥的是不要在getStaticProps里做数据库查询。t3code的示例默认把文章列表做成了静态生成演示你要是照搬到生产环境需要改成客户端取数或者getServerSideProps按需查询避免构建期连接数据库。4.5 分页数据重复或丢失cursor分页有个典型错误没有在findMany里提供与cursor一致的orderBy。如果你的排序不稳定比如按updatedAt排序但两条记录时间完全一致翻页时就可能出现重复或丢数据。解决办法是排序加主键orderBy: [{ createdAt: desc }, { id: desc }], cursor: input.cursor ? { id: input.cursor } : undefined,把主键作为次级排序项保证顺序绝对稳定。这个细节在数据量小时看不出问题数据一多就非常致命。模板里的示例已经带上了这个写法你复制出去时可以留意一下。4.6 前端打包体积异常大卡在构建阶段这类问题常见于把服务端代码引入到了客户端 bundle。tRPC 类型贯通带来一个好处但也养出一个坏习惯有人从 server 目录直接 import 了一个运行时对象到前端组件比如appRouter结果导致整个服务端代码被卷入客户端。在组件里你需要的是import type { AppRouter }而不是import { appRouter }。构建分析时如果发现api目录被打进 bundle九成是这个问题。5. 模板约定背后的一些个人习惯我后面做项目时t3code的输出不仅仅是目录结构还有一些固定习惯跟着沉淀下来了。比如所有 Router 的返回类型都用Promise{ items, nextCursor }这类明确的对象结构不用松散数组方便前端做分页和缓存策略。所有 create/update mutation 都带返回完整实体的include避免前端为了拿最新数据又单独发一次查询。还有一个看得见的小细节日期时间全部交给数据库的default(now())和updatedAt不在应用层 new Date()避免多实例部署时的时钟差异导致排序混乱。这些约定单独看都很小但组合起来新人在模板基础上加功能时自然会顺着这套思路写代码风格长期不乱。在实际操作中我发现最值钱的部分并不在于模板本身而是它强制你走了“先定义模型、再写流程、后写页面”的顺序。很多项目最后变得混乱都是从页面侧反推数据库两头反复改。t3code帮你把这条路径铺好了接下来能走多远就看你在这些约定上能沉淀多少自己的业务经验了。
返回列表