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

t3code全栈模板:TypeScript端到端类型安全的实践指南

t3code全栈模板:TypeScript端到端类型安全的实践指南 ★ FEATURED ARTICLE
t3code 是我最近在维护的一个全栈 TypeScript 项目模板。名字没什么玄机就是把 T3 Stack 的几样东西——TypeScript、Tailwind CSS、tRPC再加上配套的 Next.js、Prisma、Zod——揉进一个可以直接拿来开新项目的代码仓库。折腾这个模板的初衷很直接我受够了每次起项目都要重新配置认证、数据库、类型校验这一堆重复劳动也受够了前后端各写一份接口文档然后两边对不齐的日子。如果你写过几个全栈项目、想试试端到端类型安全到底有多省事或者单纯想看看 tRPC 这类方案在实际工程里怎么组织代码这篇文章值得你从头到尾看一遍。1. 整体设计与思路拆解为什么选 T3 全家桶1.1 从“三件套”到完整闭环T3 Stack 最早来自 Theo 那套“简单、无聊、有主见”的技术选型哲学核心就一句话每个技术栈成员都应该能在三天内学会并真正用起来。这里面的套路是用量取胜的。T3 里的三个 T 分别是 TypeScript、Tailwind CSS 和 tRPC但我实际使用下来真正让这套方案能落地的是它把“类型安全”贯彻到了整条链路上Prisma 负责数据库到服务端的类型tRPC 负责服务端到前端的类型前端组件拿到的不再是“任何 JSON”而是带有完整推导类型的对象。很多人第一次看到 T3 会有个疑惑这不就是 Next.js 套了一堆库吗其实差别很大。Next.js 只解决了页面和服务端的运行框架并没有给出“数据从数据库到 UI 到底该怎么流动”的答案。t3code 在设计时把这条链路强行拧成了一个闭环任何对数据的修改都必须经过 router任何进入 router 的参数都必须经过 Zod 校验任何前端调用都能在编译期发现字段写错或类型不对。这个闭环一出现前后端联调里最耗时间的“字段名对齐”“返回结构核对”“文档过期”一类问题就基本消失了。1.2 选型背后的取舍我见过不少人一上来就喷 tRPC 是“自娱自乐”说还是 REST 通用、GraphQL 灵活。这些观点各有道理但得看场景。t3code 针对的是“前后端同一个 TypeScript 仓库”的 Web 应用这个前提下 REST 和 GraphQL 的优势反而变成了负担。我整理过一个简单的对比表基本代表了我的取舍逻辑方案类型安全需要代码生成学习成本最适合的场景REST弱靠手写类型或 OpenAPI 同步可选低公开 API、多端消费GraphQL中靠 schema 手写或生成客户端通常需要高复杂聚合、移动端多场景tRPC强完全自动推导不需要低同仓库全栈 TS、内部接口这个表不是说 REST 或 GraphQL 不好。如果你的前端是原生 App、小程序、第三方开放平台tRPC 这种前后端强耦合方案并不合适老老实实 REST。但如果只是 Web 应用前后端代码放一个仓库tRPC 能让你省掉一层“模型映射”的心智负担。t3code 的定位很清晰服务端渲染为主、客户端交互为辅的现代 React 应用不搞分布式微服务也不用对外开放接口。还要说一个容易踩的坑T3 方案里“类型安全”是建立在前后端共享同一套类型的基础上的。如果你后面把前端拆成独立仓库或者引入了非 TypeScript 的消费端tRPC 的优势会瞬间归零迁移成本还不低。所以我的原则是——选用 t3code 这类模板之前先确认你的项目在可见的未来不会拆仓库。这是选型层面的硬约束比选什么框架都重要。1.3 t3code 的目录结构与分层约定脚手架默认生成的目录已经很规范了我在此基础上又定了两条硬约定。先看核心结构t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ # Next.js App Router 页面层 │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/route.ts # tRPC 唯一的 HTTP 入口 │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ # 按业务域拆分的 router │ │ │ └── trpc.ts # router/procedure 工厂与中间件 │ │ ├── db.ts # PrismaClient 单例 │ │ └── auth.ts # NextAuth 配置 │ ├── trpc/ │ │ ├── server.ts # 服务端调用 helper │ │ └── react.ts # 前端 useQuery/useMutation 用的 api 对象 │ └── utils/ │ └── api.ts # createTRPCReact 实例 └── env.js # 环境变量校验我的第一条约定src/server目录是“禁地”任何前端组件、任何服务端组件都不允许直接 importdb.ts。数据访问只能通过 router 暴露的 procedure这保证了所有数据库操作都经过统一的校验、鉴权和日志埋点。第二条约定router 文件名和 Prisma model 名对齐postRouter只操作Post模型出现跨模型的业务逻辑抽到server/lib里复用而不是随手塞进某个 router。这两条约定在初期看起来有点“小题大做”等业务加到二十多个模型的时候你会感谢自己当初没乱来。2. 核心技术细节解析与实操要点2.1 tRPC 路由器贯穿前后端的类型通道tRPC 的核心是路由器router和过程procedure。procedure 分两类query用来读数据mutation用来写数据。两者都可以通过.input()挂一个 Zod schematRPC 会在执行前自动完成校验和类型推导。下面这段是 t3code 里基础配置的骨架代码// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import type { createTRPCContext } from ./context; const t initTRPC.contexttypeof createTRPCContext().create(); export const createTRPCRouter t.router; export const publicProcedure t.procedure; const isAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { user: ctx.session.user } }); }); export const protectedProcedure t.procedure.use(isAuthed);initTRPC在初始化时就把 context 类型绑定进去了后续所有 router 都能感知ctx.session和ctx.db。中间件机制很像 Express但它的返回值会合并进 context并且类型在编译期就能被感知。这里的精髓是protectedProcedure一旦被调用后面的 procedure 里ctx.user就一定是非空的你根本不需要在业务代码里再写一遍“如果没登录就返回 401”的防御逻辑。很多人刚开始用 tRPC 会忽略错误处理的分类。实际工程里我会要求团队把错误分成两类输入错误和业务错误。输入错误交给 Zod 的报错信息直接返回前端拿到code: BAD_REQUEST就知道是自己传参的问题业务错误比如余额不足、重复提交应该在 router 内部捕获再抛成合适的TRPCError比如CONFLICT、FORBIDDEN。这么分类的好处是前端拿到错误码就能做针对性提示而不是把所有错误都当“网络错误”弹横幅。2.2 Zod 校验把边界卡在入口Zod 和 tRPC 是黄金搭档它最大的价值不是“写起来爽”而是把校验边界固定在了接口入口。前端传上来的数据一律不可信这是安全的底线。我在模板里给 recommend 校验规则定了一条标准能校验类型就校验类型能限制长度就限制长度能 trim 就 trim。看个例子const createPostSchema z.object({ title: z.string().trim().min(1, 标题不能为空).max(100, 标题最长 100 字), content: z.string().trim().max(5000, 正文最长 5000 字), }); type CreatePostInput z.infertypeof createPostSchema;.infer可以从 schema 反推 TypeScript 类型这意味着你永远不需要手写一份“接口请求类型”。还有一个很实用的小技巧对输出做校验。比如有些老的数据库字段结构不干净直接返回给前端容易让类型声明和实际数据不一致你可以在 procedure 的.output()挂上 Zod schematRPC 会在返回前再校验一次。代价是额外的运行时开销但换来的安全边际非常值尤其是接口里有Date对象需要序列化、或者嵌套了关联查询的时候。2.3 Prisma 建模与迁移数据库这一层不能省Prisma 在 T3 里的角色是“最后一公里的类型发电机”。你只需要在schema.prisma里定义模型prisma migrate dev会自动生成迁移文件并执行 SQLprisma generate会生成带完整类型提示的客户端。t3code 里预置了一个用户和文章的基础模型大概长这样model User { id String id default(cuid()) name String? posts Post[] } model Post { id String id default(cuid()) title String content String default() authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }开发阶段我建议直接用 SQLite 起步一个文件就能跑通整个链路等部署之前再切到 PostgreSQL。需要注意切换数据库是要动datasource db里的 provider还要重新生成迁移文件最好在项目早期就定下来。另外我强烈建议没事多用用npx prisma studio它会在浏览器里给一个可视化后台调试数据比在 SQL 客户端里盯字段舒服太多。2.4 环境变量与配置管理create-t3-app 有个很硬核的默认行为用 Zod 校验所有环境变量启动时缺一个变量就直接报错退出而不是等到运行时某个地方莫名其妙地 undefined。t3code 里沿用了这套思路所有环境变量集中在env.js统一声明import { createEnv } from t3-oss/env-nextjs; import { z } from zod; export const env createEnv({ server: { DATABASE_URL: z.string().url(), AUTH_SECRET: z.string().min(32), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: process.env, });这里有个细节client前缀的变量必须以NEXT_PUBLIC_开头而且千万别把密钥类配置放进 client 段否则会直接打到浏览器端。我见过有人图省事把数据库密码写在NEXT_PUBLIC_DATABASE_URL里构建一通过就上线结果密码全泄露了。环境变量这个事宁可启动时多报几次错也不能运行时默默用空值。3. 实操过程与核心环节实现3.1 初始化项目create-t3-app 的选择怎么勾t3code 不搞特立独行就是用官方脚手架起的家。初始化命令很简单npm create t3-applatest t3codeCLI 会问你一堆问题我按实际经验给一份勾选建议选项我的建议说明TypeScript必选T3 的立身之本不选就没意义了Tailwind CSS默认勾上如果团队有现成组件库可以去掉tRPC必选这是整套方案的核心Prisma推荐勾上有数据库需求就勾没有可以先不勾NextAuth.js看需要需要登录就勾不勾后面自己接很麻烦App Router推荐新版默认Pages Router 只适合老项目迁移初始化完成后先别急着写代码第一时间跑npm run dev确认默认的 todo 示例能跑起来再把示例页面清空。我踩过的坑是有人初始化完直接删文件结果把src/app/api/trpc/[trpc]/route.ts这个 tRPC 的 HTTP 入口删了后面所有前端请求全部 404排查半天才发现路由文件丢了。这个 catch-all 路由是前后端通信的桥别动它。3.2 从数据库到页面的完整链路写一个带鉴权的 Post CRUD为了让新手看清楚链路我拿“发布文章”这个场景走一遍完整流程。第一步定义好Post模型并迁移模型代码见 2.3 节。执行npx prisma migrate dev --name add_posts npx prisma generate第二步新建 router 文件把业务逻辑都放这里// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: { select: { name: true } } }, }); }), create: protectedProcedure .input(z.object({ title: z.string().trim().min(1).max(100), content: z.string().trim().max(5000), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, }, }); }), });第三步把它挂到根路由上// src/server/api/root.ts import { createTRPCRouter } from ~/server/api/trpc; import { postRouter } from ./routers/post; export const appRouter createTRPCRouter({ post: postRouter, }); export type AppRouter typeof appRouter;第四步前端组件里直接调用类型完全不丢use client; import { useState } from react; import { api } from ~/utils/api; export function PostList() { const utils api.useUtils(); const [title, setTitle] useState(); const { data: posts, isLoading } api.post.list.useQuery(); const createPost api.post.create.useMutation({ onSuccess: () utils.post.list.invalidate(), }); if (isLoading) return p加载中.../p; return ( div form onSubmit{(e) { e.preventDefault(); createPost.mutate({ title, content: }); }} input value{title} onChange{(e) setTitle(e.target.value)} placeholder输入文章标题 / button disabled{createPost.isPending}发布/button /form ul {posts?.map((post) ( li key{post.id} {post.title} - {post.author?.name ?? 匿名} /li ))} /ul /div ); }第五步回到页面组件里把它渲染出来。这里有一个新手最容易忽略的点api.post.list.useQuery()只能在 client component 里使用因为它底层依赖 React Query 的 hooks。如果你的页面是服务端组件要么把这段抽出来放进单独的PostList组件并在外层标注use client要么走服务端调用方式。我一般习惯把“需要交互的数据组件”全部封成独立 client 组件页面服务端只负责布局这样边界清晰很多。调用完成后utils.post.list.invalidate()会让 React Query 重新拉取列表数据实现“发完立即刷新”不需要手动维护缓存版本号。这也是我特别喜欢 tRPC React Query 组合的原因类型和缓存策略都给你安排好了你要做的只是把业务逻辑写对。3.3 部署与性能优化从本地到线上t3code 默认面向的方案是 Next.js 的 Serverless 部署所以首选 Vercel基本上git push就能完成发布。部署前记得在 Vercel 的项目环境变量里把DATABASE_URL换成生产数据库的连接串同时把AUTH_SECRET生成一个足够长的随机值。数据库连接串建议用带 SSL 模式的很多云数据库默认要求 SSL本地能连、线上连不上多半就是这个原因。如果不想绑定 Vercel也可以用 Node 自托管。需要在next.config.js里加上const nextConfig { output: standalone, };然后构建产物会在.next/standalone里变成一个可以单独运行的 Node 服务部署时把public目录和.next/static一起拷过去。这种方式的启动命令是node .next/standalone/server.js内存占用比整套 Next 开发依赖小很多适合跑在一台固定的小机器上。性能优化方面最值得做的一件事是请求合并。tRPC 的httpBatchLink默认会把同一时刻发出的多个请求合并成一个 HTTP 请求这在列表页同时调用四五个 query 的时候非常有用能显著减少网络往返。还有个进阶技巧在服务端组件里通过createCaller直接调用 router 函数绕过 HTTP 层减少一次网络开销。但要注意这种方式拿不到浏览器的请求上下文ctx.session里的用户信息要自己从 cookies 解析处理起来略麻烦我一般只在确实需要首屏直出的公开数据上用。4. 常见问题与排查技巧实录4.1 改了 Router前端类型却不更新这是 t3code 使用中出现频率最高的问题。场景通常是我在 router 里给某个 procedure 加了新的 input 字段回到前端组件却报“类型上不存在该属性”重启了 dev server 也还是不行。排查看三步。第一步确认你修改的 router 已经挂在了根路由上如果只是新建了routers/comment.ts但没在root.ts里注册那它在类型世界里根本不存在。第二步跑一次npx tsc --noEmit看是不是项目里有旧的.next类型缓存干扰必要时rm -rf .next后重新npm run dev。第三步检查你是不是手写了前端类型。tRPC 的最大优势就是“类型只有一个来源”一旦你手写了一个interface Post { ... }去对接接口数据类型就会开始分叉然后你会陷入无穷无尽的字段对齐。4.2 Prisma 客户端报错Prisma 的报错信息对新手很不友好我把高频的几个整理成了速查表报错现场根因处理方式Query engine library for current platform could not be foundPrisma Engine 二进制缺失执行npx prisma generate必要时删掉node_modules/.prisma重装P1001 Cant reach database server网络不通或连接串错误检查DATABASE_URL确认云数据库白名单P2002 Unique constraint failed重复数据插入唯一字段捕获 Prisma 错误码映射成TRPCError({ code: CONFLICT })binaryTargets相关报错Serverless 部署云环境缺少本地引擎二进制在 schema 的 generator 中声明binaryTargets [native, linux-musl]关于第四个问题多说一句Vercel 等 Serverless 平台运行在 Linux 容器里和本地 macOS 的 Prisma 引擎二进制不通用。你本地prisma generate产出的引擎很可能部署后就找不到。解决办法是在schema.prisma里这样配置generator client { provider prisma-client-js binaryTargets [native, linux-musl-openssl-3.0.x] }然后重新prisma generate并推到线上构建。版本不同这个配置略有差异以官方文档为准但思路是一样的让构建环境同时产出本地和云端的引擎二进制。4.3 App Router 里 tRPC 的“客户端/服务端分界”Next.js App Router 引入服务端组件之后tRPC 的调用姿势多了坑也跟着多了。最典型的一个报错Cannot read properties of undefined (reading useState)或者更直白的“Youre importing a component that needsuseState. It only works in a Client Component but none of its parents are marked with use client”。这个报错的本质是你在服务端组件里用了api.post.list.useQuery()。React Query 的 hooks 依赖useState而服务端组件根本不挂载 React hooks 运行环境。正确的边界划分是这样页面路由、布局这种纯展示的组件留在服务端组件里凡是用了 tRPC hooks、表单交互、状态管理的组件统一标注use client。如果一个 client 组件内部又嵌了服务端组件优先用 children 的方式传而不是直接 import 服务端组件进 client 组件。这个约束刚上手会觉得烦但习惯之后你会发现页面性能反而更好因为真正需要 hydrate 的部分变少了。4.4 部署后接口 404 或环境变量缺失本地一切正常线上页面能打开但所有 tRPC 请求 404这类问题基本都出在路由或环境变量上。先确认src/app/api/trpc/[trpc]/route.ts存在且内容没被改坏。它导出的handler必须使用你项目实际的appRouter和createTRPCContext如果是从别人项目粘过来的模板可能引用了不存在的导出函数构建不报错但运行时打不到正确的处理器。再排查环境变量。线上启动时会执行env.js的 Zod 校验任何缺失或格式不对的变量都会导致进程启动失败或接口异常。特别注意NEXTAUTH_URL这类变量在 Auth.js v5 里已经改名成了AUTH_URL和AUTH_SECRET版本不同叫法不同粘贴老教程里的环境变量名很容易踩坑。我的建议是出现奇怪部署问题后先看构建日志里有没有环境变量校验失败的红字这比翻业务代码效率高得多。最后再分享两个我在实战里沉淀下来的小经验第一个经验不管项目多大都保持“一个数据模型对应一个 router 文件”的纪律。业务复杂了router 之间需要互相调用的场景会变多这时候正确的做法是用createCaller在服务端内部互相调用而不是在一个 router 里 import 另一个 router 的私有函数。这样每个 router 的对外接口边界是完整的以后写测试也方便。第二个经验把类型测试当成项目的一部分。t3code 里我加了一个简单的类型断言文件专门检查 AppRouter 的类型确实能推导出前端useQuery的参数类型。这种测试跑得很快但它能在你重构数据库模型或改 router 签名时第一时间提醒你哪些前端调用点被影响了比等构建报错再回头修要省心得多。如果你正准备用 T3 这套东西搭建自己的项目我建议先别一次梭哈所有功能。从“两张表、三个 query、两个 mutation”的最小闭环开始跑通再逐步加入鉴权、文件上传、后台管理这些模块。类型安全这套体系的价值会随着项目复杂度上升越来越明显但在五个模型以内你可能完全感受不到它比传统方案强在哪里。等哪天你被一个“接口文档写的是 A 字段代码里用的是 b 字段”的问题折磨过后就会明白 t3code 这套折腾到底是图什么了。
阅读完成 · 觉得有帮助?
咨询建站