ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

T3全栈项目实战:端到端类型安全与工程落地

T3全栈项目实战:端到端类型安全与工程落地

t3code 是我最近半年一直在迭代的一个全栈项目代号,核心是一套完整的 TypeScript 全栈工程实践。关注开源圈的朋友可能已经猜到了 T3 这个词的来源——它指的是 TypeScript、tRPC、Tailwind 再加上 Next.js 的组合,t3code 就是基于这条技术路线的落地项目。做这个项目的初衷很简单:我手里攒了不少业务需求,每次新建应用都要在一堆框架、库、验证方案里重新选型,重复劳动量极大,而且团队里每个人搭出来的东西风格还不一样。所以我干脆把这条成熟路线固化成一套可复用的项目模板,顺便把前后端类型安全打通,让一人开发或者小团队协作都能省掉大量低效沟通。

这篇文章打算把我踩过的坑、做过的取舍、以及最终的落地配置完整写一遍。不管你是想了解 T3 技术栈,还是准备在自己的项目里引入端到端类型安全,或者单纯想看看一个真实全栈项目是怎么从零搭起来的,这篇内容都能给你一个比较完整的参考。

1. t3code 的项目定位与技术栈拆解

1.1 为什么选 T3 技术栈而不是别家

先说说选型这件事。现在的全栈方案其实不少,有 Blitz、Redwood,也有很多人直接 Next.js 加 Express 或者 Fastify 自己拼。我最后选了 T3 这条路线,最核心的驱动因素是“类型安全闭环”。你可能听说过一句话,T3 的核心不是某个具体框架,而是“类型安全优先”。就是说从数据库查询结果,到 API 接口的出入参,再到前端页面的渲染数据,全程都用 TypeScript 的类型串在一起,中间不需要手写任何接口文档,也不需要在后端返回数据之后到前端再重复声明一遍类型。

拿我自己真实的感受来说,之前用 REST 接口联调,最烦的就是后端改了返回结构,前端拿到数据一跑就报 undefined,查半天才发现是字段名变了。在 t3code 项目里,数据库用 Prisma 定义 schema,表结构是什么,TypeScript 类型就是什么;tRPC 的 procedure 定义了输出格式,前端调用的时候类型自动跟着后端走。我改了某个字段,编译期间前端直接报错,连运行都不用运行,这种体验一旦习惯了就回不去。

再说说 Next.js。我选择 App Router 而不是 Page Router,一方面是 App Router 本身支持 Server Component,另一方面是它和 tRPC、React Query 配合起来非常自然。App Router 下很多数据获取逻辑可以放到服务端,而 tRPC 的 server caller 又能在 Server Component 里直接调用,前端组件不用变成“请求的起点”,数据从哪儿来的这种心智负担会低很多。这个点在后面实操部分我会专门演示。

Tailwind 属于锦上添花的角色。说实话,样式方案对工程结构的决定性没那么强,但 Tailwind 的原子类非常适合模板化的项目,因为页面组件的样式不需要单独维护一堆 CSS 文件,新页面写起来也很快。更重要的是 T3 生态里 Tailwind 和 Headless UI、Radix 这些组件库搭配得比较顺手,所以我在 t3code 里也直接预置了。

1.2 项目的目录设计与边界划分

一个项目代码能不能长期维护,目录结构占一半以上的因素。t3code 的目录是基于 Next.js App Router 的标准布局做了一些工程化调整的,核心设计思路是“按模块聚合,而不是按文件类型堆叠”。我不喜欢那种把所有 components 丢一个目录、所有 hooks 丢一个目录的写法,因为一个业务功能往往需要组件、hooks、API 调用、类型定义同时改动,放在一起改起来才顺。

我的目录大概是这样划分的:

  • src/app:Next.js 路由层,只负责页面的骨架和服务端数据预取
  • src/server:包含 tRPC 路由定义、数据库访问逻辑、鉴权相关代码,理论上这一层的代码绝不参与浏览器打包
  • src/trpc:前后端共享的 tRPC 客户端、服务端 caller 的初始化,还有类型导出
  • src/components:通用组件和业务组件,按组件名拆分
  • src/lib:全部是与框架无关的纯函数、工具方法、常量配置
  • src/validation:zod 的 schema 定义,前端表单和后端入参校验都从这里取

这里面我最想强调的一条边界是:任何涉及数据库的操作都不能出现在组件里,任何浏览器专属的 API 也不能出现在服务端代码里。执行这种边界的方式是利用 Next.js 的 import 隔离,其次是在团队规范里约定好目录职责。t3code 里我还在 tsconfig 里配置了路径别名,比如 @server 指向 src/server,@trpc 指向 src/trpc,物理上杜绝了绕路访问的可能。

1.3 数据流与类型安全的整体闭环

我经常跟朋友打比方说,t3code 的数据流像一根管子,从数据库流到页面,中间管壁是 TypeScript 的类型系统,哪一段漏了类型,编译期就能看见。这话听起来有点玄,实际落地靠的是三个环节的配合:Prisma 生成类型、tRPC 推导类型、React Query 消费类型。

Prisma 负责把数据库表映射成 TypeScript 类型,比如我定义了一张 Post 表,那 Prisma 客户端会自动导出一个 Post 类型,里面的字段、关联、枚举都是只读的类型约束。tRPC 的 router 在定义 query 和 mutation 时,输入参数可以用 zod 校验,输出类型默认就是 query 函数的返回值。也就是说,只要每个查询函数返回的是 Prisma 的结果,那前端拿到的数据类型就已经完全确定了。

到了前端这一侧,React Query 的 useQuery 拿到的 data,它的类型是从 tRPC 的 useUtils 或者 tRPC 客户端自动推导出来的。这里没有手写任何 interface,也没有用 any 把类型断掉。整条链路下来,从表结构到 UI 数据,真正做到了单一事实来源。如果哪天数据库表加了字段,Prisma 模型改一行,TypeScript 类型全局更新,前端的每个使用点都会重新检查一遍。

2. 核心细节:端到端类型安全是怎么落地的

2.1 tRPC 路由在实践中怎么组织

tRPC 可能是 T3 技术栈里大家最陌生的部分,因为它和传统 REST 的写法差异很大。简单说,tRPC 不是一个“取数据的 URL”,它是一个“调用函数的桥”。客户端直接调用服务端定义好的方法,就像本地函数调用一样,序列化、网络传输、错误处理这些事框架内部都处理掉了。

t3code 里我把路由拆成了几层:根 router 是总入口,下面按业务域拆分为 postRouter、userRouter、fileRouter 等。每个业务域独立一个文件,里面导出该域下的多个 procedure。举例来说,postRouter 里会有 getPostList、getPostById、createPost 这几个方法。分别对应列表查询、详情查询、创建操作。每个方法都用 tRPC 的 protectedProcedure 或者 publicProcedure 来声明访问级别。

这里有个需要注意的地方:tRPC 的 procedure 一旦定义了 input schema,所有参数都会被严格校验。比如 createPost 的 input 我定义为 z.object({ title: z.string().min(1), content: z.string().min(10) }),那客户端传一个空 title 过来,请求会在正式执行逻辑之前被拦截掉。这种做法比在业务代码里手动判断要舒服太多,因为它的校验层是统一封装在创建 procedure 时的 factory 里的。

路由文件到最后统一在 src/server/api/root.ts 里聚合。聚合时还会 appRouter 导出类型,前端所有调用都会通过这个类型获得完整的调用提示。我实测下来,VSCode 里写前端调用代码时,方法名、参数类型、返回类型全都有智能补全,基本告别了翻文档的日子。

2.2 Prisma 建模与数据库迁移的细节

数据库层我用的是 Prisma + PostgreSQL。选择 Prisma 的最大原因是它的 schema 文件和类型生成机制非常适合 T3 这种类型安全闭环。你在 schema.prisma 里定义一个模型,执行 prisma generate 之后,TypeScript 里立刻多了一堆可用的类型。这里我补充一句,Prisma 生成的类型不只是字段本身,还包括了关系字段、创建输入的参数类型,比如 PostCreateInput,做表单提交时这个类型直接就能用。

t3code 的 schema.prisma 里我目前维护了用户、帖子、标签、评论四张核心表。用户和帖子是一对多,帖子和标签是多对多。这种关联在 Prisma 里的写法并不复杂,用 relation 字段标出来就行。真正要留意的是迁移流程。我个人的习惯是本地开发时每次改完 schema 就执行一次 prisma migrate dev,它会自动生成迁移 SQL 并且应用到本地数据库,还会重新生成客户端类型。千万不要直接改数据库表结构,那样 Prisma 的类型和真实库结构迟早会漂移。

部署环境的迁移我也提前做了准备:在构建流程里加入了 prisma migrate deploy 的命令。这个命令只负责把已经生成好的迁移文件应用到生产库,不会做任何交互式确认,适合在 CI 里跑。相比之下 prisma migrate dev 带的一些开发时特性,比如重置数据库、seed 数据,就不适合生产环境。

再提一嘴数据库连接:Prisma 在 Next.js 的 serverless 环境里需要特别注意连接池的问题。t3code 里我用了一个比较老到的处理方式,把 PrismaClient 实例挂到全局对象上,避免每次请求都重新创建新的连接实例,同时开启 connection_limit 参数,防止边缘函数并发数高的时候把数据库连接池打满。

2.3 Next.js 服务端组件的数据请求姿势

T3 技术栈里容易犯的一个错误是,在 Server Component 里直接用 useEffect 或者 React Query 去拉数据。这个做法不是不行,但是浪费了 Server Component 的能力。t3code 的做法是优先在服务端调用 tRPC 的 caller。

所谓 caller,就是不需要经过 HTTP 请求,直接在 Node 环境里调用 tRPC 的 router。这样一来,服务端组件可以在渲染时同步拿到数据,直接塞进 props 或者并行渲染子组件。举个例子,我的首页要展示最近帖子列表,在 page.tsx 里直接调用 postRouter.createCaller 的 getPostList 方法,拿到的数据天然是类型安全的,也不会有额外的一次网络往返。

不过要提醒的是,服务端调用和客户端调用返回的数据形式可能稍有不同。客户端通过 tRPC 拿到的数据是响应对象,服务端通过 caller 拿到的也是同样的数据结构,但错误处理方式不一样:服务端如果抛出 tRPC 错误,会向上抛异常,需要自己捕获;客户端的话,错误会进入 React Query 的 error 状态。所以我在 t3code 里对每个依赖数据的 Server Component 都做了一个 error.tsx 边界,用来兜底渲染错误状态。

还有一个小细节,也是我踩过的坑:在 Server Component 里调用 tRPC 时,中间件逻辑默认也会执行,所以鉴权相关的 session 判断是有效的。但你必须在调用时把当前请求的 cookie 或 headers 传给 caller 的上下文,否则 session 读不到用户信息。t3code 里我在 src/trpc/server.ts 里封装了一个 createCaller 方法,从 Next.js 的 headers() 里自动提取必要信息,页面里就不用每次手传。

3. 实操记录:从零搭一个 t3code 项目

3.1 初始化与依赖安装的实际输出

我假设你已经有一个空目录叫 t3code,我们直接从初始化开始。创建一个全新的 Next.js 应用,我通常用 create-next-app,然后把 TypeScript、ESLint、Tailwind、App Router 都选上。这一步的交互我就不逐字照搬了,重点说几个容易懵的选项。

Tailwind 和 ESLint 建议直接选 Yes,不然回头补配置很麻烦。src 目录我个人是选 No 的,因为我打算把 src 目录作为所有源码的统一根目录,而 create-next-app 默认的 app 目录在根下,我需要手动调整。如果你懒得调整目录结构,也可以直接选 Yes,后面 import 路径保持一致就行。

项目创建后接着装 tRPC 相关的包。这里需要安装的依赖有两类,一类是服务端用的 @trpc/server,一类是客户端用的 @trpc/client,还有 React 集成用的 @trpc/next 或者 @trpc/react-query。我在 t3code 里用的是 @trpc/react-query,因为它和 React Query v5 配合得最好,也方便服务端 caller 复用同一套 router 定义。另外还要装 zod、superjson 和 @tanstack/react-query。

注意:tRPC 的版本迭代比较快,不同版本之间的 API 时有调整,比如 @trpc/next 从 v10 到 v11 就改了一些初始化方式。建议安装时直接锁定大版本,不要用 latest 到处漂。

初始化完成后的第一件事不是写业务代码,而是先配置 superjson。这是一个序列化库,主要解决 Date、Map、Set 这些 JavaScript 特殊类型在 tRPC 传输过程中的保真问题。在 tRPC 的 initTRPC 和 createTRPCNext 两处都要配置 transformer 为 superjson。忘掉这一步的后果是:数据库的日期字段传到前端会变成字符串,然后你排查半天发现约会变成字符串了,非常崩溃。

3.2 表单校验:zod 前后端共用

表单校验这块,t3code 的思路是 schema 写一份,前后端共用。zod 是这里的主角,它不仅能在前端做表单验证,还能在后端 tRPC 的 input 校验里直接复用同一套 schema,从源头避免前后端校验规则不一致的问题。

我举个例子,帖子标题的校验规则,我要求最少 1 个字符,最多 100 个字符;内容最少 10 个字符。在 src/validation/post.ts 里定义一个 createPostSchema,前端表单在提交前会先做一次 zod 校验,不符合规则就直接在 UI 上提示;后端 tRPC 在 input 接收时也会做同样的校验,不符合规则就返回一个校验错误。这套做法最大的收益是:当产品经理说标题要改成 50 字以内时,我只改一处 schema,前后端同时生效。

实际开发中,zod 的 error message 建议也统一维护。我不喜欢在 schema 里塞一堆中文文案,因为文案属于 UI 层,混在 schema 里后期会很难看。t3code 的做法是 schema 只负责规则,错误信息用 z.string().min(1, { message: '请输入标题' }) 这种方式写在具体规则后面,然后用一个工具函数把 zod 的 errors 转换成表单字段级错误,再喂给 UI。

这里再补充一个小技巧:zod 在解析未知字段时,默认会剥离多余字段。这个特性用于 tRPC 的 input 其实是好事,因为客户端传来的 payload 里如果有额外字段,后端不会因为 schema 解析失败而报错。但如果你希望严格校验,就要用 .strict() 模式。我一般在开放接口上不加,内部接口上也不怎么用,因为 tRPC 本身已经限制了输入结构。

3.3 会话鉴权:从 cookie 到中间件

鉴权是每个全栈项目逃不掉的部分。t3code 用的是基于 session cookie 的方案,没有引入重型的外部鉴权服务,而是直接用 Auth.js(NextAuth 的 v5 版本)来管理用户登录态。选择 Auth.js 的原因很简单:它原生兼容 Next.js App Router,而且 session 的获取方式对 Server Component 比较友好。

安装和配置 Auth.js 的时候,有几个点值得注意。第一个是把 AUTH_SECRET 环境变量配好,这是所有 cookie 签名的核心密钥,生产环境必须从密钥管理服务里读取,不要硬编码进代码仓库。第二个是在 app/api/auth/[...nextauth]/route.ts 里导出 handler,tRPC 的上下文需要从 session 里取用户 ID,所以 context 的创建必须放在能拿到 NextAuth 会话的地方。

t3code 的 tRPC 上下文创建逻辑很简单:先读取请求里的 cookie,然后把 session 信息注入到 ctx 里。这样在 protectedProcedure 里就能判断 ctx.session?.user 是否存在,不存在就抛出 UNAUTHORIZED 错误。其实这个模式还可以继续往细了做,比如区分“未登录”和“已登录但无权限”,但在项目初期我先做到未登录拦截这一层就够了。

设置 cookie 的 Secure 属性我也提一下:本地开发用 http 没问题,但部署到生产环境后,如果不用 HTTPS,session cookie 不会发送,浏览器会直接忽略非安全的 session cookie。这个坑看起来很小,却会让线上登录永远失败。我一般在部署配置里强制 HTTPS 跳转,并且在 Auth.js 的 session 配置里把 cookie 的 Secure 标记设为默认遵循当前协议。

4. 常见问题与排查经验

4.1 类型推导失效的几种典型场景

tRPC 在类型推导上虽然很智能,但也有一些使用姿势不对导致类型失效的场景,我整理成表格方便你排查。

症状原因解决办法
前端调用方法名没有提示根 router 类型没有通过 createTRPCNext 暴露给客户端检查 appRouter 是否作为类型传给了 createTRPCNext 的泛型
data 是 any 类型在 createTRPCReact 时没有传 Router 类型在客户端初始化时明确泛型
服务端 caller 调用时类型报错tRPC 的 createCallerFactory 泛型写错用 typeof appRouter 重新校准类型
useQuery 返回的 data 类型和预期不符query 函数返回值里混入了 undefined在 procedure 里明确 output schema 或保证返回值非空

这类问题的排查思路其实很一致:类型是从 appRouter 的推导链一路传下来的,一旦发现类型路断了,优先回查初始化时的泛型传参。我在 t3code 里专门封装了 src/trpc/client.ts 和 src/trpc/server.ts,把初始化逻辑收敛到两个文件里,之后很少再遇到类型失效的问题。

4.2 数据库连接与迁移的坑

开发中最常遇到的 Prisma 坑有两个。第一个是本地数据库没启动或连接字符串不对导致 prisma migrate dev 直接报错。这个比较好排查,检查 DATABASE_URL 里是否包含正确的用户名、密码和端口即可。第二个是开发环境改 schema 后忘了跑迁移,结果业务代码用了新字段,数据库里却没有,运行时报“column does not exist”。

为了解决第二个问题,我在 t3code 里严格要求每次修改 schema 后立即执行 prisma migrate dev --name 描述信息,并且把生成的迁移文件提交到代码仓库。这样团队其他人 pull 代码后,只需要跑一次 prisma migrate deploy 就能把本地库结构升级到一致。还有一点建议:不要把生成的迁移文件删掉或者手动改掉,Prisma 会根据迁移文件的历史记录判断当前库处于哪个版本,手动改容易把记录搞乱。

数据库连接还有一个容易忽略的问题:连接池大小。很多人在自托管 PostgreSQL 上跑 Next.js 应用,数据库默认连接数上限比较低。如果应用部署到 Node 运行时,多个并发请求同时访问数据库时,Prisma 默认会为每个实例创建连接,可能会把连接数打满导致连接超时。我在 t3code 的 PrismaClient 初始化里对连接池做了限制,生产环境还会设置 statement_cache_size 一类的参数,不过这些具体数值要结合自己的数据库规格来调,没有银弹。

4.3 部署上线时的几个细节

部署这块,Netlify 和 Vercel 都可以跑 Next.js,但 Vercel 的体验最顺滑,配置也最少。不过 T3 技术栈部署时有几个细节是通用的,值得专门记一下。

第一是数据库连接。如果部署到 serverless 环境,函数的冷启动会频繁创建和销毁进程,PrismaClient 如果每次冷启动都重新实例化,连接成本会很高。前面提到的“把实例挂到全局对象”在 serverless 环境尤其重要,但要注意 serverless 平台对全局对象的生命周期做了特殊处理,实例只存活于单个函数执行期间,不会真正跨请求复用。所以正确理解是:它在同一函数实例的多次调用之间复用,能降低一部分连接开销,但不能完全依赖。

第二是环境变量管理。t3code 项目里有 DATABASE_URL、AUTH_SECRET 等敏感信息,千万不要把它们提交进仓库,更不要在客户端组件里通过 NEXT_PUBLIC_ 前缀把它们暴露出去。我的习惯是在部署平台的仪表盘里配置环境变量,然后在本地维护一份 .env.example 作为参数说明,方便团队成员自行补全。

第三是静态生成与服务端渲染的边界。App Router 默认是动态渲染还是静态优化,取决于页面里是否使用了动态 API,比如读取 headers() 或者 cookies()。如果某个页面需要根据登录态显示不同内容,就不能让它被静态化。我在 t3code 里对这类页面显式添加 export const dynamic = 'force-dynamic',防止构建时被静态快照,导致所有用户看到同一份内容。

4.4 缓存失效与数据实时性

最后提一个 t3code 开发中后期才意识到的问题:缓存。React Query 会把查询结果缓存在客户端,这是它的天然优势,但也是数据更新后页面不刷新的一大原因。t3code 的策略是:创建、修改、删除这类 mutation 成功之后,主动调用 queryClient.invalidateQueries 让相关查询失效,重新拉取新数据。

这个策略听起来很直白,但实际操作里容易漏掉的是“到底该让哪些查询失效”。比如我创建了一篇帖子,影响的可能是 postList 查询,也可能是某个用户的 postCount 统计,还可能影响帖子详情页的缓存。如果只失效一部分,页面就会展示陈旧数据。我在 t3code 里把失效逻辑封装在 mutation 的 onSuccess 回调里,每次新增业务域时都会反复检查关联查询清单,宁可多失效一组也不留脏数据。

另外,服务端组件和客户端的缓存策略也要区别对待。服务端组件里如果不需要即时更新,可以接受它是构建时或请求时渲染的快照;但客户端一旦进行了写操作,服务端组件下次导航时最好重新请求数据。我在页面跳转逻辑上没有刻意禁用 Next.js 的客户端路由缓存,而是依赖 mutation 失效来保证返回列表页时数据是新的。实际操作中这个模式已经覆盖了我绝大多数需求。

5. 给后来者的几个建议

我先说一句实在话:t3code 这套技术栈不是万能的,它最适合的是中小型全栈应用,尤其是那些“前后端都由同一拨人维护”的项目。如果你的团队规模大到前后端完全分属两个部门,接口协议需要独立管理,那 tRPC 这种端到端类型共享反而可能成为协作的障碍,因为强耦合的类型系统会让接口变更的谈判成本变高。但如果你是一个人做产品原型,或者一个小团队想快速把想法落地成可用系统,t3code 的收益是非常明显的。

我自己的体会是,t3code 最值钱的部分不在某个单独的技术点,而在于它把整条链路的类型安全做成了默认选项。写前端时后端类型自动推导,写后端时数据库类型自动推导,整个开发过程中很少需要停下来去想“这个数据到底长什么样”,因为编译器一直在帮我把关。那种感觉很像开车时车辆自带的辅助驾驶,虽然你还是握着方向盘,但明显比纯手动挡轻松一个量级。

如果你也准备基于这套思路改造或新建项目,我建议从一个最小可用的例子开始,先跑通 tRPC 的 query 和 mutation,再逐步引入 Prisma、Auth.js 这些东西。不要一上来就追求配置大全,否则很容易被各种新概念淹没。等最小闭环跑通了,再回头看看我这篇文章里提到的目录边界、缓存失效、部署细节,你会有更深的体会。

最后还是老规矩,保持质疑,动手实践。别人的经验终究是别人的,只有亲手搭一遍 t3code,踩过那些类型推导和数据库迁移的坑,才能真正理解这套技术栈为什么能让人上瘾。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进