ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code全栈样板工程:以类型安全重构前后端联调链路

t3code全栈样板工程:以类型安全重构前后端联调链路

1. t3code在解决什么问题:一次全栈项目选型的真实算账

t3code是我在GitHub上维护的一套全栈样板工程,核心思路很朴素:把 Next.js、TypeScript、tRPC、Prisma、Tailwind 这五个工具,以"类型安全"为共同主线串成一个开箱即用的底座。很多人第一次看到 T3 Stack 会觉得它是网红技术栈,真正用起来才发现,它解决的问题非常具体——前后端联调时那种"接口文档写了等于没写、改个字段两边同时炸"的尴尬,在 t3code 这套架构里会大幅减少。

1.1 传统分工模式里,类型断裂的代价被低估了

先说说我为什么最终选了这套方案。早几年做全栈项目,最常见的结构是前端一个仓库、后端一个仓库,前端用 Axios 手写接口调用,后端用 Swagger 出文档。这种模式在项目小的时候很顺畅,一旦业务复杂起来,问题就暴露了:

  • 后端改了接口返回结构,前端只要没及时更新类型定义,运行时大概率直接拿不到字段。
  • 接口文档要么没人维护,要么反序列化类型和真实返回根本对不上,联调阶段一半时间在扯皮。
  • 所有接口调用都得手写一遍类型声明,一百个接口就是一百份重复劳动,模板代码多到让人麻木。

t3code 这套东西最核心的思路,就是把这些"手写类型"的环节改成"自动推导"。数据库表结构由 Prisma 的 schema 定义,类型从 schema 生成;后端接口由 tRPC 的 router 定义,前端调用方可以直接复用同一套类型。换句话说,类型不是人写的,而是从源头流出来的。我第一次跑通这条链路的时候,感觉最明显的变化是:前后端对字段的理解从"约定"变成了"编译期强制"。

1.2 选型时我也对比过其他方案,不只是跟风

可能有人会说,Next.js 自带 API Route,也可以用 GraphQL,为什么偏偏选 tRPC?我实际对比过几种方案:

方案类型穿透能力学习成本服务端缓存/鉴权灵活性适合场景
REST + 手写类型弱,需要维护两份代码低高,完全自定义团队已有成熟规范
GraphQL + codegen中等,需要生成步骤中高中,需要自己处理缓存复杂聚合查询、多端复用
Next.js API Route + fetch弱,仍需额外类型层低高简单项目够用
tRPC强,端到端自动推导中(理解泛型需要时间)中高,procedure 可包装鉴权全栈 TypeScript 团队

真实体验下来,tRPC 并不是万能的。如果你的项目需要给第三方提供公开 API,或者前端不只是 Web 端还有大量小程序/移动端,REST 或者 GraphQL 仍然是更稳的选择。t3code 的定位是内部全栈项目,尤其是"一个团队同时写前端和后端"的场景。类型穿透的优势在这个场景下被放到最大,而对外暴露接口的劣势则不太重要。

1.3 t3code 的边界感:它不是什么都能干

我特别想强调一点:t3code 不是拿来即用的业务框架,它更像一个"骨架清晰的最小可运行系统"。你拿它起步,能省掉配环境、折腾连接、搭建目录结构的琐碎时间,但业务逻辑还是要自己写。它也不会替你决定部署方式、数据库到底用 PostgreSQL 还是 MySQL、组件库用哪个——这些是需要你自己拍板的部分。

如果你正处在一个"想试试全栈 TypeScript,但不知道从哪下手"的状态,或者被前后端类型不一致的 bug 折磨过,这篇文章接下来的内容会很有用。我会把 t3code 的骨架逻辑、一个真实功能的落地过程,以及我在生产环境里踩过的坑,一条一条拆开讲。

2. 骨架拆解:五个核心成员是怎么咬合在一起的

开始实战之前,有必要先把 t3code 的"骨架逻辑"讲清楚。这五个工具各司其职,但真正让它们产生化学反应的是彼此之间的接口设计。理解了这个咬合关系,后面遇到报错时你能更快判断问题出在哪一层。

2.1 Next.js 是底座,App Router 决定了边界的划分

t3code 基于 Next.js 的 App Router 构建。App Router 带来的一个关键变化是"服务端组件"和"客户端组件"的边界。

  • 服务端组件在服务器上执行,可以直接访问数据库、读取环境变量,代码不会打包到浏览器。
  • 客户端组件需要标记"use client",在浏览器里运行,负责交互逻辑。

这个边界对类型安全很重要。t3code 里的 tRPC 在服务端可以直接调用,在客户端则需要走一个专门的 hooks 封装。你要做的,是清晰地区分"哪些逻辑必须在服务端处理"(比如直接查库、处理密钥相关的操作)和"哪些逻辑必须交给浏览器"(比如点击事件、表单状态)。我在刚上手时经常犯的错是:把所有组件都写成客户端组件,导致服务端该干的事跑到浏览器里去干,类型倒是没崩,性能和安全性先崩了。

2.2 tRPC 是"无文档的契约"

tRPC 的特别之处在于,它不需要你写接口文档,也不需要你手写类型定义。你在服务端定义了一个 router:

// server/api/routers/post.ts import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional(), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: input }); }), });

前端调用时,类型会被自动推断出来,连输入参数的校验规则都是同一份:

// app/_components/post-list.tsx "use client"; import { api } from "~/trpc/react"; export function PostList() { const { data: posts, refetch } = api.post.list.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), }); return ( <div> {posts?.map((post) => ( <div key={post.id}> <h3>{post.title}</h3> {post.content && <p>{post.content}</p>} </div> ))} <button onClick={() => createPost.mutate({ title: "新文章", content: "内容" }) } > 新建 </button> </div> ); }

注意,这里的api.post.list.useQuery()不是普通的函数,它背后是 React Query 的 hooks。也就是说,缓存、重试、请求状态管理这些逻辑,也被 tRPC 的 React 封装一并处理了,不需要你再单独引入一套数据请求方案。

2.3 Prisma 是类型的源头

在 t3code 里,数据库表结构由 Prisma 的 schema 定义。你只要维护一个schema.prisma文件,Prisma 就能生成对应的 TypeScript 类型和 Client。这意味着,数据库字段变成代码里的类型,这个环节是自动的,不存在"数据库改了个字段,代码里忘了同步"的问题。

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Post { id String @id @default(cuid()) title String content String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

执行npx prisma migrate dev之后,ctx.db.post.findMany()里返回的post对象就是带类型的。这个类型再被 tRPC 的返回值携带,一路传到前端组件,整个链路就通了。我在实际使用中最喜欢的细节是:改 schema 之后只要重新生成 Prisma Client,前端代码马上会报出所有"字段名对不上"的地方,这种安全感是传统接口联调给不了的。

2.4 Tailwind 是样式层,但要配合组件规范

Tailwind 在 t3code 里承担的是样式职责。它本身和上面的类型链路关系不大,但它决定了样板工程的 UI 长什么样。我是这么看 Tailwind 的:它可以让你不写 CSS 文件就完成大部分布局,但如果完全靠 utility class 堆页面,后期维护会更吃力。

在 t3code 里我会建议搭配一个组件库(比如 shadcn/ui)来收敛样式原子。Tailwind 负责基础原子类,组件库负责提供结构化的组件,业务页面只关心数据和布局,不用每次从零拼按钮和输入框。这个组合用起来比较顺手,也不容易出现"改一个按钮样式要翻十几个文件"的窘境。

2.5 一张图看完整链路

如果把它们的关系简化为一条数据流,大概是这样的:

Prisma Schema → 数据库表 + TypeScript 类型 ↓ tRPC Router → 输入校验 + 业务逻辑 + 类型导出 ↓ React Query Hooks → 状态管理 + 缓存 ↓ React 组件 → 渲染与交互

这条链路的每一个环节,类型的"承重墙"都是自动生成的,人只负责写业务逻辑。理解了这点,后面排查问题时会非常有用——当你看到一个类型报错,你要判断的其实是它发生在哪一层:数据库层、接口层、还是组件层。

3. t3code 实战落地:从数据库到页面的类型闭环

理论讲完,接下来把袖子撸起来。我用一个真实的例子——从初始化项目到实现一个带数据库写入和列表展示的完整功能——带你跑一遍 t3code 的落地过程。

3.1 初始化项目:交互式命令行里有哪些选项

先说明一下,如果你要手动复刻 t3code 的结构,最直接的方式是用官方脚手架生成一个同构项目:

npx create-t3-app@latest my-app

执行命令后,交互式界面会问你要不要启用 Tailwind、tRPC、Prisma、NextAuth 等模块。我的建议是:

  • 如果只是学类型闭环,Prisma 和 tRPC 必须勾上。
  • NextAuth 可以先不勾,它处理的是鉴权逻辑,第一次上手时容易把注意力从类型链路上带偏。
  • Tailwind 建议勾上,即使你不用它,后续调整界面也方便。

脚手架生成完之后,目录结构大致是这样的:

prisma/ schema.prisma src/ server/ api/ root.ts # 汇总所有子 router routers/ post.ts # 业务 router trpc.ts # tRPC 实例与上下文 db.ts # Prisma Client 单例 trpc/ react.tsx # 客户端 tRPC provider 与 hooks server.ts # 服务端 tRPC caller(可选) app/ api/trpc/[trpc]/ route.ts # tRPC 的 HTTP 入口 _components/ post-list.tsx page.tsx

这个结构本身是 t3code 的核心价值——它把所有样板代码和配置文件都摆好了,你不用从空白开始思考"tRPC 挂在哪个文件、Prisma Client 怎么全局共享"这类问题。

3.2 配置数据库:环境变量与连接串的讲究

默认情况下,脚手架会用 SQLite 起步,但我建议你直接换成 PostgreSQL 或者 MySQL,因为生产环境基本不会用 SQLite。

修改prisma/schema.prisma:

datasource db { provider = "postgresql" url = env("DATABASE_URL") }

然后在.env里配置:

DATABASE_URL="postgresql://user:password@localhost:5432/t3code"

这里有一个很容易踩的坑:不要把包含敏感信息的 .env 文件提交到 Git。脚手架生成的.gitignore通常已经帮你把.env排除掉了,但如果你是自己手动搭的环境,一定要检查确认。数据库连接串相当于数据库的钥匙,泄露出去就是一个严重的安全事故。

配置好之后,在 schema 里加上最简单的 Post 模型,然后执行迁移:

npx prisma migrate dev --name init

这个命令会把变更同步到数据库,同时生成 Prisma Client。如果没有任何报错,你打开src/server/db.ts,里面应该是这样的:

import { PrismaClient } from "@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; }

这个"全局单例"的模式很关键。它确保在开发环境的模块热更新(HMR)过程中,不会因为每次文件变更都创建新的 Prisma Client 而导致连接数暴增。不写这个单例,开发到一半你可能会看到数据库连接被占满的警告。

3.3 写第一个 tRPC 接口:从 router 到页面

接下来,定义一个 post router,并在根 router 里注册:

// src/server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "~/server/api/routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;

回到之前那个例子——我们已经有list和create两个 procedure 了。现在重点是理解前端如何调用。

在服务的入口处,t3code 已经把 HTTP 路由挂好了(src/app/api/trpc/[trpc]/route.ts),所以前端不需要关心请求路径。你只需要在客户端组件里使用api对象:

"use client"; import { api } from "~/trpc/react"; export function PostList() { const utils = api.useUtils(); const { data: posts, isLoading } = api.post.list.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), }); if (isLoading) return <div>Loading...</div>; return ( <div> {posts?.map((post) => ( <div key={post.id}>{post.title}</div> ))} <button onClick={() => createPost.mutate({ title: "Hello t3code" })} > 新增 </button> </div> ); }

这里你可能注意到,我没有用refetch,而是用了utils.post.list.invalidate()。这是 React Query 比较推荐的做法:让缓存失效后自动重新拉取,而不是手动触发请求。在 t3code 里,React Query 的缓存策略可以按业务场景定制,比如设置staleTime减少不必要的请求。

3.4 类型是怎么"流动"的,以及为什么你不会漏改字段

很多人第一次看 t3code 会有个疑问:前端代码明明没有导入任何后端类型,为什么posts是带类型的?答案在于 tRPC 的泛型推导。

api.post.list.useQuery()的api对象是createTRPCReact<AppRouter>()生成的,而AppRouter又是typeof appRouter。所以,TypeScript 会把appRouter里所有 router 的输入输出类型全部推导出来,并注入到前端的 hooks 类型参数中。这相当于一个"全项目的类型地图",前端不需要手动 import,编辑器自动补全就能看到返回字段。

这套机制带来的实际效果是:如果后端list返回值从{ title: string }变成了{ title: string; content: string },前端除非显式解构字段,否则不会报错;但如果后端改了字段名(比如title改成name),前端所有用到post.title的地方会立即标红。这种"改错名十分钟内发现"的体验,在传统接口联调里几乎不敢想。

4. 生产环境最容易翻车的坑,和完整的排查链路

t3code 这套架构本地跑得很顺,但一到生产环境,还是有几个高频问题,我一个个说清楚,包括排查思路和解决方案。

4.1 数据库连接被边缘函数打满:Prisma 连接池配置

如果你把 Next.js 部署到 Vercel 这类边缘/Serverless 环境,会遇到一个特别典型的坑:Prisma Client 默认创建的连接池和 Serverless 按需启动的短生命周期实例不匹配。

现象是:请求量稍微上来之后,数据库报错P1001连接超时,或者后台看到大量闲置连接。原因是每个 Serverless 实例都会创建自己的 Prisma Client,实例销毁时连接池来不及释放,积少成多。

排查链路:

  1. 先看日志是不是PrismaClientInitializationError或者连接数告警。
  2. 确认是否每个 function 实例都持有了数据库连接。
  3. 解决方向有两个:一个是在数据库里把直接连接地址和连接池地址分开,一个是用外置连接池。

在我的 t3code 仓库里,推荐的做法是在.env里区分两个变量:

DATABASE_URL="postgresql://user:password@host:5432/db?pgbouncer=true&connection_limit=1" DIRECT_URL="postgresql://user:password@host:5432/db"

然后在 schema 里声明:

datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }

DATABASE_URL走连接池,适合 Prisma Client 在 Serverless 环境里频繁创建连接;DIRECT_URL走直连,适合执行迁移命令时使用。迁移的时候不需要连接池,直连更可靠。

4.2 tRPC 类型不同步:两端的"版本断裂"

t3code 的强项是类型穿透,但如果你把前后端拆成两个仓库部署,或者一个仓库里前后端依赖版本不一致,就会出现"后端新加了一个 procedure,前端类型却没更新"的情况。这种断裂在 monorepo 结构里尤其常见。

典型报错是:前端调用api.xxx.yyy时,TypeScript 提示Property 'yyy' does not exist on type ...。

排查链路:

  1. 确认是否运行过npm install,确保@trpc/server和@trpc/react-query版本一致。
  2. 确认src/server/api/root.ts里新 router 有没有正确注册。
  3. 清掉 TypeScript 的增量缓存:npx tsc --noEmit如果仍报错,再看编辑器里的 TypeScript 服务是否旧缓存,重启 TS Server。
  4. 如果你用了 monorepo,检查子包之间的依赖是否指向了旧构建产物,必要时重新 build。

这类问题的根源基本不在代码,而在"类型生成层"没有同步。我建议把prisma generate和类型检查放进 CI 流程,任何一步没跑过都不允许合并。

4.3 App Router 的"use client"边界:错误使用带来的混乱

App Router 出现之后,"哪些组件能直接用 tRPC hooks"成了新问题。tRPC 的 React hooks 依赖客户端状态(React Query 的 Provider),所以只能在客户端组件里调用。但是很多新手会把整个页面都标成"use client",导致服务端组件完全失效,首屏渲染变成浏览器渲染,性能明显下滑。

正确做法是:页面外壳保持服务端组件,只有需要交互的叶子组件(按钮、表单、列表容器)单独标记"use client"。

比如在 t3code 里,一个典型的服务端页面骨架:

// app/page.tsx(服务端组件) import { PostList } from "./_components/post-list"; export default function HomePage() { return ( <main> <h1>文章列表</h1> <PostList /> </main> ); }

而PostList内部才是一个客户端组件。这样做的好处是:页面首屏可以静默渲染,只有列表区域在客户端进行交互。如果你发现自己把"use client"写到了页面最顶层,就要停下来想想,是不是所有代码都真的需要运行在浏览器上。

排查思路:用 React DevTools 看组件是否被正确区分为 Server/Client;用构建产物检查浏览器 bundle 的大小,如果服务端代码被打进去了,边界就出问题了。

4.4 Zod 校验失败时的模糊报错

tRPC 的输入校验依赖 Zod。当你传的参数不合法时,报错信息有时会比较"原始",尤其是嵌套对象里的字段名错误,容易看得一头雾水。

我踩过的一个典型例子:

createPost.mutate({ title: "" });

z.string().min(1)会抛错,但如果你没有配置全局错误处理或者前端显示逻辑,用户只会看到一个笼统的错误。解决办法是在客户端统一做错误映射:

const createPost = api.post.create.useMutation({ onError: (err) => { const message = err.shape?.message ?? "请求失败"; // 在这里把 message 展示给用户 }, });

生产环境里,我建议前端对用户输入先做一层 UI 校验(比如禁用按钮、提示必填),Zod 校验作为后端兜底。这样既保证用户体验流畅,也不会依赖后端错误提示的文案质量。

5. 部署收尾:连接池、边缘函数与构建配置

最后聊聊把 t3code 从开发机推到生产环境时,我在部署层面整理的几条经验。

5.1 数据库连接方案:按部署环境区分

前面已经提到连接池的问题,这里再细化一下。在生产环境,我通常给数据库配置两个连接地址:

  • DATABASE_URL:给应用运行时使用,走连接池(比如通过外部连接池服务或者数据库自带的连接池端口)。
  • DIRECT_URL:给prisma migrate deploy这类迁移命令使用,直连更稳定。

如果是 PostgreSQL,还建议设置合理的connection_limit。在 Serverless 场景下,单个实例的并发请求数通常很小,connection_limit=1是合理的起点。不要把它设得太大,否则实例一多,连接数会迅速膨胀。

5.2 构建阶段的类型检查不要跳过

t3code 的脚手架在构建时会默认跑tsc --noEmit和next build。很多人嫌慢,会在 CI 里把类型检查关掉,这是个典型隐患。类型安全是这个架构的地基,如果构建阶段不检查类型,等于把自动推导的优势白白扔掉了。

我的建议是:

  • 本地开发:npm run dev可以跳过完整类型检查,保证热更新速度。
  • CI/生产构建:必须跑npm run build,它会执行完整类型检查。
  • 首次启动慢一点没关系,换来的是"类型断裂不会悄悄溜进生产"。

5.3 边缘运行时(Edge Runtime)和 Node 运行时的选择

Next.js 页面可以配置为 Edge Runtime 或 Node.js Runtime。tRPC 和 Prisma 有一些生态兼容性差异,尤其是 Prisma,它依赖 Node 的部分原生能力,和 Edge Runtime 的兼容性并不总是理想。

我的经验是:如果用了 Prisma,就老老实实用 Node.js Runtime。Edge Runtime 的优势在于全球低延迟响应,但前提是你的数据层也要跟着驻留边缘,否则请求最终还是得回源到数据库所在区域,优化效果非常有限。在 t3code 里,我会让 API 路由和页面都使用默认的 Node.js Runtime,优先保证稳定性和功能完整。

5.4 环境变量的三个"必须"

部署到托管平台(不管 Vercel、Netlify 还是自建 Node 服务),环境变量都要单独检查一遍,漏掉任何一个都会在运行时出问题。我的检查清单是:

  • DATABASE_URL和DIRECT_URL是否正确指向生产数据库。
  • NODE_ENV是否自动设置为production(大多数平台会自动处理)。
  • 是否有任何第三方服务的密钥、公开地址被硬编码进代码里。

另外,有一点要特别提醒:前端的构建产物里不要包含任何服务端密钥。Next.js 里只有以NEXT_PUBLIC_开头的环境变量才会被打进浏览器 bundle,其他变量只在服务器端可见。在 t3code 里,数据库连接串永远不要以NEXT_PUBLIC_开头,否则等于把数据库钥匙挂在门口。

最后聊几句实际使用感受

如果你现在正在纠结要不要仿照 t3code 的方式搭业务,我的个人建议是:先在一个小项目上试水,而不要一上来就全量迁移。t3code 这套架构的好处是类型安全和开发体验,代价是你需要习惯"所有东西都要先定义类型"的思路。有些同学刚开始会觉得麻烦,但跑通第一个功能后,大多数人回不去手写接口类型的日子。

小技巧放最后:开发时如果你发现编辑器卡顿或者类型提示不更新,先试试重启 TypeScript 服务;如果问题还在,再清node_modules/.cache。这类问题八成不是代码错误,而是 TypeScript 的增量缓存和 tRPC 的复杂泛型在较老的 IDE 版本上闹脾气。换新版本或者升级依赖后,通常就好了。

RELATED READING

延伸阅读

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