ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

全栈TypeScript实战:T3技术栈与tRPC类型共享的完整落地指南

全栈TypeScript实战:T3技术栈与tRPC类型共享的完整落地指南

t3code 这个名字是我给一个全栈工程随手起的代号。T 在这里指 TypeScript、Tailwind 和 tRPC,code 则是这份代码仓库本身——整个项目从数据库表结构到页面上的按钮,全部跑在同一套类型体系里。最初做这个项目的动机很直接:我想验证一个经常听人说的观点——全栈 TypeScript 可以让前后端共享类型,从而省掉接口文档和一大部分联调成本。t3code 就是这个验证的产物,一个带登录、数据增删改查和标签过滤的灵感收集工具。

如果你正在学 T3 技术栈,或者在一个传统前后端分离项目里受够了接口对齐的折磨,这篇内容应该能让你少走几段弯路。下面所有流程都是我在实际搭建过程中一步步踩过来的,包括那些只在真实项目里才会遇到的报错和诡异行为。我会尽量把关键步骤说得具体,让即使没碰过 tRPC 的人也能照着把项目跑起来。

1. t3code 是什么:一个全栈 TypeScript 实战工程

1.1 从名字说起,为什么叫 t3code

T3 是社区里对一套 TypeScript 技术栈的简称,严格说包含三个都从 T 开头的技术:TypeScript、Tailwind CSS、tRPC。这三样东西表面上分工不同——语言、样式、接口调用,但它们的内核是同一个:让开发者少写重复代码、少在类型之间来回转换。Tailwind 负责样式,tRPC 负责前端像调用本地函数一样调用后端逻辑,TypeScript 负责把所有类型串起来。

我给仓库起名 t3code 的时候,项目官方脚手架叫什么不重要,重要的是这个代号覆盖了整套组合:T3 技术栈加上实际运行时的代码本身。随着项目往深处做,你会发现这个命名其实是准确的——当 tRPC 把后端路由变成了前端可导入的函数,代码仓库里就只有一份真正意义上的“接口定义”,那就是 TypeScript 类型。

1.2 这个项目要解决什么问题

当时我手头有个实际需求:把散落在聊天记录和备忘录里的灵感统一收集到一个地方,能打标签、能筛选、能控制公开和私有。听起来功能不复杂,但按传统思路做,要先拆成两个项目,一份写接口文档,一份做 Mock 数据,前后端再对字段类型,光联调可能就得耗掉两天。

t3code 换了一种方式:数据库模型用 Prisma 定义,tRPC 直接基于 Prisma 的类型生成路由,前端组件再通过 tRPC 客户端调用这些路由。这个链条里每一步的类型都是自动推导出来的。我在 Prisma 里给字段加一个约束,前端组件里的类型立刻跟着变;我在 tRPC 路由里改一个返回结构,调用方代码里马上出现编译错误。类型本身就是契约,接口文档变成了可选的补充。

这个项目能帮你解决的问题很明确:如果你正在评估全栈 TypeScript 方案是否适合团队,或者你想亲手体验一次“从数据库到按钮”的完整闭环,t3code 可以作为一套可运行的样板。它不是生产级框架,但覆盖了绝大多数项目中反复出现的核心模式:认证、增删改查、权限控制、列表刷新。

1.3 谁适合参考这个项目

写了一些 React 但还没独立搭过全栈应用的前端开发者,可以拿 t3code 当跳板。你不需要先精通 Next.js 再开始,跟着步骤跑起来,重点观察类型是怎么从后端流到前端的。如果你已经在维护一个传统的前后端分离项目,想给团队引入更省事的协作方式,这个项目的代码风格和目录结构也值得参考。

当然有一点需要提前说明:零基础直接上手会有点吃力。至少得知道 React 组件、useState、TypeScript 基本类型这些概念,否则很容易被“在客户端调用服务端函数”这种思维转换卡住。我建议先把 React 和 TS 基础过一遍,再来看 t3code 的代码,理解成本会低很多。

2. 技术选型与项目骨架

2.1 为什么选 T3 而不是自定义 Next.js 项目

有人可能会问:直接用 create-next-app 搭一个 Next.js 项目,再自己接 Prisma 和 tRPC,不也一样吗?能一样,但没必要。create-t3-app 这个脚手架的价值在于把默认配置和最佳实践提前绑好了。你不需要研究 tRPC 的 context 怎么组织、Prisma 实例怎么保持单例、NextAuth 的 session 怎么注入 tRPC,它默认生成的样板代码已经把链条打通。

我当时选择 T3 还有一个更实际的考虑:脚手架自带的路由拆分方式很清晰。src/server/api/routers 下面是按业务域拆分的路由文件,src/server/api/trpc.ts 统一管理 context 和 middleware,前端只需要在服务端组件里调 createCaller,或者在客户端组件里用 api.xxx.useQuery。这套约定是社区反复验证过的,比自己脑补一套目录结构要可靠得多。

用 T3 也避开了最常见的“配置地狱”。路径别名、ESLint 规则、Next.js 的 App Router 配置、Tailwind 的 content 配置,脚手架初始化时全都处理好了。我不用在项目一开始就为 tsconfig 里的 paths 字段头疼,也不用担心 next.config 漏了什么。

2.2 t3code 的目录结构与职责

脚手架生成后,t3code 的目录大致是这样:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── _components/ │ │ ├── api/auth/[...nextauth]/route.ts │ │ ├── ideas/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ │ └── globals.css │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ └── env.mjs ├── .env ├── next.config.mjs ├── tailwind.config.ts └── tsconfig.json

重点理解这几个位置。prisma/schema.prisma 是数据模型唯一真源;src/server/api/trpc.ts 负责创建 tRPC 实例、定义公共和受保护的 procedure;src/server/api/routers 里放业务路由;src/trpc/react.tsx 是前端 React Query 的封装,客户端组件从这里导出 api 对象使用。

这个结构最大的好处是单向依赖。页面组件只依赖 routers 暴露的接口,routers 只依赖 Prisma 和 context,谁都不会反向去碰别人。项目变大之后,想替换某个数据源或者给某个流程加中间层,改动范围是可控的。

2.3 t3code 的功能清单与数据模型

t3code 最终实现的功能不算多,但足以覆盖典型的业务闭环:

  • 通过 GitHub OAuth 登录,使用 NextAuth 管理会话
  • 创建灵感条目,包含标题、描述、标签、公开状态
  • 编辑和删除自己创建的灵感
  • 首页展示最新公开灵感,我的页面只展示当前用户的内容
  • 按标签筛选列表

数据模型围绕用户和灵感两张表展开。User 表由 NextAuth 默认逻辑管理,Idea 表记录内容字段和作者外键。因为 tags 需要多值,我直接用了 Postgres 的数组类型,这也是后面踩坑最多的地方之一。如果用 SQLite,String[] 这个写法根本跑不起来,所以我从一开始就把数据库定为 PostgreSQL。

3. 从零到可用:t3code 的搭建过程

3.1 脚手架初始化与关键选项

初始化命令很简单:

npm create t3-app@latest t3code

交互式选项里我按这个思路勾选:Next.js 选 App Router,TypeScript 必选,Tailwind CSS 选上,tRPC 和 Prisma 都选,NextAuth 选上。ESLint 和 Prettier 建议都开,少一个都会让多人协作时格式冲突。src 目录和路径别名按默认来,不需要额外改动。

跑完初始化之后,脚手架会生成一个自带 Post 示例的可运行项目。这个示例存在的意义是验证整套链路通畅:页面能渲染、Prisma 能连库、tRPC 路由能调用。我建议不要急着删,先在本地把 npm run dev 跑起来,看到示例页面再动手改造,这样后续出了问题能区分是环境问题还是代码问题。

初始化完成后别忘了装依赖:

cd t3code npm install

然后去 .env 里填 DATABASE_URL。如果你本地还没装 Postgres,直接用一个远程实例也行。我第一次用本地 Postgres 的时候,启动服务后连库没问题,但 schema 里有数组字段,必须在迁移前确认数据库版本支持 Postgres 12 及以上的数组操作。

3.2 Prisma 数据模型设计

我在 prisma/schema.prisma 里把示例 Post 模型改成 Idea 模型,核心代码如下:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] ideas Idea[] } model Idea { id String @id @default(cuid()) title String @db.VarChar(80) description String @db.VarChar(1000) tags String[] isPublic Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) }

这里有几个细节值得多说一句。title 和 description 用 VarChar 并限制长度,是为了在数据库层面就挡住异常数据,前端再配合 zod 校验,形成双重保障。tags 用 String[] 是因为 Postgres 原生支持数组类型,查询时可以用 any 操作符做包含判断,比关系表简单得多。isPublic 默认 false,保证用户创建的灵感默认只有自己可见,避免隐私数据意外泄露。

作者外键上用了 onDelete: Cascade,用户注销时他创建的灵感会一并删除。这块如果业务上需要保留匿名数据,建议改成 SetNull,但那样的话 authorId 要做成可空,前端取数据时也要处理 author 为空的情况。t3code 的场景里用户内容跟着账号走比较合理,所以选了级联删除。

改完 schema 后执行一次迁移:

npx prisma migrate dev --name init_idea

这条命令会生成迁移文件并同步数据库。如果报连接错误,先检查 DATABASE_URL 是否正确,再确认 Postgres 允许 TCP 连接,最后再看是不是 SSL 参数问题。这些我后面在踩坑部分细说。

3.3 tRPC 路由与受保护接口

tRPC 是 t3code 里最核心的一层。脚手架生成的 src/server/api/trpc.ts 默认已经处理了 context、superjson 和错误格式化,我要做的是在 trpc.ts 里定义一个受保护的 procedure:

const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

这段逻辑的核心是把鉴权集中到 middleware 里。任何需要登录才能调用的接口,只要用 protectedProcedure 定义,用户没带有效 session 就会被拦截,根本走不到业务代码。我写 create 和 delete 的时候只需一句:

export const ideaRouter = createTRPCRouter({ getAll: publicProcedure .input(z.object({ tag: z.string().optional() })) .query(async ({ ctx }) => { return ctx.db.idea.findMany({ where: { isPublic: true, ...(input.tag ? { tags: { has: input.tag } } : {}), }, orderBy: { createdAt: "desc" }, take: 30, }); }), create: protectedProcedure .input( z.object({ title: z.string().trim().min(1, "标题不能为空").max(80), description: z.string().trim().min(1).max(1000), tags: z.array(z.string().max(30)).max(8).default([]), isPublic: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.idea.create({ data: { title: input.title, description: input.description, tags: input.tags, isPublic: input.isPublic, authorId: ctx.session.user.id, }, }); }), });

publicProcedure 用于公开列表,游客也能看。create 用 protectedProcedure,确保只有登录用户能创建。zod 的输入校验放在路由层而不是只靠前端表单,意义在于任何绕过前端直接调接口的调用,都会被这里挡住。title 和 description 的 trim 只去掉首尾空格,不会改动正文内部,避免用户手滑多打空格造成空标题。

getAll 里的 take: 30 是粗暴的限流,避免一次性拉太多数据。真实项目里这个位置应该换成 cursor 分页,后面优化部分我会展开说。tags 用 has 操作符,是 Prisma 针对 Postgres 数组字段的过滤语法,等价于 SQL 里的数组包含判断。

3.4 前端组件如何调用接口

t3code 的前端页面分两类:服务端组件直接通过 tRPC server 调用,客户端组件走 React Query。以创建灵感的表单为例,它必须标注为客户端组件,因为要用到表单状态和 mutation:

"use client"; import { api } from "~/trpc/react"; export function NewIdeaForm() { const utils = api.useUtils(); const createIdea = api.idea.create.useMutation({ onSuccess: async () => { await utils.idea.getAll.invalidate(); }, }); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); return ( <form onSubmit={(e) => { e.preventDefault(); createIdea.mutate({ title, description, tags: [], isPublic: true }); }} > {/* 表单控件 */} </form> ); }

这里的 api.idea.create.useMutation() 会自己管理请求状态:pending、error、success 都有对应字段。最有用的是 onSuccess 里调 utils.idea.getAll.invalidate(),它会自动让列表重新拉取。我不用手动刷新页面,也不用写事件总线通知列表组件,React Query 的缓存体系帮我搞定了数据同步。

如果页面是服务端组件,可以改成调用 createCaller:

import { api } from "~/trpc/server"; const ideas = await api.idea.getAll({ tag: undefined });

createCaller 在服务端直接执行函数体,不走 HTTP,首屏渲染少了一次网络请求。这个组合非常好用:服务端组件负责首屏数据,客户端组件负责交互,两边用的是同一套路由和同一套类型。

3.5 从表单提交到数据库写入的完整链路

把前面几段串起来看,用户在表单里输入标题并点击提交之后,完整流程是这样的:

  1. 浏览器里的 React 表单收集输入
  2. useMutation 被触发,把数据发给 Next.js 内置的 tRPC HTTP 端点
  3. tRPC 路由先解析输入,zod 校验失败会直接返回 400 并且带上字段错误
  4. 校验通过后,middleware 检查 session,没登录就抛 UNAUTHORIZED
  5. mutation 执行 db.idea.create,Prisma 把数据写入 Postgres
  6. 响应返回给前端,useMutation 的 onSuccess 触发
  7. invalidate 让列表重新请求,React Query 拿新数据刷新 UI

整个链条里没有手写任何一个 HTTP 路由,没有定义过一份接口文档,也没有在前后端各自维护一份类型。Prisma 的模型、tRPC 的返回类型、前端组件的 props,由编译器保证一致。我第一次跑通这个流程时印象很深:以前写接口要花大量时间对齐字段名和类型,现在改一个字段,编译器的报错会一路指到必须改的地方。

4. 踩坑记录:常见问题与排查实录

4.1 Prisma 迁移和数据库连接坑

第一个坑出现在我第一次执行 migrate。本地 Postgres 连不上,日志显示连接超时。检查后发现是我在 DATABASE_URL 里漏写了端口,Postgres 默认是 5432,我填成了 5433。这个问题的排查方式很朴素:先尝试数据库客户端能否连上,再用 Prisma 的日志确认连接串解析后的目标地址。

还有一个更隐蔽的坑:Prisma 的 migrate 命令需要直连数据库,而运行时用连接池地址更合适。如果你用的是 Neon 这类托管 Postgres,你会在后台看到两个连接地址,一个带连接池,一个不带。连接池地址用于运行时请求,直连地址用于迁移。如果环境变量里只放了连接池地址,migrate 可能反复报错,部署阶段也在这一步卡住。我当时在 .env 里分成 DATABASE_URL 和 DIRECT_URL 两个变量,migrate 时明确用直连地址,问题才彻底解决。

4.2 tRPC 类型没有更新

tRPC 的类型推导依赖 TS 编译器对路由的引用解析。我遇到的情况是:给 ideaRouter 加了一个新的 query,结果前端 api.idea.newQueryName 一直报不存在。我反复检查代码,路由肯定加了,导出的 root.ts 也引入正确,最后发现是 .next 目录里的缓存没刷新。

这种问题的处理方式不算复杂,但容易让人慌。先停掉 dev server,删掉 .next 目录重新跑:

rm -rf .next npm run dev

如果你用的是 VS Code,可能还需要重启 TS Server:命令面板里选 TypeScript: Restart TS Server。这是 tRPC 最典型的体验问题,本质是类型生成依赖 dev server 的模块缓存。等你用久了会发现,凡是“代码明明改了但类型没变”的报错,第一反应先清缓存,比检查代码更高效。

4.3 NextAuth 在服务端取不到 session

t3code 的登录流程正常情况下不会出问题,但我把 User 模型改成带 ideas 关系之后,出现过 session 里 user.id 取不到值。原因是 NextAuth 的 session 回调默认只保存 name、email、image 这些基础字段,id 需要手动扩展。

t3 脚手架生成的 auth.ts 里有 session 回调的处理逻辑:

session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id, }, }),

如果某次调整模型或手误把这段覆盖掉了,受保护接口的 ctx.session.user.id 就会变成 undefined,Prisma 写入外键时会直接报错。另外有个非常隐蔽的坑:NEXTAUTH_SECRET 没有配置时,preview 环境登录可能时好时坏,刷新页面后 session 消失。生产部署一定要显式设置 NEXTAUTH_SECRET,不能依赖默认值。

4.4 部署阶段的环境变量那点事

部署到 Vercel 时,环境变量数量和本地不一样。除了 DATABASE_URL,还需要 NEXTAUTH_URL、NEXTAUTH_SECRET,以及 OAuth 应用的 Client ID 和 Client Secret。我在本地用 localhost 配置过的 GitHub OAuth 应用,放到生产环境后回调地址完全不同,忘记改 OAuth 回调就会一直登录失败。

DNS 解析和 HTTPS 证书由 Vercel 处理,这个问题不大。真正的坑是 migrate 时机。构建和部署时,Prisma migrate dev 不能执行,因为 migrate dev 会尝试和 schema 历史交互,还可能弹出交互式提示。生产环境应该用:

npx prisma migrate deploy

migrate deploy 会按照迁移文件按序执行,适合自动化部署流程。你可以把它写进 Vercel 的 build 命令或 predeploy 钩子里,也可以单独跑一次,保证数据库结构先于应用代码更新。还有一个很多人忽略的点:线上环境如果 schema 改了但忘了生成 client,运行时会提示 Prisma Client 版本和 schema 不匹配。部署脚本里建议在 migrate deploy 之后执行 prisma generate。

4.5 数组字段与数据库方言的限制

tags 字段用 String[] 在 Postgres 上没有任何问题,但如果想在 CI 或本地用 SQLite 快速跑测试,这一行直接会让 migration 失败。我刚开始准备了一个 SQLite 分支来加速开发,结果 prisma migrate dev 报错不支持数组类型,只能重新换回 Postgres。

解决方案有两个。一是坚持用 Postgres,本地用 Docker 起一个临时实例。二是改用逗号分隔的字符串字段,存的时候拼接,取出时拆分,牺牲一部分查询能力。t3code 选了前者,因为标签过滤用数组的 has 操作符写起来太方便了。如果你要写单元测试,可以考虑跑一个 Postgres 测试容器,而不是依赖 SQLite 的内存模式。

5. 性能优化与后续扩展

5.1 查询与缓存优化

t3code 跑通后,我做的第一轮优化是给列表加 select 字段裁剪。数据库里 description 可能很长,但列表页只需要标题、标签、发布时间,详情页才需要全文。Prisma 的 findMany 支持 select 选择字段,少取一个大数据字段,传输量和 JSON 解析时间都能降下来。

接着是分页。前面用 take: 30 硬编码限制了条数,条目多了之后只能看到最早或最新的 30 条。改成 cursor 分页的标准做法是让客户端传一个 lastCursor,服务端用 skip: 1 加 cursor 进行定位:

input: z.object({ cursor: z.string().optional(), tag: z.string().optional(), }), query: async ({ ctx, input }) => { return ctx.db.idea.findMany({ where: { isPublic: true, ...(input.tag ? { tags: { has: input.tag } } : {}), }, orderBy: { createdAt: "desc" }, take: 30, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, }); },

cursor 分页比 offset 分页的优势在后端:offset 越往后查询越慢,cursor 基于索引定位,深翻页性能损失小得多。前端的 React Query 可以配合 useInfiniteQuery 的 getNextPageParam 自动加载下一页。

最后给查询设置合理的 staleTime。列表数据不是实时性要求很高的场景,可以把 staleTime 设为 30 秒甚至 60 秒,减少用户来回切换页面时的重复请求。这个配置在 create-t3-app 生成的 api 实例里可以直接设置:

queryClient: new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 } }, });

别小看这个值,团队小项目里省掉的大量无效请求都来自这个字段的默认值 0。staleTime 设为 0 意味着每次组件挂载都会发请求,实际上很多数据根本不需要这么新鲜。

5.2 从 t3code 继续长成更复杂的项目

t3code 做完整套闭环之后,再往复杂方向扩展有两条很自然的路径。

第一是接入全文搜索。目前 tags 用数组过滤已经能支撑标签场景,但标题和描述的模糊搜索用 SQL Like 在数据量上来之后性能会很差。Postgres 自带全文索引,配合 pg_trgm 可以做模糊搜索加速;如果数据量继续涨,再考虑 Meilisearch 或 Typesense 这类独立搜索引擎。tRPC 层只需要新增一个 search 路由,前端复用现有组件。

第二是抽象服务层。现在路由里直接写了 Prisma 调用,虽然方便,但业务逻辑多了之后路由文件会越来越重。可以把校验、权限、业务规则拆到 service 函数里,tRPC 路由只负责输入输出和调用 service。这个演进方式和传统后端里的 service 层思路一致,区别只是类型在前后端之间是共享的。

我个人在 t3code 项目上最深的体会是:全栈 TypeScript 真正的收益不在少写了多少行代码,而在类型系统把大量低级错误提前挡在了编译阶段。字段名写错、返回结构对不上、参数缺失,这些都是以前联调时来回扯皮的来源,现在写代码的过程中就能发现。如果你也想检验自己是否真正理解了这套技术栈的协作方式,我建议把手上某个已经跑通的小项目用 T3 重写一遍,重写时遇到的那些类型对不上的报错,会比任何教程都更能帮你建立全链路思维。

返回列表