资讯详情

资讯详情

t3code全栈样板工程:以类型安全重构前后端联调链路

1. t3code在解决什么问题一次全栈项目选型的真实算账t3code是我在GitHub上维护的一套全栈样板工程核心思路很朴素把 Next.js、TypeScript、tRPC、Prisma、Tailwind 这五个工具以类型安全为共同主线串成一个开箱即用的底座。很多人第一次看到 T3 Stack 会觉得它是网红技术栈真正用起来才发现它解决的问题非常具体——前后端联调时那种接口文档写了等于没写、改个字段两边同时炸的尴尬在 t3code 这套架构里会大幅减少。1.1 传统分工模式里类型断裂的代价被低估了先说说我为什么最终选了这套方案。早几年做全栈项目最常见的结构是前端一个仓库、后端一个仓库前端用 Axios 手写接口调用后端用 Swagger 出文档。这种模式在项目小的时候很顺畅一旦业务复杂起来问题就暴露了后端改了接口返回结构前端只要没及时更新类型定义运行时大概率直接拿不到字段。接口文档要么没人维护要么反序列化类型和真实返回根本对不上联调阶段一半时间在扯皮。所有接口调用都得手写一遍类型声明一百个接口就是一百份重复劳动模板代码多到让人麻木。t3code 这套东西最核心的思路就是把这些手写类型的环节改成自动推导。数据库表结构由 Prisma 的 schema 定义类型从 schema 生成后端接口由 tRPC 的 router 定义前端调用方可以直接复用同一套类型。换句话说类型不是人写的而是从源头流出来的。我第一次跑通这条链路的时候感觉最明显的变化是前后端对字段的理解从约定变成了编译期强制。1.2 选型时我也对比过其他方案不只是跟风可能有人会说Next.js 自带 API Route也可以用 GraphQL为什么偏偏选 tRPC我实际对比过几种方案方案类型穿透能力学习成本服务端缓存/鉴权灵活性适合场景REST 手写类型弱需要维护两份代码低高完全自定义团队已有成熟规范GraphQL codegen中等需要生成步骤中高中需要自己处理缓存复杂聚合查询、多端复用Next.js API Route fetch弱仍需额外类型层低高简单项目够用tRPC强端到端自动推导中理解泛型需要时间中高procedure 可包装鉴权全栈 TypeScript 团队真实体验下来tRPC 并不是万能的。如果你的项目需要给第三方提供公开 API或者前端不只是 Web 端还有大量小程序/移动端REST 或者 GraphQL 仍然是更稳的选择。t3code 的定位是内部全栈项目尤其是一个团队同时写前端和后端的场景。类型穿透的优势在这个场景下被放到最大而对外暴露接口的劣势则不太重要。1.3 t3code 的边界感它不是什么都能干我特别想强调一点t3code 不是拿来即用的业务框架它更像一个骨架清晰的最小可运行系统。你拿它起步能省掉配环境、折腾连接、搭建目录结构的琐碎时间但业务逻辑还是要自己写。它也不会替你决定部署方式、数据库到底用 PostgreSQL 还是 MySQL、组件库用哪个——这些是需要你自己拍板的部分。如果你正处在一个想试试全栈 TypeScript但不知道从哪下手的状态或者被前后端类型不一致的 bug 折磨过这篇文章接下来的内容会很有用。我会把 t3code 的骨架逻辑、一个真实功能的落地过程以及我在生产环境里踩过的坑一条一条拆开讲。2. 骨架拆解五个核心成员是怎么咬合在一起的开始实战之前有必要先把 t3code 的骨架逻辑讲清楚。这五个工具各司其职但真正让它们产生化学反应的是彼此之间的接口设计。理解了这个咬合关系后面遇到报错时你能更快判断问题出在哪一层。2.1 Next.js 是底座App Router 决定了边界的划分t3code 基于 Next.js 的 App Router 构建。App Router 带来的一个关键变化是服务端组件和客户端组件的边界。服务端组件在服务器上执行可以直接访问数据库、读取环境变量代码不会打包到浏览器。客户端组件需要标记use client在浏览器里运行负责交互逻辑。这个边界对类型安全很重要。t3code 里的 tRPC 在服务端可以直接调用在客户端则需要走一个专门的 hooks 封装。你要做的是清晰地区分哪些逻辑必须在服务端处理比如直接查库、处理密钥相关的操作和哪些逻辑必须交给浏览器比如点击事件、表单状态。我在刚上手时经常犯的错是把所有组件都写成客户端组件导致服务端该干的事跑到浏览器里去干类型倒是没崩性能和安全性先崩了。2.2 tRPC 是无文档的契约tRPC 的特别之处在于它不需要你写接口文档也不需要你手写类型定义。你在服务端定义了一个 router// server/api/routers/post.ts import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; import { z } from zod; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional(), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: input }); }), });前端调用时类型会被自动推断出来连输入参数的校验规则都是同一份// app/_components/post-list.tsx use client; import { api } from ~/trpc/react; export function PostList() { const { data: posts, refetch } api.post.list.useQuery(); const createPost api.post.create.useMutation({ onSuccess: () refetch(), }); return ( div {posts?.map((post) ( div key{post.id} h3{post.title}/h3 {post.content p{post.content}/p} /div ))} button onClick{() createPost.mutate({ title: 新文章, content: 内容 }) } 新建 /button /div ); }注意这里的api.post.list.useQuery()不是普通的函数它背后是 React Query 的 hooks。也就是说缓存、重试、请求状态管理这些逻辑也被 tRPC 的 React 封装一并处理了不需要你再单独引入一套数据请求方案。2.3 Prisma 是类型的源头在 t3code 里数据库表结构由 Prisma 的 schema 定义。你只要维护一个schema.prisma文件Prisma 就能生成对应的 TypeScript 类型和 Client。这意味着数据库字段变成代码里的类型这个环节是自动的不存在数据库改了个字段代码里忘了同步的问题。generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model Post { id String id default(cuid()) title String content String? createdAt DateTime default(now()) updatedAt DateTime updatedAt }执行npx prisma migrate dev之后ctx.db.post.findMany()里返回的post对象就是带类型的。这个类型再被 tRPC 的返回值携带一路传到前端组件整个链路就通了。我在实际使用中最喜欢的细节是改 schema 之后只要重新生成 Prisma Client前端代码马上会报出所有字段名对不上的地方这种安全感是传统接口联调给不了的。2.4 Tailwind 是样式层但要配合组件规范Tailwind 在 t3code 里承担的是样式职责。它本身和上面的类型链路关系不大但它决定了样板工程的 UI 长什么样。我是这么看 Tailwind 的它可以让你不写 CSS 文件就完成大部分布局但如果完全靠 utility class 堆页面后期维护会更吃力。在 t3code 里我会建议搭配一个组件库比如 shadcn/ui来收敛样式原子。Tailwind 负责基础原子类组件库负责提供结构化的组件业务页面只关心数据和布局不用每次从零拼按钮和输入框。这个组合用起来比较顺手也不容易出现改一个按钮样式要翻十几个文件的窘境。2.5 一张图看完整链路如果把它们的关系简化为一条数据流大概是这样的Prisma Schema → 数据库表 TypeScript 类型 ↓ tRPC Router → 输入校验 业务逻辑 类型导出 ↓ React Query Hooks → 状态管理 缓存 ↓ React 组件 → 渲染与交互这条链路的每一个环节类型的承重墙都是自动生成的人只负责写业务逻辑。理解了这点后面排查问题时会非常有用——当你看到一个类型报错你要判断的其实是它发生在哪一层数据库层、接口层、还是组件层。3. t3code 实战落地从数据库到页面的类型闭环理论讲完接下来把袖子撸起来。我用一个真实的例子——从初始化项目到实现一个带数据库写入和列表展示的完整功能——带你跑一遍 t3code 的落地过程。3.1 初始化项目交互式命令行里有哪些选项先说明一下如果你要手动复刻 t3code 的结构最直接的方式是用官方脚手架生成一个同构项目npx create-t3-applatest my-app执行命令后交互式界面会问你要不要启用 Tailwind、tRPC、Prisma、NextAuth 等模块。我的建议是如果只是学类型闭环Prisma 和 tRPC 必须勾上。NextAuth 可以先不勾它处理的是鉴权逻辑第一次上手时容易把注意力从类型链路上带偏。Tailwind 建议勾上即使你不用它后续调整界面也方便。脚手架生成完之后目录结构大致是这样的prisma/ schema.prisma src/ server/ api/ root.ts # 汇总所有子 router routers/ post.ts # 业务 router trpc.ts # tRPC 实例与上下文 db.ts # Prisma Client 单例 trpc/ react.tsx # 客户端 tRPC provider 与 hooks server.ts # 服务端 tRPC caller可选 app/ api/trpc/[trpc]/ route.ts # tRPC 的 HTTP 入口 _components/ post-list.tsx page.tsx这个结构本身是 t3code 的核心价值——它把所有样板代码和配置文件都摆好了你不用从空白开始思考tRPC 挂在哪个文件、Prisma Client 怎么全局共享这类问题。3.2 配置数据库环境变量与连接串的讲究默认情况下脚手架会用 SQLite 起步但我建议你直接换成 PostgreSQL 或者 MySQL因为生产环境基本不会用 SQLite。修改prisma/schema.prismadatasource db { provider postgresql url env(DATABASE_URL) }然后在.env里配置DATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code这里有一个很容易踩的坑不要把包含敏感信息的 .env 文件提交到 Git。脚手架生成的.gitignore通常已经帮你把.env排除掉了但如果你是自己手动搭的环境一定要检查确认。数据库连接串相当于数据库的钥匙泄露出去就是一个严重的安全事故。配置好之后在 schema 里加上最简单的 Post 模型然后执行迁移npx prisma migrate dev --name init这个命令会把变更同步到数据库同时生成 Prisma Client。如果没有任何报错你打开src/server/db.ts里面应该是这样的import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这个全局单例的模式很关键。它确保在开发环境的模块热更新HMR过程中不会因为每次文件变更都创建新的 Prisma Client 而导致连接数暴增。不写这个单例开发到一半你可能会看到数据库连接被占满的警告。3.3 写第一个 tRPC 接口从 router 到页面接下来定义一个 post router并在根 router 里注册// src/server/api/root.ts import { createTRPCRouter } from ~/server/api/trpc; import { postRouter } from ~/server/api/routers/post; export const appRouter createTRPCRouter({ post: postRouter, }); export type AppRouter typeof appRouter;回到之前那个例子——我们已经有list和create两个 procedure 了。现在重点是理解前端如何调用。在服务的入口处t3code 已经把 HTTP 路由挂好了src/app/api/trpc/[trpc]/route.ts所以前端不需要关心请求路径。你只需要在客户端组件里使用api对象use client; import { api } from ~/trpc/react; export function PostList() { const utils api.useUtils(); const { data: posts, isLoading } api.post.list.useQuery(); const createPost api.post.create.useMutation({ onSuccess: () utils.post.list.invalidate(), }); if (isLoading) return divLoading.../div; return ( div {posts?.map((post) ( div key{post.id}{post.title}/div ))} button onClick{() createPost.mutate({ title: Hello t3code })} 新增 /button /div ); }这里你可能注意到我没有用refetch而是用了utils.post.list.invalidate()。这是 React Query 比较推荐的做法让缓存失效后自动重新拉取而不是手动触发请求。在 t3code 里React Query 的缓存策略可以按业务场景定制比如设置staleTime减少不必要的请求。3.4 类型是怎么流动的以及为什么你不会漏改字段很多人第一次看 t3code 会有个疑问前端代码明明没有导入任何后端类型为什么posts是带类型的答案在于 tRPC 的泛型推导。api.post.list.useQuery()的api对象是createTRPCReactAppRouter()生成的而AppRouter又是typeof appRouter。所以TypeScript 会把appRouter里所有 router 的输入输出类型全部推导出来并注入到前端的 hooks 类型参数中。这相当于一个全项目的类型地图前端不需要手动 import编辑器自动补全就能看到返回字段。这套机制带来的实际效果是如果后端list返回值从{ title: string }变成了{ title: string; content: string }前端除非显式解构字段否则不会报错但如果后端改了字段名比如title改成name前端所有用到post.title的地方会立即标红。这种改错名十分钟内发现的体验在传统接口联调里几乎不敢想。4. 生产环境最容易翻车的坑和完整的排查链路t3code 这套架构本地跑得很顺但一到生产环境还是有几个高频问题我一个个说清楚包括排查思路和解决方案。4.1 数据库连接被边缘函数打满Prisma 连接池配置如果你把 Next.js 部署到 Vercel 这类边缘/Serverless 环境会遇到一个特别典型的坑Prisma Client 默认创建的连接池和 Serverless 按需启动的短生命周期实例不匹配。现象是请求量稍微上来之后数据库报错P1001连接超时或者后台看到大量闲置连接。原因是每个 Serverless 实例都会创建自己的 Prisma Client实例销毁时连接池来不及释放积少成多。排查链路先看日志是不是PrismaClientInitializationError或者连接数告警。确认是否每个 function 实例都持有了数据库连接。解决方向有两个一个是在数据库里把直接连接地址和连接池地址分开一个是用外置连接池。在我的 t3code 仓库里推荐的做法是在.env里区分两个变量DATABASE_URLpostgresql://user:passwordhost:5432/db?pgbouncertrueconnection_limit1 DIRECT_URLpostgresql://user:passwordhost:5432/db然后在 schema 里声明datasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) }DATABASE_URL走连接池适合 Prisma Client 在 Serverless 环境里频繁创建连接DIRECT_URL走直连适合执行迁移命令时使用。迁移的时候不需要连接池直连更可靠。4.2 tRPC 类型不同步两端的版本断裂t3code 的强项是类型穿透但如果你把前后端拆成两个仓库部署或者一个仓库里前后端依赖版本不一致就会出现后端新加了一个 procedure前端类型却没更新的情况。这种断裂在 monorepo 结构里尤其常见。典型报错是前端调用api.xxx.yyy时TypeScript 提示Property yyy does not exist on type ...。排查链路确认是否运行过npm install确保trpc/server和trpc/react-query版本一致。确认src/server/api/root.ts里新 router 有没有正确注册。清掉 TypeScript 的增量缓存npx tsc --noEmit如果仍报错再看编辑器里的 TypeScript 服务是否旧缓存重启 TS Server。如果你用了 monorepo检查子包之间的依赖是否指向了旧构建产物必要时重新 build。这类问题的根源基本不在代码而在类型生成层没有同步。我建议把prisma generate和类型检查放进 CI 流程任何一步没跑过都不允许合并。4.3 App Router 的use client边界错误使用带来的混乱App Router 出现之后哪些组件能直接用 tRPC hooks成了新问题。tRPC 的 React hooks 依赖客户端状态React Query 的 Provider所以只能在客户端组件里调用。但是很多新手会把整个页面都标成use client导致服务端组件完全失效首屏渲染变成浏览器渲染性能明显下滑。正确做法是页面外壳保持服务端组件只有需要交互的叶子组件按钮、表单、列表容器单独标记use client。比如在 t3code 里一个典型的服务端页面骨架// app/page.tsx服务端组件 import { PostList } from ./_components/post-list; export default function HomePage() { return ( main h1文章列表/h1 PostList / /main ); }而PostList内部才是一个客户端组件。这样做的好处是页面首屏可以静默渲染只有列表区域在客户端进行交互。如果你发现自己把use client写到了页面最顶层就要停下来想想是不是所有代码都真的需要运行在浏览器上。排查思路用 React DevTools 看组件是否被正确区分为 Server/Client用构建产物检查浏览器 bundle 的大小如果服务端代码被打进去了边界就出问题了。4.4 Zod 校验失败时的模糊报错tRPC 的输入校验依赖 Zod。当你传的参数不合法时报错信息有时会比较原始尤其是嵌套对象里的字段名错误容易看得一头雾水。我踩过的一个典型例子createPost.mutate({ title: });z.string().min(1)会抛错但如果你没有配置全局错误处理或者前端显示逻辑用户只会看到一个笼统的错误。解决办法是在客户端统一做错误映射const createPost api.post.create.useMutation({ onError: (err) { const message err.shape?.message ?? 请求失败; // 在这里把 message 展示给用户 }, });生产环境里我建议前端对用户输入先做一层 UI 校验比如禁用按钮、提示必填Zod 校验作为后端兜底。这样既保证用户体验流畅也不会依赖后端错误提示的文案质量。5. 部署收尾连接池、边缘函数与构建配置最后聊聊把 t3code 从开发机推到生产环境时我在部署层面整理的几条经验。5.1 数据库连接方案按部署环境区分前面已经提到连接池的问题这里再细化一下。在生产环境我通常给数据库配置两个连接地址DATABASE_URL给应用运行时使用走连接池比如通过外部连接池服务或者数据库自带的连接池端口。DIRECT_URL给prisma migrate deploy这类迁移命令使用直连更稳定。如果是 PostgreSQL还建议设置合理的connection_limit。在 Serverless 场景下单个实例的并发请求数通常很小connection_limit1是合理的起点。不要把它设得太大否则实例一多连接数会迅速膨胀。5.2 构建阶段的类型检查不要跳过t3code 的脚手架在构建时会默认跑tsc --noEmit和next build。很多人嫌慢会在 CI 里把类型检查关掉这是个典型隐患。类型安全是这个架构的地基如果构建阶段不检查类型等于把自动推导的优势白白扔掉了。我的建议是本地开发npm run dev可以跳过完整类型检查保证热更新速度。CI/生产构建必须跑npm run build它会执行完整类型检查。首次启动慢一点没关系换来的是类型断裂不会悄悄溜进生产。5.3 边缘运行时Edge Runtime和 Node 运行时的选择Next.js 页面可以配置为 Edge Runtime 或 Node.js Runtime。tRPC 和 Prisma 有一些生态兼容性差异尤其是 Prisma它依赖 Node 的部分原生能力和 Edge Runtime 的兼容性并不总是理想。我的经验是如果用了 Prisma就老老实实用 Node.js Runtime。Edge Runtime 的优势在于全球低延迟响应但前提是你的数据层也要跟着驻留边缘否则请求最终还是得回源到数据库所在区域优化效果非常有限。在 t3code 里我会让 API 路由和页面都使用默认的 Node.js Runtime优先保证稳定性和功能完整。5.4 环境变量的三个必须部署到托管平台不管 Vercel、Netlify 还是自建 Node 服务环境变量都要单独检查一遍漏掉任何一个都会在运行时出问题。我的检查清单是DATABASE_URL和DIRECT_URL是否正确指向生产数据库。NODE_ENV是否自动设置为production大多数平台会自动处理。是否有任何第三方服务的密钥、公开地址被硬编码进代码里。另外有一点要特别提醒前端的构建产物里不要包含任何服务端密钥。Next.js 里只有以NEXT_PUBLIC_开头的环境变量才会被打进浏览器 bundle其他变量只在服务器端可见。在 t3code 里数据库连接串永远不要以NEXT_PUBLIC_开头否则等于把数据库钥匙挂在门口。最后聊几句实际使用感受如果你现在正在纠结要不要仿照 t3code 的方式搭业务我的个人建议是先在一个小项目上试水而不要一上来就全量迁移。t3code 这套架构的好处是类型安全和开发体验代价是你需要习惯所有东西都要先定义类型的思路。有些同学刚开始会觉得麻烦但跑通第一个功能后大多数人回不去手写接口类型的日子。小技巧放最后开发时如果你发现编辑器卡顿或者类型提示不更新先试试重启 TypeScript 服务如果问题还在再清node_modules/.cache。这类问题八成不是代码错误而是 TypeScript 的增量缓存和 tRPC 的复杂泛型在较老的 IDE 版本上闹脾气。换新版本或者升级依赖后通常就好了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →