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

T3栈全栈开发实战:t3code 类型安全工程化指南

T3栈全栈开发实战:t3code 类型安全工程化指南 ★ FEATURED ARTICLE
第一次看到 t3code 这个项目名字的时候我以为是某个加密算法库点进去才发现这是围绕 T3 技术栈写的一整套可复制的全栈工程范本。T3 栈这两年在前端圈子里热度一直没降核心就是 Next.js TypeScript Tailwind tRPC再配上 Prisma 和 NextAuth几乎把“类型安全”这四个字贯彻到了骨子里。t3code 能帮你解决什么问题说白了就是一件事让你不再纠结“前端写接口、后端写接口、两边类型对不上”这种破事。从数据库表结构到浏览器页面组件所有数据的形状都是同一个 TypeScript 类型在约束改一个字段全链路都在编译期给你报错。它适合两类人一类是想系统学习 T3 栈怎么落地的中高级前端另一类是小团队里想快速把全栈应用搭起来、又不想维护两套接口文档的开发者。这篇文章不是 T3 官方文档的翻译而是我拿着 t3code 逐行跑了一遍、部署上线、又踩了一堆坑之后的实操记录。里面会有具体的 Schema 设计、tRPC 路由组织、前端接入代码也有我在本地联调和线上部署时遇到的真实报错和排查思路。你照着抄能省下至少一周的摸索时间。1. T3 技术栈选型逻辑解析1.1 为什么“TypeScript-first”这么让人上瘾先聊一个最基础的问题T3 栈到底在解决什么痛点传统全栈开发里前后端是两套代码、两种语言甚至两个仓库。前端要调后端接口得先看接口文档后端改了字段文档没更新前端调用时就一脸懵就算有 Swagger、OpenAPI也少不了“文档写的和实际返回的不一样”这种荒诞时刻。如果团队用的是 TypeScript理论上前后端共享类型实际操作中你会发现共享一个types.ts文件很容易但真正让接口运行时也用同一套类型几乎没有现成方案。T3 栈的答案很直接既然大家都用 TypeScript干脆别搞 HTTP 接口那套了直接在服务端函数里调用客户端通过 React Query 挂钩子类型从头到尾是一份。这就是 tRPC 在做的事。它不像 REST 那样定义 URL、Method、请求体、响应体而是像调本地函数一样调远程函数参数和返回值都由 TypeScript 编译器保证。我用一个生活化的类比解释 tRPCREST 接口像打电话你得先拨号写 URL、说你好请求头、等对方回答响应然后再解析对方的话JSON.parse。tRPC 更像直接推开隔壁工位的门说“帮我把这份文件处理一下”你俩在同一间办公室语言完全一致根本不需要翻译。这个类比有点糙但道理就是这个道理——类型就是双方共同的母语。Next.js 负责页面渲染和路由Tailwind 负责样式Prisma 负责数据库访问。这四个东西单独拎出来任何一个都不是新东西但组合在一起它们形成了一个非常顺滑的链路Prisma 的 Schema 文件是数据库的类型源头 → tRPC 的 Router 是 API 的类型源头 → React Query 的 Hook 是客户端的类型源头三者由 TypeScript 编译器串在一起任何一环改动其他环节会在编译期亮红灯。1.2 t3code 项目的整体定位与设计取舍t3code 不是那种“教你写一个 Todo List 就结束”的入门教程它的定位更像一个“带注释的生产级样板间”。里面包含了一个完整业务模块的增删改查、认证逻辑、数据库模型设计、错误处理甚至还有部署配置。它的几个设计取舍我觉得特别能体现作者的工程判断力第一目录结构遵循“服务端逻辑与客户端逻辑物理隔离”的原则。所有涉及数据库、鉴权、API 路由的代码放在src/server目录下所有页面组件放在src/app或src/pages目录下取决于 App Router 还是 Pages Router。这样做的直接好处是你绝对不会在组件里直接写prisma.xxx查询所有数据访问都收敛在 tRPC Router 里审计和安全边界非常清晰。第二数据校验统一用 Zod并且放在 tRPC Router 的入口层。客户端传进来的参数先过 Zod再过业务逻辑最后才到数据库。这个顺序不能乱。我见过很多项目把校验写在数据库层或者前端组件里前者的问题是错误反馈太晚、数据库被无谓请求打到后者的问题是校验逻辑分散、容易被绕过。t3code 的做法是标准答案入参校验必须发生在 API 边界。第三认证用 NextAuth现在叫 Auth.js并且和 tRPC 的中间件机制做了深度绑定。受保护的查询和修改操作统一走protectedProcedure没登录的用户直接抛 UNAUTHORIZED前端会收到统一的错误结构。这个设计比我见过的很多“登录了就显示按钮没登录就手动 hide”的方案优雅得多权限判断在服务端完成而不是靠前端表演。2. 核心代码细节与工程化实践2.1 数据层设计Prisma Schema 的建模细节t3code 里的主业务模块以笔记应用Note App为例数据模型涉及用户User、笔记Note、标签Tag以及二者之间的多对多关系。我从实际操作角度把 Schema 里最值得注意的几个点拆开讲。// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] notes Note[] tags Tag[] } model Note { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] } model Tag { id String id default(cuid()) name String unique notes Note[] }这里面有三个细节容易踩坑第一主键用了cuid()而不是自增 int。原因很简单业务上无论是笔记还是标签未来都可能被客户端直接引用用自增 id 容易被人遍历抓取全部数据cuid 在分布式环境下也不冲突。自增主键在单体低并发场景下没问题但作为一个生产级模板cuid 是更稳的选择。第二updatedAt DateTime updatedAt是 Prisma 帮你自动维护的字段。你更新任何一条记录的任意字段它都会自动刷新。这个字段在展示“最后编辑时间”时非常有用但你得知道它是 Prisma Client 的行为如果你绕过 Prisma 直接改数据库这个字段不会自动更新。第三外键关系上显式声明了onDelete: Cascade。这意味着删除用户时他名下的所有笔记会被级联删除。这个设计在业务上是合理的但需要注意如果以后你的笔记表要保留匿名归档这个级联策略就要改。数据库关系设计没有银弹关键是要意识到“关系的维护策略是你自己控制的”而不是 Prisma 帮你决定的。迁移数据库时直接用 Prisma Migrate 命令即可npx prisma migrate dev --name init npx prisma generatemigrate dev会对比 Schema 文件生成 SQL 迁移并自动在本地开发库上执行。generate会重新生成 Prisma Client 类型这一步在每次改 Schema 后都必须执行否则你的prisma/client里的类型是旧版编译会报错。2.2 API 层设计tRPC 路由与 Zod 校验t3code 的 tRPC 路由组织方式非常清晰值得直接抄// src/server/api/routers/note.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const noteRouter createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) { return ctx.db.note.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { updatedAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { const note await ctx.db.note.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: input.tags?.length ? { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, }))} : undefined, }, }); return note; }), delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) { const note await ctx.db.note.findUnique({ where: { id: input.id }, }); if (!note || note.authorId ! ctx.session.user.id) { throw new Error(Note not found or unauthorized); } await ctx.db.note.delete({ where: { id: input.id } }); return { success: true }; }), });这个文件里的每一个选择都有讲究。getAll用了query而不是mutation虽然它本质上也是查数据库但语义上你要区分“只读操作”和“写操作”这不仅影响前端生成的 Hook 形态useQuery还是useMutation也影响 React Query 的缓存策略。create的入参用了z.object做嵌套校验尤其注意id字段用了z.string().cuid()它会在最外层拦截非法格式的 id比在服务端手动判断typeof id string要优雅得多。Zod 的错误信息还能自动映射成 tRPC 标准的TRPCClientError结构前端拿到后可以直接展示给用户。delete这个块我特别想提醒findUnique之后你要手动比对note.authorId和ctx.session.user.id是否一致。很多人以为既然接口是登录用户才能调用那就可以直接删了。但别忘了登录用户 A 可以传用户的 B 的笔记 id 过来。t3code 这个模式叫“对象级权限校验”你要防的不是没登录的人而是登录了但没权限的人。protectedProcedure的定义是在 tRPC 的 root 里通过中间件实现的// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { type Session } from next-auth; import { ZodError } from zod; interface CreateContextOptions { session: Session | null; } export const createTRPCContext async ({ session }: CreateContextOptions) { return { session, db }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, errorFormatter: ({ error, shape }) ({ ...shape, data: { ...shape.data, zodError: error.code BAD_REQUEST error.cause instanceof ZodError ? error.cause.flatten() : null, }, }), }); export const createTRPCRouter t.router; export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这中间最关键的是errorFormatter。它把 Zod 的校验错误统一转换成 tRPC 的错误结构前端拿到error.data.zodError之后可以直接按字段拆分渲染到表单下方。这意味着你只写一份校验规则服务端执行它、前端展示它、类型系统约束它实现了真正的“一处定义全链路生效”。2.3 前端接入React Query 与类型推断前端调用 tRPC看起来像在调用本地函数实际底层走的还是 HTTP。只是这个“胶水层”被完全封装了。t3code 里的页面组件长这样// src/app/notes/page.tsx use client; import { api } from ~/trpc/react; export default function NotesPage() { const { data: notes, isLoading, error } api.note.getAll.useQuery(); const createNote api.note.create.useMutation({ onSuccess: () { // 手动失效缓存让它重新拉取最新数据 utils.note.getAll.invalidate(); }, }); if (isLoading) return div加载中.../div; if (error) return div拉取笔记失败{error.message}/div; return ( div classNamespace-y-4 p-8 {notes?.map((note) ( div key{note.id} classNamerounded border p-4 h2 classNametext-lg font-bold{note.title}/h2 p{note.content}/p /div ))} /div ); }这里面有两点值得展开讲。第一api.note.getAll.useQuery()返回的data它的 TypeScript 类型是从noteRouter.getAll的返回值自动推断出来的。你绝对不需要手动给notes定义一个接口类型也不需要在page.tsx里import type { Note } from ~/server/db。这是 tRPC 和手写 REST API 的核心差异也是它对抗 GraphQL 时最大的优势专人专用不引入任何额外的 codegen 步骤类型推断在编译器和类型系统的层面就天然完成。第二useMutation里的onSuccess回调注意我调用了utils.note.getAll.invalidate()。这个utils变量来自api.useUtils()。它的作用是告诉 React Query“这条查询的缓存已经过期了请重新拉取。”这是 T3 应用里最容易忽略的一步——很多人写 mutation 只关心“请求发出去了”忘了数据库已经更新但前端还停留在旧缓存上于是页面上看不到新创建的数据还以为接口没生效。缓存更新策略是 React Query 的必修课在 t3code 里你能看到标准的做法。3. 从零到一完整实操复现3.1 初始化项目与基础配置不管你看的是 t3code 仓库里的哪个版本最稳妥的起步方式是用官方脚手架初始化一个同款工程然后再对照 t3code 的代码结构逐模块迁移。初始化命令npm create t3-applatest它会交互式问你要不要启用 NextAuth、Prisma、Tailwind、tRPC。全选即可这就是完整 T3 栈。初始化完成后默认目录结构大概是这样的src/ app/ # Next.js App Router 页面 pages/ # 如果用 Pages Router 则是这个目录 server/ api/ routers/ # tRPC 路由文件 auth.ts # NextAuth 配置 db.ts # Prisma Client 实例 trpc/ react.tsx # 前端 tRPC Hook 封装 server.ts # 服务端 tRPC 初始化 prisma/ schema.prisma # 数据库模型初始化之后第一件事不是写代码是配环境变量。在.env文件里至少要有三项DATABASE_URLpostgresql://postgres:passwordlocalhost:5432/t3code NEXTAUTH_SECRETopenssl rand -base64 32 生成一个 NEXTAUTH_URLhttp://localhost:3000如果DATABASE_URL填的是 SQLite也行T3 脚手架默认可以支持sqlite作为临时方案但我的经验是别省这一步直接用本地 Postgres。原因很简单Prisma 的很多高级特性比如Json字段、全文搜索、enum在 SQLite 上是残缺的后续迁移到生产环境必然要换数据库与其到时候踩一遍差异的坑不如一开始就用生产同款数据库。3.2 搭建第一个完整业务模块笔记/待办现在假设你跟着 t3code 做一个笔记应用的核心模块。我按顺序列出完整步骤每一步都标出关键命令和代码。第一步改 Prisma Schema加入Note模型内容参考前面 2.1 节的 Schema。改完执行迁移npx prisma migrate dev --name add_note npx prisma generate第二步创建 tRPC Router。新建src/server/api/routers/note.ts代码参考 2.2 节的示例。然后在src/server/api/root.ts里注册它// src/server/api/root.ts import { noteRouter } from ./routers/note; export const appRouter createTRPCRouter({ note: noteRouter, }); export type AppRouter typeof appRouter;这行export type AppRouter typeof appRouter是整个类型安全链路的核心你必须把它导出去前端所有useQuery才能推断出正确的类型。第三步写前端页面。在src/app/notes/page.tsx里用api.note.getAll.useQuery()和api.note.create.useMutation()完成列表展示和新建功能。代码参考 2.3 节。第四步在根布局里加一个入口链接确保用户能访问到/notes页面// src/app/layout.tsx import Link from next/link; import { TRPCReactProvider } from ~/trpc/react; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( html langzh-CN body TRPCReactProvider nav classNameflex gap-4 p-4 Link href/首页/Link Link href/notes笔记/Link /nav {children} /TRPCReactProvider /body /html ); }注意TRPCReactProvider一定要包裹整个应用它负责把 tRPC 的客户端实例和 React Query 的 QueryClient 注入到组件树里。如果漏了这个 Provider前端调用api.note.getAll.useQuery()会直接报This hook can only be used inside a TRPCReactProvider。第五步跑起来看效果npm run dev打开http://localhost:3000/notes。如果你没有登录getAll会返回 UNAUTHORIZED 错误。别慌这就是protectedProcedure在正常工作。你可以在src/server/auth.ts里暂时配置一个简单的 Credentials Provider 或者直接用 GitHub OAuth登录之后再刷新列表就能正常显示了。3.3 本地联调与部署验证本地联调这一步我建议刻意做一个小实验来验证类型安全的威力改一下 Prisma Schema把Note.title从String改成String?然后保存并重新执行prisma generate。你会看到前端代码里凡是直接使用note.title的地方立刻爆出一堆 TypeScript 错误——因为现在它可能是null了你必须做空值处理。这就是类型安全的价值数据库结构的变化在编译期就传导到了每一个使用它的组件而不是等到运行时才从接口返回里炸出来。部署方面我用的是 Vercel PostgresNeon 或者 Supabase 都可以。需要注意三件事第一Vercel 环境变量要和本地.env完全对齐。每次在 Vercel 控制台改环境变量都要等新部署生效热更新不会自动注入到当前实例里。第二NEXTAUTH_URL在生产环境必须改成你的线上域名否则 OAuth 回调会跳到 localhost。第三Prisma Client 在 Vercel 的 Serverless 环境里需要跑一次prisma generate才能正常加载引擎。你可以在package.json里加一条构建钩子{ scripts: { postinstall: prisma generate } }这样每次构建时都会自动生成客户端避免部署后运行时报PrismaClientInitializationError。4. 踩坑实录常见问题与排查技巧4.1 类型错误与 tRPC 上下文问题我见过最多的报错长这样Property user does not exist on type object | undefined或者是Type Session | null is not assignable to type Session这种类型报错几乎都出在 tRPC 上下文Context类型不匹配上。你需要在src/server/api/trpc.ts里检查createTRPCContext的返回类型和initTRPC.context...()接受的泛型是否完全一致。一个非常隐蔽的坑是如果你在 Context 里加了session: Session | null但中间件里只判断了ctx.session没有给next传入新的ctx那protectedProcedure内部拿到的ctx.session类型还是Session | null于是.user.id就会报错。解决办法是参考前面 2.2 节的代码在enforceUserIsAuthed中间件里用return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } })显式收窄类型。这几行代码写了后面所有protectedProcedure里的ctx.session.user.id才能安全取值。4.2 React Query 缓存带来的假数据问题另一个高频坑是“数据明明写进数据库了页面上就是看不到”。这 90% 是 React Query 缓存没失效。你用api.note.delete.useMutation()删了一条数据但页面上那条笔记还留在列表里直到你手动刷新浏览器。t3code 的标准处理方式是在 mutation 的onSuccess里手动失效相关 queryconst utils api.useUtils(); const deleteNote api.note.delete.useMutation({ onSuccess: () { utils.note.getAll.invalidate(); }, });invalidate会让 React Query 标记该查询过期并重新请求页面立即恢复最新状态。如果你担心invalidate太粗暴想用setData做乐观更新那也完全可以但复杂度更高——新手不建议开始就上乐观更新老老实实用 invalidate 就好。4.3 部署环境下的 Prisma 与数据库问题部署上线后最常见的报错是PrismaClientInitializationError: engine not found或者Error: Environment variable not found: DATABASE_URL前者通常是prisma generate没在构建阶段执行后者是环境变量没配对。部署前你可以在本地构建一遍验证环境是否 OKnpm run build如果本地构建能通过大概率代码没大问题剩下的就是把环境变量对整齐再部署。还有一个容易被忽略的数据库连接字符串里如果带特殊字符比如密码里有或#Postgres 的 URL 解析会出问题。别手改 URL去万不得已时要用encodeURIComponent转义特殊字符。4.4 认证回调和 Cookie 的坑NextAuth 在本地跑没问题一部署就“登录成功后回到首页但没登录状态”这种环节十有八九是NEXTAUTH_URL没配对你的线上域名。还有一个更隐蔽的坑如果你在 Next.js 中间件里做了自定义的前端路由保护比如判断session存在才放行页面那要特别小心 Cookie 的 SameSite 策略。跨域时浏览器可能拦截认证 Cookie导致每次刷新都丢会话。用同域部署或者正确的代理配置能从根本上避开这个问题。5. 一点个人经验t3code 这种项目最值得吸收的不是某个具体 API 的用法而是它对“全链路类型安全”的组织方式。我自己在把它的模板套用到一个小型项目管理工具时感受特别深有一次改数据库模型把任务的优先级字段从枚举改成了整数结果前端所有用到优先级的地方全部标红我一路改下来全程没有跑过前端单靠编译器的报错就把整个链路理顺了。这种体验用惯了之后再回头写那些手拆 JSON 的接口时代真的会觉得不适应。最后再分享一个小技巧t3code 里的errorFormatter那段代码我建议原封不动保留。它不只是把 Zod 错误格式化得更好看更重要的是它让表单校验错误能直接以结构化的方式从服务端传到前端你甚至可以做“前端零校验全信服务端”的极简模式。这种设计省掉的代码量比你想的多得多。
阅读完成 · 觉得有帮助?
咨询建站