1. 从零认识 t3code:它到底是什么,能解决什么问题
第一次看到 t3code 这个名字,很多人会以为是某个新出的编程语言或者框架缩写。实际上,t3code 是一套围绕 TypeScript 全栈开发场景构建的代码组织与工程化实践方案,核心思路是把 tRPC、TypeScript 和 Tailwind CSS 这三样东西捏合成一条顺畅的开发链路。名字里的“t3”正是取自这三个技术首字母,而“code”则强调它最终落地的是可维护、可复用的代码结构,而不是空泛的概念。
我在实际项目里接触 t3code 这套模式,最早是因为一个中小型 SaaS 后台的需求。团队只有三个人,却要同时维护前端页面、后端接口和数据库模型。传统做法是前后端各写各的接口定义,改一个字段要来回沟通三四次,类型对不上还得靠运行时调试。t3code 这套东西最直接的价值,就是让类型定义从数据库一路贯穿到前端组件,改一处字段,编译器立刻告诉你哪些地方需要同步调整。这种“类型即文档、类型即契约”的体验,一旦用过就很难回去。
它适合谁?如果你正在用 TypeScript 写全栈项目,尤其是那种迭代频繁、字段经常变动、团队规模不大的场景,t3code 的思路非常值得参考。它不适合那种前后端完全分离、由不同团队维护、接口需要严格版本管理的大型系统,因为 tRPC 的强耦合特性在这种场景下反而会变成负担。但对于独立开发者、小团队、快速验证型产品来说,t3code 能省下大量写接口文档和联调的时间。
这篇文章我会从整体设计思路、核心细节、实操过程、常见问题四个维度,把 t3code 这套东西拆开讲透。不管你是刚听说这个概念,还是已经在用但总觉得哪里别扭,都能从下面这些内容里找到可以直接抄作业的部分。
2. t3code 整体设计与思路拆解
2.1 为什么是 tRPC + TypeScript + Tailwind 这个组合
要理解 t3code 的设计,得先明白它为什么偏偏选这三样东西。市面上全栈方案那么多,Next.js 自带 API Routes,GraphQL 也有成熟的代码生成工具,为什么还要搞一套 tRPC 为中心的方案?
核心原因在于类型传递的零损耗。传统 REST 接口,前端调用fetch('/api/user'),返回的 JSON 是什么类型,TypeScript 根本不知道,你得手动写一个interface User然后祈祷后端别改字段。GraphQL 好一些,可以通过 codegen 生成类型,但多了一层 schema 定义和代码生成步骤,改一个字段要跑一次生成命令。tRPC 的做法更直接:后端定义一个函数,前端直接调用这个函数的类型签名,中间没有 schema 文件、没有代码生成、没有接口文档,类型就是代码本身。
Tailwind CSS 在这个组合里的角色容易被低估。很多人觉得它只是个 CSS 框架,跟类型安全有什么关系?实际上,t3code 选择 Tailwind 是因为它和组件化开发天然契合。当你用 tRPC 保证了数据层的类型安全后,UI 层的样式如果还散落在各种 CSS 文件里,维护成本依然很高。Tailwind 的把样式写在 className 里的做法,让组件的结构、样式、数据绑定全部集中在一个文件里,改一个组件不需要在三个文件之间跳来跳去。
这个组合的另一个考量是学习曲线的一致性。TypeScript、tRPC、Tailwind 都是围绕“类型”和“约束”做文章的工具。TypeScript 约束变量类型,tRPC 约束接口类型,Tailwind 约束样式取值范围。三者叠加起来,整个项目的每一层都有明确的边界,新人接手时能通过类型提示快速理解代码意图,而不是靠读文档和猜。
2.2 目录结构背后的分层逻辑
t3code 的目录结构不是随便拍的,每一层都有明确的职责划分。我见过很多项目把 tRPC 的 router 定义和业务逻辑混在一起,结果一个文件上千行,改一个接口要翻半天。t3code 的做法是把“路由定义”和“业务实现”拆开,router 只负责声明输入输出类型和调用哪个 procedure,真正的数据库操作和业务计算放在单独的 service 层。
这种分层的好处在于测试和复用。service 层的函数不依赖 tRPC 的上下文,可以直接用单元测试覆盖,也可以被其他非 tRPC 的场景调用,比如定时任务或者命令行脚本。router 层则保持轻薄,只做参数校验和权限判断,逻辑一目了然。
另一个关键设计是共享类型目录。前端和后端都会用到的类型定义,比如 User、Post、Pagination 这些,放在一个独立的types目录里,前后端都从这里导入。这样避免了“前端写一遍 interface,后端写一遍 interface,然后两边对不上”的经典问题。这个目录里的类型应该是纯类型声明,不包含任何运行时代码,保证它既能被前端打包工具处理,也能被后端 Node 环境直接引用。
2.3 类型安全到底省了什么成本
很多人对类型安全的认知停留在“编辑器有提示”这个层面,觉得没有类型也能写代码,只是没那么方便。但在 t3code 这种全栈场景下,类型安全省下的成本是实打实的。
最直接的是联调时间。以前前后端对接,前端说“你这个字段怎么没返回”,后端说“我返回了啊你字段名写错了”,来回扯皮半小时。现在后端改完字段名,前端编辑器立刻标红,根本不需要等到运行时才发现。我统计过自己手上的项目,引入 tRPC 之后,因为字段不匹配导致的 bug 减少了大概七成。
其次是重构信心。当你需要把一个字段从string改成number,或者把一个接口的返回结构从数组改成对象,TypeScript 会列出所有受影响的地方。你不需要全局搜索字段名然后一个个判断是不是同一个东西,编译器帮你做了这件事。这种重构时的安全感,是动态类型语言很难提供的。
还有一个隐性收益是文档的实时性。类型定义本身就是最好的接口文档,而且它永远不会过期,因为过期了编译就通不过。新人接手项目时,打开 router 文件就能看到所有可用的接口、每个接口的输入输出类型,比读一份可能已经过时的 Markdown 文档靠谱得多。
3. 核心细节解析与实操要点
3.1 tRPC 的 router 与 procedure 定义规范
tRPC 的核心概念是 router 和 procedure。router 相当于一个命名空间,procedure 相当于具体的接口函数。定义 procedure 时,输入和输出类型必须显式声明,这是类型安全的基础。
一个典型的 procedure 定义长这样:输入用z.object({ id: z.string() })这样的校验器声明,输出通过函数的返回类型自动推断。这里的关键是输入校验和类型推断要一步到位。很多人习惯先写一个 TypeScript interface 作为输入类型,然后再写一个 zod schema 做运行时校验,这样等于同样的东西写了两遍,而且容易不同步。正确的做法是直接用 zod schema,然后用z.infer<typeof schema>提取出 TypeScript 类型,一份定义两处使用。
输出类型方面,tRPC 会根据函数的实际返回值自动推断,不需要额外声明。但这里有个坑:如果你的函数有多个返回分支,比如成功时返回{ data: User },失败时返回{ error: string },TypeScript 会推断出联合类型,前端调用时就需要做类型收窄。我的建议是尽量保持返回结构一致,错误通过抛出异常的方式处理,让 tRPC 的错误处理机制统一接管,而不是在返回值里混入错误信息。
procedure 的命名也有讲究。我习惯用“动词+名词”的方式,比如getUserById、createPost、updateProfile,而不是user、post这种模糊的名字。这样在 router 里一眼就能看出每个 procedure 的用途,前端调用时trpc.user.getUserById.useQuery()读起来也顺畅。
3.2 中间件与上下文的设计要点
tRPC 的中间件机制是处理认证、日志、权限这些横切关注点的关键。上下文(context)是每个请求都会携带的数据,比如当前登录用户、数据库连接、请求头信息。中间件则是在 procedure 执行前后插入的逻辑,比如检查用户是否登录、记录请求耗时。
设计上下文时,只放真正需要跨 procedure 共享的东西。我见过有人在 context 里塞了十几个字段,结果每个请求都要初始化一遍,性能受影响不说,调试时也搞不清哪个字段是从哪来的。通常 context 里只需要三样东西:数据库客户端、当前用户信息(可能为 null)、请求相关的元数据(如 IP、User-Agent)。
中间件的顺序很重要。认证中间件应该放在最前面,因为后续的权限检查依赖它。日志中间件可以放在认证之后,这样日志里能记录到具体是哪个用户发起的请求。权限中间件则根据具体 procedure 的需要单独挂载,不要全局挂载,否则每个接口都要跑一遍权限检查,浪费性能。
还有一个容易忽略的点是中间件的错误处理。如果认证失败,中间件应该抛出 tRPC 的TRPCError,并指定正确的错误码,比如UNAUTHORIZED。这样前端能根据错误码做统一处理,比如跳转到登录页,而不是每个接口都写一遍错误判断逻辑。
3.3 前端调用与缓存策略
tRPC 前端调用的核心是 React Query 的集成。trpc.user.getUserById.useQuery({ id })这一行代码背后,React Query 帮你处理了缓存、重试、轮询、乐观更新等一系列复杂逻辑。但默认配置不一定适合所有场景,需要根据业务特点调整。
缓存时间(staleTime)的设置是个关键决策。对于用户信息这种变化不频繁的数据,可以设置较长的 staleTime,比如 5 分钟,避免每次切换页面都重新请求。对于列表数据,可以设置较短的 staleTime,比如 30 秒,保证数据的实时性。对于实时性要求极高的场景,比如聊天消息,应该用useSubscription而不是useQuery。
乐观更新是提升用户体验的利器,但用不好也会带来麻烦。tRPC 配合 React Query 做乐观更新的标准流程是:在onMutate里取消正在进行的查询、保存旧数据、更新缓存;在onError里回滚到旧数据;在onSettled里重新拉取数据确保一致性。这个流程看起来简单,但实际写的时候很容易漏掉某一步,导致界面显示的数据和服务器不一致。
我踩过的一个坑是:在onMutate里更新缓存时,直接修改了缓存对象的属性,而不是创建新对象。React Query 依赖引用相等性来判断数据是否变化,直接修改属性不会触发重新渲染。正确的做法是用展开运算符创建新对象,比如{ ...oldData, name: newName }。
3.4 Tailwind 在 t3code 中的组织方式
Tailwind 的原子化类名在组件内部很好用,但在 t3code 这种多组件项目里,如果每个组件都写一长串 className,代码会变得很难读。我的做法是把重复的样式组合提取成组件,而不是提取成 CSS 类。
比如一个按钮,如果项目里有五种不同的按钮样式,不要写五个 CSS 类然后用@apply组合,而是写一个 Button 组件,通过 props 控制 variant。这样样式逻辑集中在组件内部,外部调用时只需要传variant="primary",既保持了 Tailwind 的灵活性,又避免了类名爆炸。
对于确实需要复用的样式片段,可以用 Tailwind 的@layer components定义语义化的类名,比如.card、.input。但要注意不要过度使用,否则又回到了传统 CSS 的老路。我的经验是:如果一个样式组合在三个以上的地方出现,才考虑提取;否则直接在组件里写 className 更直观。
响应式设计方面,Tailwind 的断点前缀(sm、md、lg、xl)用起来很方便,但要注意移动优先的原则。默认样式应该是移动端的,然后用md:、lg:前缀覆盖大屏样式。很多人习惯反过来,先写桌面端样式再用max-md:覆盖移动端,这样代码读起来是反的,维护时容易搞混。
4. 实操过程与核心环节实现
4.1 项目初始化与依赖安装
从零开始搭建一个 t3code 风格的项目,第一步是初始化 Node 项目并安装核心依赖。我习惯用 pnpm 而不是 npm 或 yarn,因为 pnpm 的硬链接机制在 monorepo 场景下能省很多磁盘空间,而且安装速度明显更快。
初始化命令很简单:pnpm init然后手动修改 package.json 里的 name、version、scripts 等字段。接着安装核心依赖:pnpm add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod。如果是 Next.js 项目,还需要pnpm add next react react-dom以及对应的类型声明。
TypeScript 配置是容易被忽视但很关键的一步。tsconfig.json里的strict必须设为 true,否则类型安全就是一句空话。moduleResolution建议设为bundler,这样导入路径不需要写.js后缀。paths字段可以配置路径别名,比如@/*指向src/*,避免写../../..这种相对路径。
Tailwind 的安装稍微麻烦一点,需要pnpm add -D tailwindcss postcss autoprefixer然后npx tailwindcss init -p生成配置文件。tailwind.config.js里的content字段要包含所有可能用到 Tailwind 类名的文件,通常是./src/**/*.{js,ts,jsx,tsx}。如果漏了某个目录,那个目录下的 Tailwind 类名就不会被编译进最终的 CSS 文件,页面样式会莫名其妙丢失。
4.2 数据库层与 Prisma 的配合
t3code 本身不限定数据库方案,但实践中 Prisma 是最常见的选择,因为它和 TypeScript 的集成非常顺滑。Prisma schema 里定义的模型会自动生成对应的 TypeScript 类型,这些类型可以直接在 tRPC 的 procedure 里使用。
定义 schema 时,字段命名要统一风格。我习惯用 camelCase 命名字段,然后在 Prisma 的@map里指定数据库的实际列名。这样 TypeScript 代码里是 camelCase,数据库里是 snake_case,两边都符合各自的惯例。如果不用@map,要么 TypeScript 里出现 snake_case 字段名(不符合 JS 惯例),要么数据库里出现 camelCase 列名(不符合 SQL 惯例),总有一边别扭。
关系定义是 Prisma schema 里最容易出错的部分。一对多关系需要在“多”的一方定义外键,在“一”的一方定义数组字段。多对多关系则需要一个中间表,Prisma 会自动处理。我踩过的坑是:忘记在关系字段上加@relation注解,导致 Prisma 生成了错误的查询。每次修改 schema 后,一定要跑npx prisma generate重新生成客户端,否则代码里用的还是旧类型。
数据库迁移方面,开发阶段用npx prisma migrate dev会自动检测 schema 变化并生成迁移文件。生产环境则用npx prisma migrate deploy只应用已有的迁移文件,不会自动检测变化。这个区别很重要,生产环境千万不要用migrate dev,否则可能意外删除数据。
4.3 tRPC 服务端完整实现
服务端的核心文件是server/trpc.ts,这里初始化 tRPC 实例、定义上下文类型、导出中间件和 procedure 构造器。初始化代码大致如下:
import { initTRPC, TRPCError } from '@trpc/server'; import { ZodError } from 'zod'; import { prisma } from './db'; export const createContext = async (opts: { req: Request }) => { const user = await getUserFromRequest(opts.req); return { prisma, user }; }; type Context = Awaited<ReturnType<typeof createContext>>; const t = initTRPC.context<Context>().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, user: ctx.user } }); });这段代码里,errorFormatter的作用是把 zod 的校验错误格式化成一个前端容易处理的结构。protectedProcedure则是一个中间件,检查用户是否登录,如果没登录就抛出UNAUTHORIZED错误。注意next({ ctx: { ...ctx, user: ctx.user } })这一行,它把 user 的类型从User | null收窄成了User,这样后续的 procedure 里就不需要再判断 user 是否为空了。
定义具体的 router 时,我习惯按业务模块拆分文件,比如userRouter、postRouter、commentRouter,然后在根 router 里合并。每个 procedure 的输入用 zod schema 定义,输出让 TypeScript 自动推断。一个典型的查询 procedure 长这样:
export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, email: true, createdAt: true }, }); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' }); } return user; }), });这里用select明确指定返回字段,而不是返回整个 user 对象,是为了避免意外泄露密码哈希等敏感字段。这个习惯很重要,我见过太多项目因为直接返回整个数据库对象而导致敏感信息泄露。
4.4 前端集成与组件调用
前端集成的第一步是创建 tRPC 客户端。在 Next.js 的 App Router 模式下,需要区分服务端组件和客户端组件的调用方式。服务端组件用createCaller直接调用,客户端组件用createTRPCReact创建 hooks。
'use client'; import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/routers/_app'; export const trpc = createTRPCReact<AppRouter>();然后在根布局里包裹 Provider,配置 React Query 的 QueryClient 和 tRPC 的 httpBatchLink。httpBatchLink的作用是把多个请求合并成一个 HTTP 请求,减少网络往返次数。配置里需要指定url和headers,headers 里通常放认证 token。
组件里调用就很简单了:
function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId }); if (isLoading) return <div>加载中...</div>; if (error) return <div>出错了:{error.message}</div>; if (!user) return <div>用户不存在</div>; return ( <div className="rounded-lg border p-4"> <h2 className="text-lg font-semibold">{user.name}</h2> <p className="text-sm text-gray-500">{user.email}</p> </div> ); }注意useQuery的输入参数类型是自动推断的,如果你传了错误的字段名或者类型,编辑器会立刻报错。这就是 t3code 类型安全的直观体现。
4.5 构建与部署的关键配置
构建阶段最重要的是类型检查不能跳过。很多项目为了加快构建速度,在next.config.js里设置了typescript: { ignoreBuildErrors: true },这等于把类型安全的最大价值扔掉了。我的建议是构建时一定要跑类型检查,如果觉得慢,可以用tsc --noEmit单独跑,或者用增量编译缓存。
环境变量的管理也是个关键点。数据库连接字符串、认证密钥这些敏感信息必须放在环境变量里,不能硬编码在代码中。Next.js 项目里,只有以NEXT_PUBLIC_开头的环境变量才会暴露给前端,其他变量只在服务端可用。这个机制要利用好,避免意外泄露服务端密钥。
部署方面,如果用的是 Vercel 这类平台,需要注意 tRPC 的 API 路由要配置正确的运行时。默认的 Node.js 运行时适合大多数场景,但如果用了 Prisma,需要确保 Prisma 的查询引擎能在部署环境中正常运行。我遇到过因为 Prisma 二进制文件没有正确打包导致部署后接口全部报错的情况,解决办法是在package.json的scripts里加上prisma generate作为构建的前置步骤。
5. 常见问题与排查技巧实录
5.1 类型推断失败与循环引用
tRPC 项目里最常见的问题就是类型推断失败,表现为前端调用时AppRouter类型变成any,所有类型提示都消失。这个问题通常有两个原因:一是 router 的导出方式不对,二是存在循环引用。
导出方式方面,根 router 必须用export type AppRouter = typeof appRouter的方式导出类型,而不是export { appRouter }。前者只导出类型,不会把服务端代码打包进前端;后者会把整个 router 实现导出,导致前端 bundle 体积暴涨,而且可能因为服务端依赖(如 Prisma)无法在前端运行而报错。
循环引用则更隐蔽。如果userRouter引用了postRouter的某个类型,而postRouter又引用了userRouter,TypeScript 在推断类型时就会陷入死循环,最终放弃推断返回any。解决办法是把共享类型抽到独立的types文件里,两个 router 都从这个文件导入,而不是互相引用。
排查这个问题的技巧是:在tsconfig.json里开启"noImplicitAny": true,这样类型推断失败的地方会直接报错,而不是静默变成any。然后逐个 router 注释掉,看哪个 router 恢复后类型就正常了,就能定位到循环引用的位置。
5.2 请求缓存不更新的典型场景
React Query 的缓存机制很强大,但也很容易出问题。最常见的场景是:修改了数据后,列表页面没有自动刷新,还显示旧数据。这是因为修改操作和查询操作用的是不同的缓存 key,React Query 不知道它们有关联。
解决办法是在useMutation的onSuccess里手动调用utils.invalidate(),指定需要失效的查询。比如创建文章后,调用utils.post.list.invalidate()让文章列表重新请求。如果关联的查询很多,可以用utils.invalidate()不带参数,让所有查询都失效,但这样会导致不必要的重新请求,性能上不划算。
另一个坑是乐观更新后的数据不一致。乐观更新是在服务器响应之前就更新本地缓存,让用户感觉操作立即生效。但如果服务器返回的数据和本地乐观更新的数据不一致,就会出现“界面显示成功但实际数据不对”的情况。解决办法是在onSettled里重新拉取数据,用服务器的真实数据覆盖本地缓存。虽然多了一次请求,但保证了数据一致性。
还有一个容易被忽略的点是跨标签页的缓存同步。用户在一个标签页修改了数据,另一个标签页的缓存不会自动更新。React Query 提供了refetchOnWindowFocus选项,可以在窗口重新获得焦点时刷新数据。对于数据一致性要求高的场景,还可以用 BroadcastChannel 手动同步缓存,但这属于进阶用法,大多数项目用refetchOnWindowFocus就够了。
5.3 数据库连接池耗尽的排查
Prisma 默认的连接池大小是num_cpus * 2 + 1,在开发环境通常够用,但在生产环境高并发场景下很容易耗尽。表现是接口响应变慢,日志里出现Timed out fetching a new connection from the pool的错误。
排查这个问题的第一步是确认连接池大小是否合理。可以在 Prisma 的连接字符串里加上connection_limit参数,比如?connection_limit=10。但要注意,连接池不是越大越好,数据库本身的连接数上限是有限的,如果每个应用实例都开很大的连接池,数据库会先撑不住。
更根本的解决办法是减少长事务和慢查询。很多连接池耗尽的情况,实际上是因为某些查询执行时间太长,占着连接不释放。用 Prisma 的$on('query')事件可以记录所有查询的执行时间,找出那些超过 1 秒的慢查询,针对性地加索引或优化查询逻辑。
还有一个常见原因是在循环里调用数据库查询。比如要查 100 个用户的详细信息,不要写for (const id of ids) { await prisma.user.findUnique({ where: { id } }) },这样会占用 100 次连接。正确的做法是用prisma.user.findMany({ where: { id: { in: ids } } }),一次查询搞定。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 前端类型提示消失 | AppRouter 导出方式错误 | 检查根 router 是否用export type | 改为export type AppRouter = typeof appRouter |
| 类型推断变成 any | router 之间存在循环引用 | 逐个注释 router 定位 | 抽取共享类型到独立文件 |
| 修改后列表不刷新 | 缓存 key 未失效 | 检查 mutation 的 onSuccess | 调用utils.xxx.invalidate() |
| 乐观更新数据不一致 | 未在 onSettled 重新拉取 | 对比本地缓存和服务器数据 | 在 onSettled 里调用refetch() |
| 接口响应变慢 | 数据库连接池耗尽 | 查看 Prisma 日志中的连接超时 | 优化慢查询,减少循环内查询 |
| 部署后接口全部报错 | Prisma 引擎未正确打包 | 查看部署日志中的 Prisma 错误 | 构建前跑prisma generate |
| Tailwind 样式丢失 | content 配置遗漏目录 | 检查 tailwind.config.js | 补全 content 字段的 glob 模式 |
| 认证中间件不生效 | 中间件顺序错误 | 检查 procedure 的 use 链 | 认证中间件放在最前面 |
5.5 几个我踩过的坑和对应技巧
第一个坑是zod schema 的.optional()和.nullable()混用。.optional()表示字段可以不存在,.nullable()表示字段可以存在但值为 null。在 tRPC 的输入校验里,如果前端传了undefined,.nullable()会校验失败;如果传了null,.optional()会校验失败。我的经验是:对于用户可能不填的字段,用.optional();对于数据库里允许为 null 的字段,用.nullable();如果两者都可能,用.nullish()。
第二个坑是tRPC 的错误信息在生产环境被隐藏。tRPC 默认在生产环境只返回错误码,不返回具体的错误信息,这是为了防止敏感信息泄露。但有时候我们需要给用户展示具体的错误原因,比如“用户名已存在”。解决办法是在抛出TRPCError时,把用户可见的信息放在message里,把技术细节放在cause里。然后在 errorFormatter 里判断,如果是生产环境,只返回 message,不返回 cause。
第三个坑是React Query 的enabled选项和条件查询。有时候我们需要根据某个条件决定是否发起查询,比如用户 ID 为空时不查询。这时候要用useQuery的enabled选项,而不是在组件里写if判断。因为 React Hooks 的规则要求 hooks 必须在顶层调用,不能放在条件语句里。enabled: !!userId这样的写法既符合 hooks 规则,又能避免不必要的请求。
第四个坑是Tailwind 的purge误删动态类名。Tailwind 在构建时会扫描代码里的类名,只保留用到的样式。但如果类名是动态拼接的,比如className={`text-${color}-500`},Tailwind 扫描不到完整的类名,就会把text-red-500、text-blue-500这些样式全部删掉。解决办法是把动态类名写成完整的字符串,比如用一个映射对象{ red: 'text-red-500', blue: 'text-blue-500' },这样 Tailwind 就能扫描到。
6. 性能优化与扩展思路
6.1 查询性能的优化手段
t3code 项目里,性能瓶颈通常出现在数据库查询上。Prisma 提供了include和select两个选项来控制关联查询,用好了能显著减少查询次数。include会把关联数据一起查出来,适合一对多关系;select则更精细,可以指定只返回哪些字段。
对于复杂的关联查询,Prisma 会生成多条 SQL 语句,而不是一条 JOIN 查询。这在数据量小的时候没问题,但数据量大时性能会明显下降。解决办法是用 Prisma 的$queryRaw手写 SQL,或者用findMany配合in条件分两步查询,然后在应用层做数据组装。后者虽然多了一次查询,但避免了复杂的 JOIN,总体性能往往更好。
分页是另一个优化重点。Prisma 的skip和take实现的是偏移分页,在数据量大时性能很差,因为数据库需要扫描并跳过前面的所有行。更好的方案是游标分页,用cursor和take实现,每次查询从上次结束的位置开始,不需要扫描前面的数据。tRPC 的输入 schema 里可以定义一个cursor字段,前端把上一页最后一条记录的 ID 传过来,后端用它作为游标。
6.2 前端渲染性能的注意事项
React Query 的缓存虽然好用,但如果缓存了太多数据,内存占用会很高。对于列表数据,可以设置gcTime(旧版本叫cacheTime)来控制缓存多久后被回收。默认是 5 分钟,对于不常访问的页面可以调短一些。
组件的重渲染也是性能优化的重点。tRPC 的useQuery返回的data对象在每次请求后都是新的引用,即使数据内容没变,也会触发组件重渲染。解决办法是用select选项提取真正需要的字段,或者用useMemo包裹依赖 data 的计算逻辑。React Query 的structuralSharing选项默认开启,会对新旧数据进行深度比较,如果内容相同就保留旧引用,避免不必要的重渲染。
图片和静态资源的优化也不能忽视。Next.js 的next/image组件会自动做懒加载、格式转换、尺寸优化,比直接用img标签性能好很多。Tailwind 的aspect-ratio类可以避免图片加载时的布局抖动,提升用户体验。
6.3 从单体到模块化的演进路径
项目初期,所有 router 放在一个文件里没问题,但随着业务增长,文件会变得很大。这时候需要按业务模块拆分,每个模块一个目录,包含 router、service、types 三个文件。router 只做接口定义,service 做业务逻辑,types 做类型声明。
拆分之后,模块之间的依赖关系要理清。我习惯用“依赖倒置”的原则:上层模块依赖抽象接口,而不是具体实现。比如订单模块需要获取用户信息,不要直接导入用户模块的 service,而是定义一个UserProvider接口,用户模块实现这个接口,订单模块依赖接口。这样两个模块可以独立测试和替换,耦合度更低。
如果项目进一步增长,可以考虑拆分成 monorepo,把共享类型和工具函数抽成独立的 package。pnpm workspace 是管理 monorepo 的好工具,配置简单,依赖提升机制也很清晰。但要注意,monorepo 会增加构建复杂度,如果团队规模不大,单体项目加清晰的目录结构通常就够了,不必过早引入 monorepo。
6.4 测试策略与质量保障
t3code 项目的测试分三层:单元测试、集成测试、端到端测试。单元测试覆盖 service 层的纯函数,用 Vitest 或 Jest 跑,速度快,反馈及时。集成测试覆盖 tRPC 的 procedure,用createCaller直接调用,不需要启动 HTTP 服务器,测试的是从输入校验到数据库操作的完整链路。端到端测试用 Playwright 或 Cypress,模拟真实用户操作,覆盖关键业务流程。
集成测试是 t3code 项目里性价比最高的测试。因为 tRPC 的 procedure 是纯函数式的,输入输出都有类型约束,测试写起来很直观。我通常会给每个 procedure 写三个测试用例:正常输入返回预期结果、非法输入返回校验错误、无权限时返回 UNAUTHORIZED。这三个用例覆盖了大多数边界情况。
测试数据库的管理是个麻烦事。我习惯用 Docker 起一个独立的测试数据库,每次跑测试前清空数据,跑完后销毁容器。Prisma 的migrate reset命令可以快速重置数据库结构,配合 seed 脚本填充测试数据。这样每次测试都在干净的环境里跑,结果可重复。
7. 我个人的一些实操体会
t3code 这套东西最大的价值不在于某个具体技术,而在于它提供了一种“类型驱动开发”的思维方式。当你习惯了先定义类型、再写实现、最后让编译器帮你检查所有调用点,就很难再回到那种靠运行时调试来发现问题的开发方式了。
不过我也要提醒一句:类型安全不是银弹。它解决的是“字段不匹配”“参数类型错误”这类问题,但解决不了“业务逻辑错误”“并发竞争”“数据一致性”这些更深层的问题。该写的测试还是要写,该做的代码审查还是要做。类型系统只是第一道防线,不是唯一防线。
另外,tRPC 的强耦合特性意味着它不适合所有场景。如果你的项目需要对外开放 API,或者前后端由不同团队维护,那 REST 或 GraphQL 可能更合适。t3code 的最佳适用场景是“全栈 TypeScript 团队开发内部产品”,在这个范围内,它的开发效率优势非常明显。
最后分享一个小技巧:在 tRPC 的 procedure 里,善用ctx传递请求级别的数据,但不要滥用。我见过有人在 ctx 里放了一个全局的缓存对象,结果不同请求之间互相污染,排查了半天才发现是 ctx 的问题。ctx 应该是每个请求独立的,任何跨请求共享的状态都应该放在数据库或专门的缓存服务里,而不是 ctx 里。