ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code 全栈 TypeScript 类型安全实践:tRPC + Prisma + Tailwind 工程化指南

t3code 全栈 TypeScript 类型安全实践:tRPC + Prisma + Tailwind 工程化指南

1. 项目缘起与核心定位

第一次看到“t3code”这个名字,我下意识以为是某个新出的低代码平台或者代码生成工具。翻了一圈资料、也自己动手跑了几轮之后才明白,它更像是一个围绕“T3 技术栈”构建的代码组织与工程化实践方案——T3 指的是 TypeScript、tRPC、Tailwind 这一套组合拳,而 code 则强调它最终落地到的是“怎么写代码、怎么组织代码、怎么让代码在端到端之间保持类型安全”。

说白了,t3code 解决的是一个很具体的痛点:全栈 TypeScript 项目里,前后端之间的类型契约总是容易断。你可能用 Prisma 定义了数据库模型,用 Zod 写了校验,用 React 写了页面,但中间那层 API 调用往往是靠手写 fetch 或者 axios 拼出来的,类型对不上、字段改了忘了同步、运行时才报错——这些事我踩过太多次。t3code 的思路就是把这条链路用类型系统串起来,让编译器帮你抓错,而不是等用户点出 500。

它适合谁?如果你正在做一个中小型全栈项目,团队里有人懂 TypeScript,又不想引入 GraphQL 那套相对重的 schema 管理,那 t3code 这套模式非常值得参考。哪怕你只是前端出身、后端只会写点简单的 Node 接口,也能顺着它的结构把项目搭起来。反过来,如果你的项目是纯静态页面、或者后端是 Java/Go 那种强类型语言且已有成熟契约管理,那 t3code 的收益就没那么明显了。

我个人的判断是:t3code 不是一个库,而是一种“约定 + 工具链”的组合。它的价值不在于某个 API 多神奇,而在于它把“类型安全”这件事从数据库一路贯穿到了 UI 组件,中间没有断点。下面我就按自己实际搭项目的顺序,把整套思路拆开讲。

2. 整体架构设计与选型逻辑

2.1 为什么是 TypeScript + tRPC + Tailwind 这三件套

先说说选型背后的逻辑。市面上全栈方案很多,Next.js 自带 API Routes、Remix 有 loader/action、NestJS 有装饰器那一套,为什么 t3code 偏偏强调这三样?

TypeScript 是地基。没有 TS,后面所有类型推导都无从谈起。我见过不少项目嘴上说用 TS,实际上any满天飞,那跟写 JS 没区别。t3code 要求你把类型当回事,尤其是strict模式要打开,noUncheckedIndexedAccess也建议开——这个选项能帮你抓出数组越界访问,我一开始嫌它烦,后来发现它救了我好几次。

tRPC 是粘合剂。它的核心卖点就是“没有 schema 文件、没有代码生成步骤,类型直接从服务端推导到客户端”。传统 REST 你要么手写类型、要么用 OpenAPI 生成,中间总有一道同步工序。tRPC 把这道工序省了:你在服务端定义一个 procedure,客户端useQuery的时候类型自动就出来了,改字段名编译器立刻报错。这个体验一旦用过就回不去。

Tailwind 是表现层。有人觉得 Tailwind 是“把 CSS 写进 className”,不够优雅。但从工程角度看,它解决了两个实际问题:一是样式和组件同文件,改样式不用跳来跳去;二是设计系统约束,间距、颜色都从配置里来,团队协作时不会出现十种不同的蓝色。t3code 里 Tailwind 不是必须的,但它和这套“快速迭代、类型驱动”的风格很搭。

注意:tRPC 并不是银弹。如果你的 API 需要被非 TS 客户端(比如移动端原生、第三方合作方)调用,那 tRPC 就不合适,还是得走 REST 或 GraphQL。t3code 的适用边界是“前后端都是你自己控制的 TS 项目”。

2.2 目录结构怎么划分才不乱

我试过好几种目录组织方式,最后稳定下来的结构是这样的:

src/ server/ api/ routers/ user.ts post.ts root.ts trpc.ts db/ client.ts auth/ pages/ 或 app/ components/ utils/ styles/

关键点在于server 和 client 的边界要清晰。server/api下面只放服务端代码,绝对不能被客户端 import 到,否则数据库连接串就泄露了。tRPC 的trpc.ts里定义 context 和 procedure 基类,root.ts把各个 router 合并成appRouter,然后导出AppRouter类型给客户端用。

这里有个细节:只导出类型,不导出实现。客户端import type { AppRouter } from '~/server/api/root',这样打包时不会把服务端代码带进去。我一开始没注意,结果 bundle 里混进了 Prisma,体积暴涨,排查了半天。

2.3 数据流是怎么走的

整个数据流可以这样理解:数据库(Prisma)→ 服务端 procedure(tRPC)→ 客户端 hook(React Query)→ 组件渲染。每一层都有类型约束,改任何一层,上下游都会报错。

举个具体例子。数据库里 User 表有个name字段,Prisma 生成的类型是string。服务端 procedure 返回user对象,类型自动推导。客户端const { data } = trpc.user.getById.useQuery({ id }),此时data的类型就是{ id: string; name: string; ... } | undefined。如果你在组件里写data.name,TS 会提醒你可能 undefined,逼你做空值处理。这个链条里没有任何手写类型,全靠推导。

这种设计的好处是重构成本极低。我想把name拆成firstName和lastName,改 Prisma schema、跑迁移、改 procedure 返回值,然后编译器会把所有用到name的地方标红,一个个改就行。坏处是类型推导有时会变慢,项目大了之后 IDE 会卡,这个后面讲优化。

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

3.1 tRPC 的 procedure 到底怎么写

tRPC 的核心概念是 procedure,分 query(读)和 mutation(写)。我一般会在trpc.ts里先定义好基础 procedure:

import { initTRPC, TRPCError } from '@trpc/server'; import { type Context } from './context'; const t = initTRPC.context<Context>().create(); export const router = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } }, }); });

protectedProcedure这个中间件很关键,它把“登录校验”这件事收敛到一处。所有需要登录的接口都用它,不用每个接口里重复写if (!user) throw。而且next里重新构造 ctx 之后,后续 procedure 里ctx.session.user的类型就是非空的,不用再!断言。

然后写具体 router:

import { z } from 'zod'; import { protectedProcedure, publicProcedure, router } from '../trpc'; export const postRouter = router({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { return ctx.db.post.findMany({ take: input.limit, orderBy: { createdAt: 'desc' }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(200), content: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), });

这里 Zod 负责输入校验,校验失败会直接返回错误,不会进到业务逻辑。我特别喜欢这个设计,因为以前写 Express 的时候,校验散落在各个 handler 里,容易漏。现在 input 定义即校验规则,还能推导出 TS 类型,一举两得。

3.2 客户端调用与缓存策略

客户端这边,tRPC 配合 React Query 使用。先建一个 trpc client:

import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '~/server/api/root'; export const trpc = createTRPCReact<AppRouter>();

然后在根组件挂 Provider,配置 httpBatchLink。这里有个优化点:用 superjson 做序列化,这样 Date、Map、Set 这些类型能正确传输,不然 Date 会变成字符串,用的时候还得手动转。

调用的时候:

const { data, isLoading } = trpc.post.list.useQuery({ limit: 20 }); const createPost = trpc.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, });

invalidate这步很重要。mutation 成功后,列表数据已经过期了,得手动让它重新拉。我一开始忘了写,结果创建完帖子列表不刷新,还以为是 bug。React Query 的缓存机制是“stale-while-revalidate”,默认 staleTime 是 0,但你不主动 invalidate,它不会立刻重拉。

实操心得:对于变动不频繁的数据(比如分类列表),可以设staleTime: 5 * 60 * 1000,减少请求。对于实时性要求高的,保持默认并配合 invalidate。

3.3 数据库层与类型同步

Prisma 这块,schema 定义完跑prisma generate,会生成类型到node_modules/.prisma/client。tRPC 的 ctx 里注入db实例,procedure 里直接用,返回的类型自动就是 Prisma 的类型。

有个坑要注意:Prisma 返回的 Date 对象经过 tRPC 传输后,在客户端是 Date 还是 string,取决于你有没有配 superjson。没配的话是 string,你在组件里调date.getFullYear()会报错。配了 superjson 就还是 Date。这个我踩过,排查的时候一脸懵。

另外,不要直接把 Prisma model 整个返回给客户端。比如 User 表有passwordHash字段,你findMany直接返回就把哈希泄露了。正确做法是用select明确指定要返回的字段,或者写一个publicUserSelect常量复用。

3.4 环境变量与配置管理

t3code 这套东西依赖几个环境变量:DATABASE_URL、NEXTAUTH_SECRET(如果用 NextAuth)、NEXTAUTH_URL等。我建议用@t3-oss/env-nextjs这个包来做校验,它能在启动时就检查环境变量是否齐全、格式对不对,而不是等到运行时才报错。

import { createEnv } from '@t3-oss/env-nextjs'; import { z } from 'zod'; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, });

这样env.DATABASE_URL用的时候类型是 string,不用!断言。而且部署时如果忘了配某个变量,构建阶段就失败了,不会等到线上才炸。

4. 完整实操流程与关键环节

4.1 从零初始化项目

我一般用create-t3-app这个脚手架起步,它会把 Next.js、tRPC、Prisma、Tailwind、NextAuth 都配好。命令是:

npm create t3-app@latest my-app

交互式选择里,我通常勾选 TypeScript、Tailwind、tRPC、Prisma、NextAuth。App Router 看项目需求,新项目我倾向选 App Router,虽然有些库兼容性还在完善,但大势所趋。

初始化完之后,目录结构已经搭好了。接下来改.env,把DATABASE_URL指向本地 Postgres。我用 Docker 起一个:

docker run --name t3-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:16

然后DATABASE_URL="postgresql://postgres:password@localhost:5432/mydb"。跑npx prisma db push把 schema 同步到数据库。

4.2 定义第一个业务模型

假设做一个简单的博客,先改prisma/schema.prisma:

model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

跑npx prisma db push和npx prisma generate。注意db push适合开发阶段快速迭代,生产环境要用prisma migrate生成迁移文件,这样有版本记录、可回滚。

4.3 写 router 并接入

在src/server/api/routers/post.ts里写上面提到的 postRouter,然后在root.ts里注册:

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

客户端就能trpc.post.list.useQuery()了。这里有个细节:App Router 下 tRPC 的路由 handler 要放在app/api/trpc/[trpc]/route.ts,用fetchRequestHandler处理。Pages Router 则是pages/api/trpc/[trpc].ts。两种模式配置不一样,别搞混。

4.4 页面里消费数据

写一个列表页:

'use client'; import { trpc } from '~/utils/trpc'; export default function PostList() { const { data, isLoading, error } = trpc.post.list.useQuery({ limit: 20 }); if (isLoading) return <div>加载中...</div>; if (error) return <div>出错了:{error.message}</div>; return ( <ul> {data?.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }

注意data?.map里的可选链,因为 query 返回类型是T | undefined。这是类型系统在提醒你处理加载态,不是找麻烦。

4.5 表单提交与乐观更新

创建帖子的表单,用 mutation:

const utils = trpc.useUtils(); const createPost = trpc.post.create.useMutation({ onMutate: async (newPost) => { await utils.post.list.cancel(); const prev = utils.post.list.getData({ limit: 20 }); utils.post.list.setData({ limit: 20 }, (old) => [ { ...newPost, id: 'temp', createdAt: new Date() } as any, ...(old ?? []), ]); return { prev }; }, onError: (_err, _newPost, ctx) => { if (ctx?.prev) utils.post.list.setData({ limit: 20 }, ctx.prev); }, onSettled: () => { utils.post.list.invalidate(); }, });

这是乐观更新的写法:提交时先把新数据塞进缓存,用户立刻看到效果;失败了回滚;最后无论成败都 invalidate 拉真实数据。这个体验比“转圈等接口”好很多,但要注意临时 id 的处理,真实 id 回来后要替换,否则 React key 会冲突。

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

5.1 类型推导慢或 IDE 卡顿

项目大了之后,tRPC 的类型推导会变得很重,VSCode 里 hover 一个变量要等好几秒。我试过几个办法:

  • 拆分 router:不要把所有 procedure 塞一个文件,按业务域拆开,每个文件小一点。
  • 用inferRouterOutputs提取类型:需要显式类型时用它,而不是让 TS 去推导整个 router。
  • 升级 TS 版本:TS 5.x 在类型推导性能上有优化,我升到 5.3 之后明显好转。
  • 限制strict范围:如果实在卡,可以对server目录单独放宽某些选项,但我不推荐,会失去类型保护。

5.2 数据库连接池耗尽

开发时热重载频繁,每次重载都新建 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;

这样热重载时复用同一个实例。生产环境不需要,因为不会热重载。

5.3 tRPC 错误信息不清晰

默认情况下,tRPC 在生产环境会把错误信息脱敏,只返回INTERNAL_SERVER_ERROR。开发时想看详细堆栈,要在trpc.ts里配errorFormatter:

const t = initTRPC.context<Context>().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, });

这样 Zod 校验失败时,客户端能拿到具体哪个字段错了,方便表单展示错误提示。

5.4 常见问题速查表

问题现象可能原因排查方向
客户端类型是 any没导出 AppRouter 类型或 import 路径错检查import type { AppRouter }
请求 404tRPC handler 路径配错App Router 用[trpc]/route.ts
Date 变字符串没配 superjson在 links 里加 superjson
登录态拿不到context 没注入 session检查createContext实现
mutation 后列表不刷新没 invalidate在 onSuccess/onSettled 里调用
构建时报 Prisma 类型缺失没跑 generate在 build 脚本前加prisma generate

避坑技巧:部署到 Vercel 这类平台时,postinstall脚本里要加prisma generate,否则构建时找不到 Prisma Client 类型。这个坑我踩过,本地好好的,一部署就挂。

5.5 关于鉴权的几个细节

NextAuth 和 tRPC 配合时,getServerSession要在 context 里调用。App Router 下传req和res的方式和 Pages Router 不同,容易搞错。我的做法是封装一个createContext,内部统一处理,procedure 里只管用ctx.session。

另外,不要在客户端组件里直接调getServerSession,那是服务端 API。客户端要用useSessionhook,或者干脆通过 tRPC 的protectedProcedure来间接判断——如果没登录,请求会返回 UNAUTHORIZED,客户端据此跳转登录页。

6. 性能优化与扩展思路

6.1 请求批处理与缓存

tRPC 的httpBatchLink会把同一时间发出的多个请求合并成一个 HTTP 请求,减少网络往返。这个在页面初始化时同时拉多个 query 的场景下效果明显。配置:

httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, transformer: superjson, })

配合 React Query 的缓存,相同 query key 的请求在 staleTime 内不会重复发。我一般把列表类 query 的 staleTime 设 30 秒,详情类设 5 分钟,用户主动刷新时再 invalidate。

6.2 服务端渲染与预取

Next.js App Router 下,可以在 Server Component 里预取数据,然后传给 Client Component。tRPC 提供了createServerSideHelpers来做这件事:

const helpers = createServerSideHelpers({ router: appRouter, ctx: await createContext(), transformer: superjson, }); await helpers.post.list.prefetch({ limit: 20 });

然后在客户端用trpc.post.list.useQuery()时,数据已经在缓存里了,首屏不会闪加载态。这个对 SEO 和体验都有帮助,但要注意预取的数据要能被序列化,Date 对象经过 superjson 处理没问题。

6.3 后续可以怎么扩展

这套结构搭好之后,扩展方向挺多的。比如加文件上传,可以用 tRPC 的 mutation 接收 base64 或者预签名 URL;加实时通知,可以配合 WebSocket 或者 Server-Sent Events,tRPC 有 subscriptions 支持,但配置稍复杂;加权限分级,可以在 procedure 中间件里根据 role 判断,比在每个接口里写 if 优雅得多。

我个人的体会是,t3code 这套模式最大的价值不是省了多少代码,而是把“类型安全”变成了默认状态。你不需要刻意去维护类型,编译器会推着你走。刚开始可能觉得约束多、不自由,但项目跑到后期,重构和加功能的时候,那种“改一处、编译器告诉你哪里要跟着改”的踏实感,是动态类型语言给不了的。

最后分享一个小技巧:如果你觉得 tRPC 的useQuery返回的data总是T | undefined很烦,可以封装一个useQuerySafehook,在isLoading为 false 且data存在时才渲染子组件,把空值处理收敛到一处。这样业务组件里就能直接拿到非空数据,代码干净不少。

RELATED READING

延伸阅读

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