ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code实战:T3 Stack打造数据库到浏览器的全栈类型安全

t3code实战:T3 Stack打造数据库到浏览器的全栈类型安全

最近在开发者社区里,一个叫 t3code 的词频繁出现,不少群里都在讨论。如果你是做全栈开发,尤其是 TypeScript 生态的,这个名字大概率绕不开。它不是什么新的前端框架,也不是某个库,而是围绕T3 Stack这个技术栈组合延伸出的一套实战编码思路和项目模板。我最初接触时也以为是什么新轮子,深入了解后发现,它其实代表了一种比较极致的开发理念:用最少的工具,把类型安全从数据库一路打通到浏览器。

这篇文章我就结合自己的实践,聊聊 t3code 背后这套技术选型到底在解决什么问题,适合哪些场景,以及我踩过的一些坑。如果你正准备启动一个全栈项目,或者想优化现有项目的类型体验,这篇文章应该能帮你少走几步弯路。

1. 内容整体设计与思路拆解

1.1 为什么要把“类型安全”从数据库做到浏览器

我们先抛开 t3code 这个具体名称,聊聊它背后的底层逻辑。传统全栈开发里,前后端交流靠什么?靠 REST API。前端定义一个 interface,后端再定义一套 DTO,两层之间没有任何强制约束。你改了一个字段,前端不知道,后端也不报错,等联调时接口返回结构变了,前端直接白屏,排查半天才发现是类型对不上。

t3code 的核心思路就是反着来:既然前后端都用 TypeScript,那为什么不让同一个类型定义贯穿始终?从 Prisma 的数据库模型,生成类型,经过 tRPC 的 API 层,再直接流入前端组件。你数据库加了一个字段,IDE 里马上就能看到,前端到处都会提示类型错误,改代码变得像在同一个大仓库里重构一样。这个体验一旦适应了,基本就回不去了。

我自己的感受是,这种模式特别适合两种人:一是个人开发者,一个人包揽前后端,不需要分开两个项目维护两份类型定义,效率极高;二是小团队,比如 3 到 5 人的小组,大家共享一套类型,沟通成本直线下降。但如果是大型企业项目,有独立的后端团队,技术栈和语言都被定死了,那这套方案就不太合适,不要生搬硬套。

1.2 t3code 的选型理念:少即是多

很多人第一次看到 T3 Stack 的组成时,会觉得有点困惑:Next.js + Tailwind CSS + tRPC + Prisma?好像没有哪个是特别“大牌”的重量级框架,组合起来却能产生 1+1>2 的效果。这背后的选型哲学很有意思,它不是追求功能最全,而是追求每一层只做一件事,并且做透。

  • Next.js:提供文件路由、服务端渲染和 API 能力,是整个应用的骨架。
  • tRPC:替代 REST API,让前端直接“调用”后端的函数,不需要繁琐的接口文档。
  • Prisma:作为 ORM,负责数据库访问,它的 model 定义可以自动生成 TypeScript 类型。
  • TypeScript:全栈唯一语言,从数据库到 UI,没有任何类型断层。
  • Tailwind CSS:样式方案选得也很聪明,如果已经有组件库习惯,用它写起来速度快,而且不会限制你的设计思路。

这里最关键的其实是tRPC 的引入。这个工具在海外社区热度很高,但在国内讨论度还不算高。它跟 REST 有本质区别:REST 是面向资源的设计哲学,而 tRPC 是面向函数的调用哲学。你把后端写成一个函数,前端直接调用它,参数和返回值类型都是自动推导的。换句话说,整个前后端的类型定义不再是靠“人工同步”,而是由编译器保证一致。

2. 核心细节解析与实操要点

2.1 项目骨架:初始化 t3code 工程

T3 Stack 有一个官方脚手架,叫 create-t3-app,用一条命令就能拉起来一个带有完整目录结构的新项目。打开终端,输入:

npm create t3-app@latest

执行后会出现向导,让你选择需要的模块。我实际测试下来的建议是:

  • Prisma:默认勾选,因为数据库是标配。
  • NextAuth.js:如果需要登录功能,勾选;如果只是内部工具,可以先不勾。
  • Tailwind CSS:看个人偏好,如果你熟悉备选方案同样没问题,但 t3code 的整个生态文档都围绕 Tailwind,新手跟着走最顺。

初始化完成后,项目目录里最值得关注的是三个部分:

  1. src/server/:所有服务端逻辑的老家,包括 tRPC 路由和数据库客户端。
  2. src/trpc/:rPC 上下文和初始化逻辑,前端就是通过它建立连接的。
  3. prisma/:数据库模型定义文件,是类型的源头。

这里我要重点提醒一句:官方模板默认的示例代码比较多,新手容易迷失在里面的 TODO 里。建议你初始化后,先删掉默认的 api 示例路由,再开始写自己的业务代码,不然会越看越乱。

2.2 tRPC 请求链路的原理浅析

要说 t3code 最容易卡住人的地方,就是 tRPC 的调用链路。它跟 REST 的差别,我打个比方:REST 是你去餐厅吃饭,按照菜单点菜(路由 + 请求方法);tRPC 是你直接按内线电话到后厨,让厨师“把这道菜做好端上来”(函数直接调用)。

具体到代码层面,tRPC 分为两个文件:

  • src/trpc/router.ts:定义一个个子路由,每个子路由里挂着多个查询或变更方法。
  • src/trpc/client.ts:导出前端使用的一系列 hooks。

前端如果想要调用后端的某个方法,例如post.all(),只需要这样:

import { api } from "~/trpc/react"; // 在组件里 const posts = api.post.all.useQuery();

试想一下,这里的api对象是从哪个文件导入的?是从后端的 router 定义里的类型推断出来的。你不需要写任何请求 URL、不需要手动处理 loading 状态、不需要在 useQuery 的泛型里手动指定返回类型,一切都是自动推导。

动手实践时,有个细节很关键:tRPC 的类型是在编译期推断的,但运行时的对象跨越了网络边界。这里的实现原理是,tRPC 把函数名和参数组成的对象序列化成一个 JSON,通过 HTTP POST 请求发送到后端,后端的 router 再解析并调用对应的函数。所以它的“远程调用”感是建立在底层的封装和约定之上的,并非真正离地飞行。

2.3 上下文对象:为什么每个请求都要带上数据库连接和 session

在 t3code 里,有个概念叫“上下文”,是很多新手绕不过去的一道坎。其实理解起来不难:每一次 tRPC 请求进入后端时,会先经过一个 createContext 函数,把当前请求需要的信息挂载在一个对象上,然后所有路由的 resolver 都能从ctx里拿到这些信息。

默认模板代码如下:

export const createTRPCContext = ({ session }: { session: Session | null }) => { return { db, session, }; };

这样做的最大价值,就是你在写后端查询时,不需要每次手动 import db:

import { db } from "~/server/db"; export const getUser = protectedProcedure.query(({ ctx }) => { // 直接使用上下文里的 db 和 session return ctx.db.user.findFirst({ where: { id: ctx.session.user.id } }); });

初学时最容易犯的错,就是忘记在createTRPCContext里注入 session,导致访问需要登录的保护接口时,直接抛异常。因此,我后来的习惯是:任何需要登录的逻辑,一律用protectedProcedure来定义,而不是自己手动检查 session,这样能在类型层面就过滤掉未登录调用。

3. 实操过程与核心环节实现

3.1 第一个查询:从 Prisma Schema 到 tRPC 路由

为了让你能完整体验 t3code 的链路,我推荐你从零开始手写一个最简单的功能:获取用户列表。

第一步,在prisma/schema.prisma里定义一个模型:

model User { id String @id @default(cuid()) name String? email String @unique createdAt DateTime @default(now()) }

保存后,在终端执行:

npx prisma db push

这个命令会把模型同步到数据库里。如果数据库是 SQLite,它会自动在本地创建一个.db文件。执行完后,Prisma Client 也会同步生成对应的 TypeScript 类型。

第二步,在 tRPC 路由里写一个查询。T3 Stack 的默认结构里,src/server/api/目录下存放路由文件。默认已经有了一个 post 路由,我建议你看看它的写法,照着模仿。写一个自己的路由:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ all: publicProcedure.query(async ({ ctx }) => { const users = await ctx.db.user.findMany({ select: { id: true, name: true, email: true }, }); return users; }), });

这里我特意用了select,只查询必要的字段。因为这又是一个在实践里发现的效率教训:如果直接用findMany()返回整行数据,一旦表中有一大坨 longtext 字段,接口响应就会慢得让人想砸电脑。

第三步,在根路由里挂载这个 userRouter:

export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, });

挂载好之后,打开任意一个页面,引入查询:

"use client"; import { api } from "~/trpc/react"; export function UsersList() { const { data: users, isLoading } = api.user.all.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <div> {users?.map((user) => ( <div key={user.id}>{user.name}</div> ))} </div> ); }

这里最吸引人的地方,就是这个api.user.all里的user和all,以及返回的users数组的每一项的字段类型,都是在编辑器中实时被 TypeScript 推导出来的。不管是字段拼写错误还是访问一个不存在的属性,IDE 会立马报错。写惯了前后端分离开发的人,第一次体验这种“全链路类型闭环”时,都会觉得很惊艳。

3.2 变更操作:用 zod 做输入校验

刚才我们实现了查询,但一个应用难免需要新增数据。t3code 里做“新增”操作,用的是 mutation(变更),并且搭配 zod 做输入校验——这也是一个很关键的配套设计。

import { z } from "zod"; export const userRouter = createTRPCRouter({ all: publicProcedure.query(async ({ ctx }) => { // ... }), create: publicProcedure .input( z.object({ name: z.string().min(1).max(30), email: z.string().email(), }), ) .mutation(async ({ ctx, input }) => { const user = await ctx.db.user.create({ data: { name: input.name, email: input.email, }, }); return user; }), });

前端的调用也很直观:

const utils = api.useUtils(); const createUser = api.user.create.useMutation({ onSuccess: async () => { // 成功后刷新列表 await utils.user.all.invalidate(); }, }); createUser.mutate({ name: "张三", email: "zhangsan@example.com" });

很多人觉得 zod 是多余的,反正后端自己写的代码,还校验啥?我个人的亲身经历告诉你,这个想法非常危险。因为服务端会接收到不可预测的参数,手动逐一校验的话,很容易漏写类型判断逻辑,比如忘记检查 email 格式,导致非法字符串直接写入数据库。zod 能把校验逻辑集中在 schema 定义中,前端表单也能复用同一份 schema 来校验输入,前后端代码完全不同步的烦恼,直接被消除了。

另一个实操建议:mutation 成功后,记得调用invalidate()让 tRPC 自动重新拉取数据,而不是手动更新本地 state。这样做的好处是,数据源保持单一来源,不会出现某个列表缓存与数据库不同步的诡异情况。

3.3 服务器端渲染与 tRPC 的结合

如果你想让某个页面在服务端渲染后返回给浏览器,在 t3code 里也有成熟的做法。T3 Stack 的模板默认就支持 SSR,唯一需要注意的是,有些 operations 需要在服务端执行,有些则只能在客户端执行。

比如,在一个 React Server Component 里,你不需要用api.user.all.useQuery()(客户端 hooks),而是直接用服务端导出的 caller 来调用后端的函数:

import { api } from "~/trpc/server"; const users = await api.user.all();

没错,这里的api.user.all是同步派生的的调用,但底层依然是走 tRPC 的请求链路。这个写法适合在初始渲染时把数据带下来,减少前端等待时间。它看起来“同步”,但其实内部做了异步处理,语法的简化让你把更多注意力放在业务上,这点对我来说很加分。

我这里要特别强调一个训练直觉的法子:凡是涉及用户交互之后产生的数据变化,优先用客户端 hooks;凡是页面首屏加载就要的数据,优先用~/trpc/server里的同步调用写法。这样分得清楚,后面逻辑就不会乱成一锅粥。

4. 常见问题与排查技巧实录

4.1 数据库迁移失败的三种典型原因

t3code 项目里,数据库操作基本都交给 Prisma。迁移失败是我碰到过的频率最高的问题,主要可以分为三类:

**第一类是 schema 语法错误。**模型之间关系写错,或者字段类型缺失,导致prisma migrate dev报错。这种问题错误日志通常写得比较明确,看看报错信息里的行号就行。

**第二类是数据库连接失败。**本地开发用的是 SQLite,如果你的.env文件里DATABASE_URL路径不对,或者目录里没写权限,同样会启动失败。我踩过一次坑:Windows 系统下,SQLite 文件路径直接用了 C 盘根目录,结果权限不足,排查了大半天才反应过来。

**第三类是迁移文件冲突。**如果你切换了分支,或者多人协作时合并了多次迁移,可能会导致_prisma_migrations表的状态与本地迁移文件对不上。这个比较棘手,我一般建议用prisma migrate reset重置,但仅在确认数据丢失无所谓的开发环境下使用。

注意:生产环境千万别用reset,会直接把数据表全部清空。生产环境应该用prisma migrate deploy按序执行迁移文件。

4.2 tRPC 类型报错的排查思路

tRPC 的类型报错,往往看起来很长很长,几百行的错误信息,很容易让人崩溃。但它其实有个规律:错误的根部一般在createTRPCContext 的返回类型与路由的 resolver 参数类型不一致,或者 zod 输入与前端传参不一致。

我在排查这类问题时,养成了一个固定动作:先找到报错信息中提示的第一个文件,通常是从router.ts开始。然后看那个文件里泛型传入的类型到底是什么,再对照实际返回的结构。

另一个常见场景是:升级依赖后,tRPC 的版本变了,部分 API 的泛型签名跟着变了。比如,有时 api 的 router 类型没有被正确 match,前端 import 到的是一个比例过大的类型。这种时候,最快的方法是先重启 TypeScript Server。在 VS Code 里按下Ctrl+Shift+P,输入 “TypeScript: Restart TS Server”,有相当多莫名其妙的报错都能这样解决。

4.3 Next.js 数据缓存导致的页面不更新

Next.js 自身有了一套比较激进的数据缓存机制。在某些情况下,t3code 的前端数据更新了,但页面上看到的还是旧内容。这个问题的核心,在于 Next.js 的路由缓存和 tRPC 的状态缓存是两层体系。

我做过的有效操作是:

  1. 在后端程序内部,对受保护的接口配置“每次请求重新验证”。
  2. 在关键路径上,使用 Next.js 的动态渲染标记,确保页面不被静态化缓存。
  3. 前端 mutation 成功之后,主动调用router.refresh()强制刷新页面数据。

实际开发中,前两步已经能应付大多数场景。如果还不够,就用第三步。但要慎用router.refresh(),因为它在重拉数据时会触发整个当前路由的刷新,如果页面特别大,体验流畅感会有些损失。

问题现象排查方向推荐手段
数据库迁移报错schema 语法、连接路径、迁移冲突看错误行号,开发环境可 reset
tRPC 类型报错巨长检查 context、rootRouter 类型、依赖版本重启 TS Server,核对泛型
页面数据不更新缓存机制叠加动态渲染 + invalidate 请求
登录态获取不到忘记注入 session 或 protectedProcedure统一用 protectedProcedure

5. 后续可以这样扩展

如果你已经跑通了上述核心链路,其实 t3code 已经成功帮你把工程底座打好了。接下来可以考虑一些扩展。

第一,从单体到一个更清晰的项目结构。默认模板把所有路由都放在src/server/api里,业务复杂之后会显得拥挤。你可以沿用 t3code 的目录设计思路,按业务模块拆分成多个子路由文件,再在根路由里合并。这样每个模块的类型定义、zod schema、路由逻辑都聚在一起,维护起来会很舒服。

第二,接入文件上传和第三方服务。tRPC 的 mutation 支持输入任意可序列化的数据,你可以直接在 input 里传递一个上传的预签名 URL 参数,然后后端再配合存储服务完成剩余工作。这种方法对比传统 rest,省去了单独的接口地址约定。

第三,尝试 tRPC v11 的新特性。社区版本迭代很快。如果你用的是最新版脚手架,偶尔会遇到一些初始化导入路径与旧教程不一致的情况,这是正常的,不用慌。看官方文档或升级指南会比任何博文更靠谱。

最后再分享一点我个人习惯上的调整。刚开始用 t3code 时,我总觉得自己写的代码太简单了,为什么几乎没写接口定义,也没写请求封装,是不是缺了什么?用了一段时间后我明白了,这就是这种模式节省出来的“无意义劳动”。你的精力应当集中在业务本身:模型设计、输入校验、页面交互。类型安全和前后端同步,是框架帮你兜底的。

如果你正准备拿这个方案做新的全栈项目,我建议你找个半天时间,从 create-t3-app 初始化开始,到完成一个“带登录 + 简单增删改查”的小应用为止,完整走一遍。体验过那种从数据库字段改动到前端页面报错提示“在几秒内同步”的爽感之后,你就会明白为什么 t3code 这套技术栈在社区里越来越受关注了。

RELATED READING

延伸阅读

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