ARTICLE DETAIL

资讯详情

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

T3 Stack全栈实战:从Prisma到tRPC的类型安全内容后台开发

T3 Stack全栈实战:从Prisma到tRPC的类型安全内容后台开发 接手 t3code 这个项目之前我对全栈工程的预期还停留在老一套要么前后端各起一套服务顺手维护一份永远晚半拍的接口文档要么上 GraphQL配一堆 codegen 脚本每次改完模型都得重新生成一遍类型。t3code 是一个典型的内容后台规模不大但迭代节奏极快数据关系又不能出错——用户、栏目、标签、文章状态来回折腾。这活儿听起来不难真正开工后才发现技术选型这一个决定直接决定了你接下来三个月是顺着往下写还是天天在接口对不上、类型对不齐的泥潭里擦地板。这篇文章就把 t3code 从零到线上这套完整操盘过程拆开聊包括为什么选 T3 Stack、目录和工程怎么铺、Prisma 数据表怎么自然长成 tRPC 接口、前端怎么在不碰 fetch 的情况下拿到类型安全的 API、上线前我清掉的几个诡异问题以及最后十几万行数据下怎么做的分页和小优化。如果你正准备开工一个类型安全的全栈应用或者已经在 T3 Stack 里踩过几个坑这篇应该能帮你省掉不少试错时间。1. 为什么是 t3code选 T3 Stack 是在选什么先说项目本身。t3code 是个内部内容运营后台核心场景很朴素运营人员维护分类和标签、编辑写文章、管理员审核并发布前台要按状态、按时间把已发布的文章列出来。用户角色就两类EDITOR 和管理员 ADMIN不需要复杂权限矩阵但「审核状态变更是强约束」——草稿不能进前台列表已发布的不允许再改回草稿这些规则如果靠约定来守迟早会出事。当时摆在桌面上的方案有四个方案端到端类型安全学习成本生成代码负担适配场景Next.js Express 手写 REST几乎为零需要自己维护 DTO低无团队以传统后端为主前端偏弱Next.js Route Handler zod局部客户端到服务端仍靠手写封装低无接口少、结构扁平的 CRUDGraphQL Apollo codegen很高但依赖每次 codegen 跑到位高较高生成文件体积大多客户端、查询维度极多T3Next.js tRPC Prisma Tailwind全链路且零生成负担中无1~2 个客户端、迭代快、后端逻辑集中于单体最后落地的是 T3 Stack也就是 t3code 这个代号里的 T3。这里头最关键的一个认知是tRPC 给的不只是少写一层接口而是把接口契约从一份维护成本极高的文档变成一种编译期约束——我改一个 Prisma 字段前端只要用到了这个字段编译立刻报错。这在做内容后台这种重业务、重状态机的项目里价值比少写几行 axios要重要得多因为最常见的线上事故就是接口字段改崩了但没人知道直到前端渲染出一片空白。1.1 tRPC 和 GraphQL 真正拉开差距的地方以前我也试过 GraphQL 配 codegen体验其实不差。但它在小团队项目里有个隐性成本codegen 生成的类型文件动辄上千行每次 schema 变更都要同步跑脚本再提交生成的 diff。tRPC 则是类型即导出——router 里声明的 query / mutation前端 useQuery 直接拿到完整的输入和输出类型输入还能用 zod 在边界做运行时校验。等于把 codegen 的一整套流水线压缩掉了。当然 tRPC 不是万能的。多客户端、第三方开放 API、需要复杂聚合查询的场景还是 GraphQL 更合适。但 t3code 这种前后端一套代码仓库、只有 Web 一个客户端的内部系统选 tRPC 属于典型地把资源花在刀刃上。1.2 T3 的隐藏收益类型是最好的人员交接文档团队里后来加入了一名新同学我原来还担心要花两周给他讲接口文档。结果他发现根本不需要文档——打开 VSCode鼠标悬停在任意一个api.post.create上入参、返回结构、哪里可空、哪里报错全在类型提示里。类型就是契约也是文档这是 T3 技术栈在协作层面最被低估的价值。2. 工程初始化与目录规整create-t3-app 之后的那些事初始化本身没什么悬念一条命令pnpm create t3-applatest t3code交互式勾选的时候我建议这样选NEXT.js App RouterTypeScripttRPCHTTP Server 端调用这两项都勾后面做服务端组件数据获取会用到PrismaNextAuth哪怕先不用登录也建议勾上省得后补一堆配置Tailwind CSS包管理器用 pnpm理由是 T3 生态里 pnpm 的依赖链接方式对trpc/*这类依赖比较友好安装速度快磁盘占用也小。脚手架到位之后真正的工程化改造才开始。默认的目录结构大概长这样src/ ├── app/ # 页面与路由 ├── server/ │ ├── api/ │ │ ├── routers/ # 按业务拆分的 router │ │ ├── root.ts # 汇总所有 router │ │ └── trpc.ts # procedure 定义 │ ├── db.ts # Prisma Client 单例 │ └── auth.ts # NextAuth 配置 ├── env.js # 环境变量运行时校验 ├── env.mjs ├── styles/ └── trpc/2.1 业务目录不是按表拆是按聚合边界拆这是 t3code 里我做的最重要的一个重构决定。初版我把 router 按表拆了userRouter、postRouter、tagRouter各管各的看起来很清爽。可一碰到发布一篇文章同时要更新统计数、给标签计数、写入审计日志这种跨聚合的操作只能在一个 mutation 里挨个调多个 router 的方法类型和上下文混在一起越写越别扭。后来改成按业务域聚合src/server/api/routers/ ├── post.ts # 文章列表、详情、发布、归档、Draft 状态流转 ├── author.ts # 作者查询、资料更新内部关联 user 数据 └── tag.ts # 标签管理文章与标签联动原则很简单一个操作闭环尽可能收敛在同一个 router 的 mutation 里跨 router 调用只处理真正独立的边界。比如发布文章这个动作它涉及文章状态变更、发布时间写入、标签关联全部放在post.ts里路由层不再感知 User 表或 Tag 表长什么样只通过 Prisma 的关联关系访问。2.2 环境变量必须在进程启动时就校验T3 脚手架带了一个env.mjs用 zod 做运行时校验。这个设计值得强调环境变量缺失的报错最好发生在进程启动第一毫秒而不是等你跑到某个接口才卡住。t3code 早期就吃过亏——CI 里DATABASE_URL写错但没被发现结果构建成功部署后只有跑到首页列表才报错排查成本直线上升。我实际用到的环境变量大概是这样// src/env.mjs import { createEnv } from t3-oss/env-nextjs; import { z } from zod; export const env createEnv({ server: { DATABASE_URL: z.string().url(), NODE_ENV: z.enum([development, test, production]), AUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.string().url().default(http://localhost:3000), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, AUTH_SECRET: process.env.AUTH_SECRET, NODE_ENV: process.env.NODE_ENV, }, });NEXT_PUBLIC_前缀的变量会被打进前端 bundle所以只放真正要给浏览器用的值数据库地址这类敏感信息绝不加这个前缀。3. 从数据表到类型安全接口Prisma Schema 和 tRPC Router 的完整链路这是 t3code 的核心部分数据建模是怎么一路贯穿到前端类型的。3.1 Schema 设计别为可能的关系买单文章模块的 Prisma Schema 我精简后长这样enum Role { EDITOR ADMIN } enum PostStatus { DRAFT PUBLISHED ARCHIVED } model User { id String id default(cuid()) email String unique name String? role Role default(EDITOR) posts Post[] createdAt DateTime default(now()) } model Tag { id String id default(cuid()) name String unique posts Post[] relation(PostTags) } model Post { id String id default(cuid()) title String slug String unique content String db.Text status PostStatus default(DRAFT) authorId String author User relation(fields: [authorId], references: [id]) tags Tag[] relation(PostTags) publishedAt DateTime? createdAt DateTime default(now()) index([status, publishedAt]) }有两点是吃过亏之后特意加上的第一publishedAt我没有设default(now())因为草稿没有发布时间必须等发布动作发生时才写值。用DateTime?可空类型来表达未发布这个状态比用什么isPublished布尔值更贴近真实语义查已发布文章只需要WHERE publishedAt IS NOT NULL。第二index([status, publishedAt])这个复合索引是为后面前台列表准备的。文章列表最常跑的查询是按状态过滤 按发布时间倒序复合索引能让这条路径走完整个叶子节点就够了。单字段索引在这种场景下救不了场。迁移直接用一条命令pnpm prisma migrate dev --name init这里提醒一下跑数据库迁移的读者migrate dev在执行时会创建 shadow database 来比对 schema 差异。如果你的数据库账号没有建库权限这条命令会在中途卡住报错信息指向一堆 internal error。解决办法是让数据库账号至少拥有CREATE DATABASE权限或者在 schema.prisma 里显式配置shadowDatabaseUrl指向一个专用的 MySQL/Postgres 实例。3.2 procedure 的层级设计公开、登录、管理员三类就够了tRPC 的类型安全不只在字段层面权限语义也是可以写死到类型里的。我在trpc.ts里定义了三层 procedure// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { type Session } from next-auth; import { ZodError } from zod; const t initTRPC.context{ db: PrismaClient; session: Session | null }().create(); export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session } }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed); const enforceUserIsAdmin t.middleware(({ ctx, next }) { if (!ctx.session?.user || ctx.session.user.role ! ADMIN) { throw new TRPCError({ code: FORBIDDEN }); } return next({ ctx: { ...ctx, session: ctx.session } }); }); export const adminProcedure t.procedure.use(enforceUserIsAdmin);这样在业务 router 里开放查询用publicProcedure写操作一律protectedProcedure涉及权限管理或彻底删除的操作用adminProcedure。路由的可见性直接通过类型暴露给前端非管理员登录后前端在类型层面根本「接触不到」管理员接口——虽然最终安全还得靠服务端校验但类型层面的隔离能让误调用在编译期就现形。3.3 一个 router 走通列表、详情、创建下面是post.ts的典型实现// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ../trpc; export const postRouter createTRPCRouter({ listPublished: publicProcedure .input( z.object({ take: z.number().int().min(1).max(50).default(10), cursor: z.string().cuid().optional(), }) ) .query(async ({ ctx, input }) { const posts await ctx.db.post.findMany({ where: { status: PUBLISHED, publishedAt: { lte: new Date() } }, take: input.take 1, // 多取一条用来判断是否还有下一页 cursor: input.cursor ? { id: input.cursor } : undefined, skip: input.cursor ? 1 : 0, orderBy: [{ publishedAt: desc }, { id: desc }], select: { id: true, title: true, slug: true, publishedAt: true, author: { select: { name: true } }, }, }); const hasMore posts.length input.take; const items hasMore ? posts.slice(0, input.take) : posts; const nextCursor hasMore ? items[items.length - 1]?.id : undefined; return { items, nextCursor }; }), getBySlug: publicProcedure .input(z.object({ slug: z.string().regex(/^[a-z0-9](?:-[a-z0-9])*$/) })) .query(({ ctx, input }) { return ctx.db.post.findFirst({ where: { status: PUBLISHED, slug: input.slug }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), slug: z.string().regex(/^[a-z0-9](?:-[a-z0-9])*$/), content: z.string().min(10), tagIds: z.array(z.string().cuid()).min(1).max(5), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, slug: input.slug, content: input.content, authorId: ctx.session.user.id, tags: { connect: input.tagIds.map((id) ({ id })) }, }, }); }), });take 1这个技巧多讲一句这是游标分页常用的判重下页手段比先count()再findMany()要少一次数据库往返。代价是列表末端会多取一条数据但 slice 掉之后对响应体积影响几乎为零。一体化接口带来的体验是——我在listPublished里改了返回字段编辑器立刻在api.post.listPublished的调用处报错告诉我某个页面在访问已经不存在的字段。这种反馈闭环是传统 REST 完全给不到的。4. 前端接入的日常没有 fetch 的调用体验前端部分t3code 走的是最常规的组合页面组件在use client下直接用 React Query 消费 tRPC hooks列表页和详情页的数据获取全部走 tRPC不开任何手写的 API 层。4.1 三种数据获取方式的取舍T3 应用里拿数据通常有三条路径客户端组件 useQuery适合大部分交互型页面天然带 loading/error 状态配合 React Query 的缓存避免重复请求。服务端组件 tRPC Server Caller适合首屏要求快、不希望前端来回跳 loading 的页面。直接在 Server Component 里调用postRouter.createCaller拿数据预渲染进 HTML。服务端组件 直接查 Prisma适合管理员后台这类不对外暴露、又需要复杂聚合统计的页面。绕过 tRPC 层直接查库代码最少但也没有接口边界了。t3code 前台列表用的第一种后台管理页用的第三种只有详情页用了第二种——因为详情页要做 SEO 友好的 SSR又想复用 tRPC 里写好的查询逻辑。列表页前端代码是这样的use client; import { api } from ~/trpc/react; export function PostList() { const [cursor, setCursor] useStatestring | undefined(undefined); const { data, isPending, isError } api.post.listPublished.useQuery({ take: 10, cursor, }); if (isPending) return div classNametext-sm text-gray-500加载中.../div; if (isError) return div classNametext-sm text-red-500列表加载失败/div; return ( div ul classNamespace-y-2 {data.items.map((post) ( li key{post.id} a href{/posts/${post.slug}}{post.title}/a span classNametext-xs text-gray-400by {post.author.name}/span /li ))} /ul {data.nextCursor ( button onClick{() setCursor(data.nextCursor)}加载更多/button )} /div ); }注意这里useState驱动的cursor直接作为 query key 的一部分传进去React Query 会自动为不同的 cursor 参数缓存不同的数据分页滚动加载时旧分页不会重新请求。这是 tRPC React Query 默认行为不需要额外配置。4.2 乐观更新改缓存优于转圈圈内容后台这种场景编辑最烦的就是点一下保存等两秒白屏转圈。t3code 的后台草稿列表我做了乐观更新核心逻辑是用 React Query 的setData改本地缓存让新文章立刻出现在列表里后台静默刷新。const utils api.useUtils(); const createPost api.post.create.useMutation({ onMutate: async (newPost) { await utils.post.listPublished.cancel(); const prev utils.post.listPublished.getData({ take: 10 }); utils.post.listPublished.setData({ take: 10 }, (old) [ ...(old?.items ?? []), { id: temp-id, title: newPost.title, slug: newPost.slug, publishedAt: null }, ]); return { prev }; }, onError: (_e, _v, ctx) { if (ctx?.prev) { utils.post.listPublished.setData({ take: 10 }, ctx.prev); } }, onSettled: () { utils.post.listPublished.invalidate(); }, });这套模式的关键点cancel()取消正在进行的查询setData写入本地临时数据invalidate在请求结束后让真实数据重写覆盖临时数据。写的时候最容易漏的是onError里的回滚——临时数据一定要恢复到操作前的快照否则报错之后界面上会挂着一条根本不存在的记录。5. 上线前顺手清掉的五个典型坑讲实力这部分才是真正烧时间的地方。5.1 迁移卡死在 shadow database权限问题比 SQL 报错更隐蔽现象跑prisma migrate dev到一半进度条停在Creating shadow database没动静几分钟后抛出连接超时或权限错误。原因Prisma 需要临时创建一份和主库表结构一样的 shadow database用来比对当前 schema 和已迁移记录的差异。数据库账号如果只有单库读写权限没有CREATE DATABASE权限就会卡死。解决办法有两个选一个即可给数据库账号增加创建数据库权限在schema.prisma里显式配shadowDatabaseUrl指向一个单独的测试实例。我团队里用的是托管数据库的只读账号最后选了第二种。提醒把这条写进初始化文档里不然换台电脑跑migrate dev又得现查一次。5.2 NEXT_PUBLIC 变量与运行时校验的边界t3code 早期犯过一个很低级的错在env.js里给NEXT_PUBLIC_APP_URL加了个url()校验结果在无网络环境跑next build时这个变量不存在构建直接挂了。这是 T3 的createEnv特意设计的宁可启动失败也不带着错误配置跑的保护机制但同时也要求你提前把 CI 里需要的变量全部配齐。我最后整理的环境变量清单变量是否 NEXT_PUBLIC必须存在环境DATABASE_URL否本地/CI/生产AUTH_SECRET否本地/CI/生产AUTH_TRUST_HOST否生产NEXT_PUBLIC_APP_URL是生产另外强调一次任何NEXT_PUBLIC_开头的变量都会被打进客户端 bundle浏览器能直接看到。API key、数据库密码这类敏感信息永远不能带这个前缀。5.3 部署后的幽灵数据路由缓存和 revalidate 的冲突上线第一个周末收到线上反馈后台改了文章标题前台首页过了一夜还是旧标题甚至刷新也没用。查了半天根因是 Next.js App Router 的静态渲染缓存和前端显示的数据不一致。列表页是客户端组件虽然数据是运行时请求的但页面本身有generateStaticParams的静态路由缓存被标记为force-static导致下游数据更新没法触发页面重新渲染。解决方式很直接给列表页和详情页加export const dynamic force-dynamic告诉 Next.js 不要做静态优化每次请求都重新渲染。代价是丢掉了静态化带来的首屏优势但对内容后台这类数据一致性优先的内部系统值。如果将来首页流量大了再换成revalidate 300这种按时间刷新没必要为了可能的高性能牺牲正确性。5.4 invalidate 的颗粒度陷阱React Query 的invalidate默认只清当前参数的缓存。比如草稿列表页第一次加载用的{ take: 10 }如果发布一个文章后调用invalidate({})想当然以为清空列表缓存实际只清了默认参数下的缓存用户滚到了下一页take: 20那份缓存还是旧的列表尾部会看到刚发布的新文章但顶部没有变化感官上像排序出 bug 了。正解是带通配符清理utils.post.listPublished.invalidate({ take: 10, cursor: undefined }); // 或者干脆全量失效 await utils.post.listPublished.invalidate();默认不带参数会失效所有今天子范围的缓存。具体用哪种取决于你要的精确度——更新频率高的用全量失效代价是滚动分页的状态也没了有可能跳回第一页。5.5 AUTH_SECRET 长度与生产会话失效NextAuth 在开发环境下默认会自己生成一个 secret看起来一切正常一上生产如果在环境变量里随便配了个短字符串NextAuth 会直接拒绝启动或者每次进程重启后会话全部失效。t3code 就踩过一次开发环境一直用着默认值上生产时忘了配AUTH_SECRET结果部署后访问任何需要登录的接口都是 401。生成一个合格 secret 很简单openssl rand -base64 32把它写进生产环境变量即可。开发与生产环境务必分开开发环境的默认 secret 绝不能带到生产。6. 性能与观测上线之后的必要优化先泼冷水后台系统真不是看并发第一的地方。t3code 上线后我做的优化基本围绕两件事——列表接口的响应速度以及出问题时能不能快速定位。6.1 游标分页与索引的配合十几万条文章数据后原本用skip做跳页分页的接口开始明显变慢OFFSET 100000这种查询会把前面所有行都翻一遍。换游标分页后查询条件变成取 id 或时间大于某个点的下一条走索引直接命中目标速度是稳的。具体的 Prisma 实现我在前面 router 代码里已经写了这里补充一个容易忽略的细节游标分页的排序字段必须和索引顺序一致。我的 schema 里索引是index([status, publishedAt])排序用的orderBy: [{ publishedAt: desc }, { id: desc }]这里面其实有个隐患——id在辅助索引里没有单独排序遇到同一秒发布的两篇文章游标值会不稳。稳妥的玩法是加一个唯一且有序的字段当 tie-breaker或者直接确保publishedAt在业务上唯一否则分页可能出现轻微的重复/漏条这种小概率问题在内容系统里刚好是运营最不能接受的。6.2 日志是后台系统的行车记录仪t3code 的日志走的是pino加 tRPC 中间件给每个请求打一行结构化日志// src/server/api/trpc.ts const loggerMiddleware t.middleware(async ({ ctx, path, type, input, next }) { const start Date.now(); const result await next(); const durationMs Date.now() - start; logger.info({ path, type, durationMs, userId: ctx.session?.user?.id ?? anonymous, input: type query ? undefined : input, ok: result.ok, }); return result; });这种日志在排查某个用户说保存失败这类问题时非常重要能看到具体是哪个路由、哪个用户、花了多少时间、输入是什么。代码层面input我只在 mutation 里记录query 不记避免把大数据量的查询参数全都刷进日志。6.3 缓存策略与后台消费体验最后补一点缓存相关的调优。后台列表页如果用户频繁切换筛选条件React Query 默认的staleTime是 0每次切换都重新请求遇到慢接口会很烦躁。t3code 的管理后台我给查询侧统一加了const { data } api.post.listPublished.useQuery( { take: 10 }, { staleTime: 5_000, // 5秒内认为数据是新鲜的避免频繁请求 } );前台列表则保持默认因为内容更新对 SEO 和时效性要求更高5 秒的 staleTime 反而会拖慢新内容上线的可见性。至于后续如果要加更复杂的权限、审计、多级缓存淘汰T3 的架构也给足了扩展空间——中间件可以挂审计日志prisma 扩展可以统一写操作时间戳这些都是后续迭代顺手能加的事不用推翻重来。做 t3code 这一圈下来我最深的一个体会是T3 Stack 这种技术栈的收益不是立竿见影的省事而是把「类型契约」这把尺子插到了从数据库到前端的每一层。短期它可能让你多写几行z.object的入参校验但长期维护新功能的时候那种改一个字段只知道一个文件、编译器自动告诉你哪里没跟上的确定性才是它真正的价值。如果你正好在犹豫要不要给下一个项目上这套组合我的建议是——别犹豫先跑一个迭代看看大概率你会和我一样回不去了。
返回列表