
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 RoutesGraphQL 也有成熟的代码生成工具为什么还要搞一套 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.infertypeof 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。这样样式逻辑集中在组件内部外部调用时只需要传variantprimary既保持了 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 AwaitedReturnTypetypeof createContext; const t initTRPC.contextContext().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 createTRPCReactAppRouter();然后在根布局里包裹 Provider配置 React Query 的 QueryClient 和 tRPC 的 httpBatchLink。httpBatchLink的作用是把多个请求合并成一个 HTTP 请求减少网络往返次数。配置里需要指定url和headersheaders 里通常放认证 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 classNamerounded-lg border p-4 h2 classNametext-lg font-semibold{user.name}/h2 p classNametext-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又引用了userRouterTypeScript 在推断类型时就会陷入死循环最终放弃推断返回any。解决办法是把共享类型抽到独立的types文件里两个 router 都从这个文件导入而不是互相引用。排查这个问题的技巧是在tsconfig.json里开启noImplicitAny: true这样类型推断失败的地方会直接报错而不是静默变成any。然后逐个 router 注释掉看哪个 router 恢复后类型就正常了就能定位到循环引用的位置。5.2 请求缓存不更新的典型场景React Query 的缓存机制很强大但也很容易出问题。最常见的场景是修改了数据后列表页面没有自动刷新还显示旧数据。这是因为修改操作和查询操作用的是不同的缓存 keyReact 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_limit10。但要注意连接池不是越大越好数据库本身的连接数上限是有限的如果每个应用实例都开很大的连接池数据库会先撑不住。更根本的解决办法是减少长事务和慢查询。很多连接池耗尽的情况实际上是因为某些查询执行时间太长占着连接不释放。用 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类型推断变成 anyrouter 之间存在循环引用逐个注释 router 定位抽取共享类型到独立文件修改后列表不刷新缓存 key 未失效检查 mutation 的 onSuccess调用utils.xxx.invalidate()乐观更新数据不一致未在 onSettled 重新拉取对比本地缓存和服务器数据在 onSettled 里调用refetch()接口响应变慢数据库连接池耗尽查看 Prisma 日志中的连接超时优化慢查询减少循环内查询部署后接口全部报错Prisma 引擎未正确打包查看部署日志中的 Prisma 错误构建前跑prisma generateTailwind 样式丢失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 里。