☰
T3 Stack全栈实战:从TypeScript到tRPC的类型安全开发
2026/10/28 15:14:26 网站建设 项目流程

1. 从零认识t3code:这个全栈技术组合到底想解决什么问题

1.1 一个名字背后的技术拼图

先给新手把概念捋清楚。t3code这个名字,我第一次看到的时候也琢磨了一阵子。严格来说它不是一个框架,也不是一个编程语言,而是指用 T3 Stack 这套技术组合去落地真实项目时的工程化实践。T3 Stack 是近两年在全栈开发圈子里热度非常高的技术选型,核心成员包括:Next.js 做前端和 API 层,TypeScript 做全链路类型约束,Tailwind CSS 处理样式,tRPC 负责前后端接口调用,Prisma 操作数据库,NextAuth 处理认证。六个工具各司其职,组合起来之后,你写代码最直观的感受就是:从数据库表结构到前端组件,类型安全几乎是全程贯穿的。

这套组合最初由 create-t3-app 这个脚手架项目带火,作者 Theo 在社区里推广的核心理念是“类型安全是第一优先级”。我理解这句话的含义是:当你的项目足够大、参与的人足够多时,绝大部分 bug 其实都出在数据形状对不上——后端返回的字段写错了、前端拿到了 undefined、数据库里存进去的类型和接口定义不一致。T3 Stack 想做的就是把这些运行时才暴露的问题,尽量提前到编译阶段就暴露出来。

所以如果你把 t3code 拆开看,其实就是在说:用 T3 这套全栈技术来写真实项目代码。它既适合那些想在 Next.js 里做全栈开发的中级前端工程师,也适合小团队快速搭建一个类型安全的内部工具或产品原型。我自己是从传统前后端分离模式切过来的,最大的体会是:以前接口文档要维护两份,前端联调靠 Postman 拷来拷去,现在 tRPC 把类型直接从后端推到前端,接口文档这件事基本可以扔掉了。

1.2 为什么全栈框架这么多,偏偏要选这套组合

你可能会问:Next.js 自己就能写 API 路由,为什么还要再加一层 tRPC?Express 或者 Nest.js 做后端成熟稳定,为什么要在 Next.js 里搞全栈?这些问题我在刚开始接触 T3 的时候也反复琢磨过。

先说前端框架。Next.js 在 React 生态里的地位已经不用多说了,SSR、静态生成、文件路由、边缘函数,这些能力开箱即用。但它本质上还是以页面渲染为核心的框架,API 路由虽然能写后端逻辑,却没有任何类型上的魔法,你在前端 fetch 一个接口,返回值永远是一个 any。这时候 tRPC 的价值就出来了:它让你在 Next.js 的 API 路由上写服务端函数,然后通过 TypeScript 的类型推导,直接把函数的入参和出参类型“传递”到前端调用侧。前端调用后端就像调用一个本地异步函数,不需要生成 SDK、不需要手写类型定义、不需要维护 API 文档。

再说数据库层。Prisma 这个 ORM 工具在类型安全上的表现是通过 schema 文件定义模型,然后自动生成客户端代码,所有的查询和写入操作都有完整的类型提示。更关键的是,Prisma 和 tRPC 加在一起能形成一个从数据库到 UI 的类型闭环:数据库表结构由 schema 决定,Prisma 生成类型,tRPC router 里的输入输出引用这些类型,前端通过 tRPC hooks 拿到完全一致的类型。数据的流动路径是直的,类型像水流一样沿着管子贯穿到底,中间没有任何断点。

这套组合当然也有学习成本。TypeScript 泛型、tRPC 的 procedure 概念、Prisma 的 schema 约定,每一项都需要花点时间去熟悉。但一旦掌握之后,开发效率确实提升得很明显,尤其是做那种中小型全栈应用的时候,几乎不需要折腾复杂的工程化配置,一个脚手架就能跑起来一个五脏俱全的项目。

2. 环境准备与项目初始化:5分钟创建你的第一个 T3 全栈应用

2.1 动手前的完整环境清单与选择

先把需要的工具列一下,确保大家能一步到位:

工具版本要求作用
Node.js18.x 及以上运行时环境,T3 项目构建依赖
npm 或 pnpm新版即可包管理器,我建议用 pnpm,安装速度快、磁盘占用小
Git任意较新版本代码管理,非必须但强烈推荐
数据库本地 SQLite 或远程 PostgreSQL开发阶段用 SQLite 足够,生产环境建议 PostgreSQL
VS Code任意版本编辑器,需要装 ESLint 和 Tailwind 插件

Node.js 版本这块要注意,T3 项目对版本有硬性要求,太老的 16.x 版本会在安装依赖时报引擎错误。我遇到过同事用 nvm 切换版本导致 dev 服务起不来的情况,后来统一用项目根目录的.nvmrc文件锁定了 Node 18 LTS,团队里每个人都切到这个版本才消停。

数据库的选择上,新手阶段强烈建议直接用 SQLite。零配置、单文件、不需要单独启动数据库服务,对于学习和本地开发来说是最舒服的。等你真到了部署上线的阶段,再切换到 PostgreSQL 也不迟,Prisma 的 schema 迁移机制能让这个过程非常平滑。后面我会专门讲一下数据库切换时要注意的事项。

包管理器这里有一个经验之谈:如果你和团队协作,尽量统一使用同一个包管理器。T3 脚手架会自动根据你使用的工具生成本地锁文件,npm 生成 package-lock.json、pnpm 生成 pnpm-lock.yaml 和 .npmrc 配置。混用的话,依赖版本可能出现细微差异,这也是很多玄学问题的根源。

2.2 交互式配置:从命令行到运行起来的完整流程

创建项目非常简单,打开终端,进入你要存放项目的目录,执行:

npm create t3-app@latest

然后它会以交互式问答的形式问你一系列问题,下面是各个选项的解释和我自己的推荐:

  • 项目名称和安装目录:输入t3code,会自动创建一个名对应的文件夹。
  • TypeScript 还是 JavaScript:直接选 TypeScript。T3 整套方案的类型安全哲学就是建立在 TS 之上的,选 JS 等于自废武功。
  • 是否启用 ESLint / Prettier:全部选 Yes。代码规范这种东西,项目一开始就应该立好,后面再加标准大家习惯已经养成了很难改。
  • Next.js 的 App Router 还是 Pages Router:新项目直接选 App Router,这是 Next.js 官方现在主推的架构,客户端组件和服务端组件边界清晰,数据获取也被统一到 Server Components 模型下。
  • tRPC、Prisma、NextAuth、Tailwind 要不要一起装:我的建议是核心全选。既然选择 T3 这套组合,就要体验它完整的能力。尤其是 NextAuth,一开始就接好认证体系,后面做权限控制会省非常多的事。
  • 导入别名使用默认的 ~/路径**:直接确认即可。

配置完成后,进入项目目录启动开发服务:

cd t3code npm install npm run dev

终端输出Ready in xx ms之后,打开http://localhost:3000就能看到项目默认页面。

需要提醒的是,脚手架初始化过程中会自动执行一次prisma db push这类数据库同步动作,所以它会提前把 SQLite 文件生成好,整个过程基本不需要你额外操作数据库命令。

2.3 初始化后的目录结构解读:哪些文件是最重要的

跑起来之后,我们把核心目录结构过一遍。create-t3-app生成的项目虽然骨架简单,但每个关键文件存在的位置直接影响后续开发效率:

├── prisma/ │ └── schema.prisma # 数据模型定义,所有业务表的来源 ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc].ts # tRPC API 路由入口 │ │ └── _app.tsx # 全局 Provider 挂载(不跨页面端的不需要) │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ │ └── post.ts # 业务路由示例 │ │ ├── db.ts # Prisma 客户端实例 │ │ ├── auth.ts # NextAuth 配置(如果启用) │ │ └── api/trpc.ts # tRPC 根路由创建与上下文 │ ├── styles/ │ │ └── globals.css # 全局样式 │ └── env.js / env.mjs # 环境变量校验(基于 zod)

我最想强调的几个点:

src/server/api/trpc.ts是 tRPC 的核心入口,这里定义了 publicProcedure 和 protectedProcedure,以及 context 对象。context 里默认包含了db和session,也就是说你在任何 procedure 处理函数内部都可以直接读数据库、拿当前登录用户信息,不需要在路由里手动初始化 Prisma 或解析 session。

src/server/db.ts文件里的 Prisma 客户端是全局单例。这个文件看起来很简单,但背后隐藏了一个 Next.js 开发模式下热更新导致客户端实例重复创建的性能坑。脚手架已经帮你处理好了 globalThis 缓存的问题,新手不要轻易去改这块逻辑。

prisma/schema.prisma是数据库模型定义的唯一来源。你会看到脚手架预置了一个Post模型,展示用户发帖这个场景。后续开发新功能的所有表都从这个文件开始定义。

src/env.js用 zod 做环境变量的运行时校验,这个设计很优雅。如果部署时某个环境变量忘记填,应用启动时会直接报错告诉你哪个变量缺失,而不是等运行到某个接口才炸。

了解完这些,你就已经知道 t3code 这个项目的基本盘了。下面我们进入核心问题:tRPC 到底是怎么工作的。

3. tRPC 核心原理:为什么前端能像调用本地函数一样调用后端

3.1 tRPC 与 REST/GraphQL 的本质差异

对从传统后端开发过来的开发者来说,理解 tRPC 可能需要先打破一个惯性思维。

REST 的世界里,前端和后端的交流是通过 URL + HTTP 方法 + JSON 报文完成的。你要先定义接口、写文档、约定错误码,然后在后端处理请求,前端还得自己封装一套请求层。

图省事的做法是用 Postman 导出的代码片段,或者用 OpenAPI 生成类型定义(type-safe 版)。但不管怎么做,前后端之间的沟通成本始终存在,因为 URL 和 JSON 是“无类型”的,类型安全是人为添加的一层外套,不是语言层面的保证。

GraphQL 做了一部分改进,用 schema 定义 API 的形状,类型可以生成客户端 SDK。但 GraphQL 有自己的复杂度:解析器、缓存策略、resolver 拆分、n+1 问题,一个功能齐全的 GraphQL 服务的成本比 REST 高出不少。

tRPC 的思路完全不一样:它不定义接口文档,不生成 SDK,不做运行时协议。它直接利用 TypeScript 的泛型推导能力,让你在服务端定义的函数签名(参数类型和返回类型)被自动推送到前端。前端引入api对象后,调用一个后端函数就像调用一个 import 进来的异步函数一样。

// 前端组件里 const { data } = api.post.getAll.useQuery(); // 这个 data 的类型就是后端 getAll 函数返回的类型 // 而且是完全自动推导的,不需要 any,不需要手动声明接口

这段代码里没有 URL,没有字符串,没有 fetch。如果后端函数签名变了,比如返回对象里加了一个字段、删了一个字段,前端代码会立刻出现 TypeScript 编译错误。这就是 tRPC 最大的价值:当类型成为接口时,编译错误就是联调错误。

3.2 从 Router 到 Procedure:tRPC 的完整工作链路

上手 tRPC 需要理解几个概念:Router(路由集合)、Procedure(一个具体接口)、Middleware(中间件)、Context(上下文)。

一个 Router 就是若干 Procedure 的集合。打开脚手架预置的post.ts:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ hello: publicProcedure .input(z.object({ text: z.string() })) .query(({ input }) => { return { greeting: `Hello ${input.text}`, }; }), getSecretMessage: protectedProcedure.query(({ ctx }) => { return { message: "you can now see this secret message!", }; }), });

publicProcedure表示所有人都能调用,protectedProcedure表示必须要登录才能调用。每个 Procedure 都有三种类型:query(GET)、mutation(POST/PUT/DELETE)、subscription(WebSocket 实时推送)。query 适合读取,mutation 适合写入。

Procedure 的输入校验是另一个亮点。input方法接收一个 zod schema,然后 tRPC 会在运行时做校验,同时把校验后的类型推给前端。前端调用时,如果参数缺了某个字段,编译阶段就会报错,不会有“接口请求成功但后端得到 undefined”这种问题。这种校验同时发生在前后端,等于一道双重保险。

链路完整走一遍是这样:浏览器页面发起调用 -> Next.js 的/api/trpc路由接收请求 -> 根据请求里的路径参数(比如["post", "hello"])找到对应的 router 和 procedure -> 经过 zod 校验 -> 创建上下文 context(这里会读取 session 和 db 实例) -> 执行 procedure 的处理函数 -> 结果序列化返回 -> 前端拿到类型安全的 data。

3.3 实操体验:快速配置根 Router 并注册到 API 路由

前面看到的 postRouter 最终要被聚合进根 Router。脚手架生成的trpc.ts文件里会有:

export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;

然后 API 路由文件[trpc].ts里把这个appRouter挂载到 Next.js 的路径上:

import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter, createTRPCContext } from "~/server/api/trpc"; const handler = (req: NextRequest) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: () => createTRPCContext({ req }), }); export { handler as GET, handler as POST };

这个文件把所有 tRPC 请求都收口到同一个 API 路径下。注意这里用的是 fetch adapter,适配 Next.js 的 Route Handler。前端页面组件里,通过~/trpc/react里生成的 hooks 调用即可。整个架构清晰干净。

我自己的经验:新手最容易把 tRPC 想复杂,其实可以把 Router 理解为后端 Controller,Procedure 理解为具体的 Action,zod 是参数校验器,Context 是在处理请求之前注入的全局数据。想清楚这四个概念,后面写业务就是机械式堆叠。

4. 实操落地:用 t3code 写一个可复用的待办事项应用

4.1 数据模型设计与 Prisma Schema 定义

理论讲再多,不如动手做点东西。我拿一个非常典型的场景——待办事项(Todo)应用来完整演示一遍 t3code 的开发流程。麻雀虽小,五脏俱全,它能覆盖数据库建模、CRUD 接口、前端交互、认证权限四个核心环节。

第一步在prisma/schema.prisma里定义 Todo 模型。脚手架自带了 Post 模型,我们新增一个:

model Todo { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] todos Todo[] }

这里做一个设计决策:每个用户只允许操作自己的 Todo,不能看到别人的数据。所以模型里加了userId外键。Prisma 定义关系的语法很直观,@relation声明关联字段,onDelete: Cascade表示删了用户之后自动删除他的所有 Todo,避免孤儿数据。

定义完模型后执行:

npx prisma db push

这条命令会把 schema 同步到本地 SQLite 数据库。开发阶段用 db push 很方便,不需要生成迁移文件。但如果项目要部署到生产环境,建议用npx prisma migrate dev --name init_todo生成正式迁移记录,这个以后细说。

4.2 编写 Todo 的 CRUD:Router 层面的核心逻辑

在src/server/api/routers/todo.ts里创建 todoRouter,完整代码如下:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ // 获取当前用户的所有待办 getAll: protectedProcedure.query(({ ctx }) => { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), // 创建新的待办 create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), // 切换完成状态 toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id }, include: { user: true }, // 这里可以控制关联数据的读取粒度 }); if (!todo || todo.userId !== ctx.session.user.id) { throw new Error("Todo not found or not permitted"); } return ctx.db.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), // 删除待办 delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId !== ctx.session.user.id) { throw new Error("Todo not found or not permitted"); } await ctx.db.todo.delete({ where: { id: input.id } }); return { success: true }; }), });

这份代码有几个细节值得展开:

第一个是ctx.session.user.id。protectedProcedure 中,context 已经注入了当前登录用户的 session 对象,直接从中取用户 id 即可,无需再去数据库查一遍 User 表。这个设计让权限控制变得简单:每个 Procedure 处理函数天然知道当前请求是谁发起的。

第二个是权限校验逻辑。切状态和删除操作都先查一次数据库,比对todo.userId和ctx.session.user.id。这是典型的防御式编码,防止用户直接在请求里改参考值。虽然 tRPC 有类型校验,但这种跨行的所有权校验必须在业务代码里做掉。

第三个是 zod 校验规则。title: z.string().min(1).max(100)设置了标题的合法性边界。前端如果传了空字符串或者超过 100 个字符的标题,请求直接被拦截,不会进入数据库层。

之后把这个 router 注册到根 router 中,和 postRouter 平级:

export const appRouter = createTRPCRouter({ post: postRouter, todo: todoRouter, });

4.3 前端页面接入:从 useQuery 到乐观更新

后端逻辑就绪后,前端页面可以直接调用。在src/pages/index.tsx中:

import { api } from "~/utils/api"; export default function Home() { const utils = api.useUtils(); const { data: todos, isLoading } = api.todo.getAll.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: () => { utils.todo.getAll.invalidate(); // 创建成功后重新拉取列表 }, }); const toggleTodo = api.todo.toggle.useMutation({ onMutate: async (variables) => { // 乐观更新:先改本地 UI,再请求后端 await utils.todo.getAll.cancel(); const prevTodos = utils.todo.getAll.getData(); utils.todo.getAll.setData(undefined, (old) => old?.map((todo) => todo.id === variables.id ? { ...todo, completed: !todo.completed } : todo, ), ); return { prevTodos }; }, onError: (_err, _vars, context) => { // 失败时回滚 if (context?.prevTodos) { utils.todo.getAll.setData(undefined, context.prevTodos); } }, }); // 渲染逻辑... }

注意到 tRPC hooks 的命名规律:api.todo.getAll.useQuery()对应 todo router 里的 getAll query,api.todo.create.useMutation()对应 create mutation。这种“按 name 自动生成 hook”的设计,让前后端方法名保持绝对一致,维护起来非常清晰。

乐观更新这块,很多人觉得难理解,其实机制不复杂:用户点击切换完成状态时,先把 UI 上的数据改掉,让界面看起来马上有了反馈,然后再等等后端。如果成功了就什么也不做;如果失败了,就用 onError 里的 context 把数据回滚到之前的版本。这样操作的手感会比传统“请求完了再刷新整个列表”流畅很多。

还有一个我特别想讲的点:invalidate。创建成功之后调用utils.todo.getAll.invalidate(),这个动作和 React Query 里的 invalidateQueries 含义一样,标记缓存失效触发重新查询。在 t3code 里 this 一步是隐式协议的一部分,如果你的列表数据在多处展示,这一个调用能保证所有引用这个 query 的地方都刷新数据。

5. 从本地到线上:T3 项目的部署与常见坑位

5.1 数据库迁移:从 SQLite 平滑过渡到 PostgreSQL

本地开发用完 SQLite,部署到线上想要稳定,一般会换 PostgreSQL。这个过程首要步骤是修改环境变量,然后一样顺畅。核心是 Prisma 的 schema 不需要大改,只需要调整 datasource provider:

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

SQLite 和 PostgreSQL 在字段类型上有细微差异。比如 SQLite 里的 Boolean 和 DateTime 在 PostgreSQL 里也是对应的,但 SQLite 没有原生枚举类型,如果你用了enum在 schema 里,迁移时要检查一下。

实际操作流程:

# 1. 修改 .env 文件,把 DATABASE_URL 换成 PostgreSQL 连接字符串 # 2. 重新生成客户端 npx prisma generate # 3. 执行迁移(开发/预发布环境) npx prisma migrate deploy # 4. 同步种子数据(可选) npx prisma db seed

我遇到的典型坑:本地 SQLite 生成的 API 数据主键是自增数字,切到 PostgreSQL 后,如果你已经用了 cuid 默认值就没问题。但如果你在 SQLite 里用了 Auto Increment 而且是手动指定的整数 ID,切换之后必须清理 history 表,否则外键关联会错乱。

另一个坑:环境变量里的 DATABASE_URL 在云平台后台上线前必须填对。很多部署失败的原因不在代码,而是 DATABASE_URL 填成了本地 127.0.0.1 的地址,或者漏了 SSL 参数。PostgreSQL 托管服务商一般会给你一个带?sslmode=require的连接串,别把这个后缀弄丢了。

5.2 构建产物与发布:最常见的三个翻车现场

T3 项目的部署方式非常灵活,Vercel 是最简单的选项。由于 Next.js 本身就出自 Vercel,后端 API 和前端都是同一套运行时,交付时只需要一个平台配置就够了。这是 T3 另一个吸引人的地方:部署到 Vercel 不需要单独布置后端服务,也不需要配置跨域策略。

发布前必做的是本地构建验证:

npm run build

实际开发中的三个经典问题,我在不同项目上都遇到或听说过:

第一个问题是 NextAuth 环境变量不对。NEXTAUTH_SECRET是 NextAuth 用于加密会话 token 的密钥,本地开发时脚手架可能自动生成了一个,但部署时如果不重新设置,每次重启服务的 session 都会失效,用户会被频繁登出。而且如果放到多个实例上,secret 不一致会造成“登录失败”这种莫名问题。

第二个问题是 Prisma 生成的 client 没有在部署机上重新生成。Vercel 部署时如果忽略.env变更,可能拿到的是本地开发环境的 client,连到了错误的数据库,甚至报 P1001 连接错误。解决办法是在 package.json 的 build 脚本前加一步:

"build": "prisma generate && next build"

第三个问题是 tRPC 的批处理行为。默认情况下,同一个页面在首屏如果有多个 query 并发请求,tRPC 会把它们合并成一个 HTTP 请求,这是它的自动批处理优化。但如果你在部署后有边缘函数或自定义 server 配置,某些环境下批处理可能失效,出现接口响应变慢。这时可以去 router 配置中把batching关闭,或者按需调整请求负载策略,不至于伤筋动骨。

5.3 性能优化:缓存、加载状态和 bundle 体积控制

全栈模式有时会被误解为“性能一定更好”,严格说不是。T3 项目像任何 Next.js 项目一样,需要在 Data Fetching 的时机上做选择。

在 App Router 模式下,服务端组件可以直接调用 tRPC 的 server caller,把数据在服务端就准备好,然后塞给客户端组件。这种方式既能做到首屏数据不闪烁,也能减少传到浏览器的 JS 体积。

具体地讲,如果你想节省 bundle 体积空间,tRPC 的server caller只是一个普通对象,不会像 hooks 那样把整个 react-query 相关逻辑带进前端。以我的经验,对展示型页面(如用户个人主页、知识库文章详情页)使用服务端数据流,可以显著减小首屏 JS 尺寸。而在需要多种交互响应的页面(如开口表单、管理面板)上,使用 hooks 反而体验更好,因为可以做乐观更新和缓存失效。

还有一个很小但很有用的原则:要避免在 query 里面把不需要的字段查出来,然后不展示。Prisma 默认支持字段选择,right-click 到 schema 再手写select的取舍是每个 T3 项目都会遇到的问题。

缓存是另一方面。因为数据源是同一个 Prisma 层,你可以启用swr: true设置。比如一个 Todo 列表,用户完成了某条操作,把它标记为 completed,但数据不会立刻重挂,配合 optimistic update 就能做到几乎秒开响应;在 tRPC 中使用useQuery(..., { staleTime: 1000 * 60 })能避免不必要的请求重复,尤其是那种高频访问的统计信息。

6. 常见问题与排查技巧实录:我踩过的坑和绕坑指南

6.1 高频问题速查表

我把这段时间里使用 t3code 前后端一体化架构时经常收到的开发同学提问整理成一个表格,方便你直接查阅:

现象根因解决方案
前端调用 tRPC 返回 undefined,但没有 TS 错误页面和服务端注册的 router 不一致检查是否有多个appRouter入口,确保 API handler 引入的是同一个 router
prisma db push 报错 P1012 或 P2002schema 存在格式错误或唯一约束冲突先npx prisma format,再逐条检查 unique 字段的重复数据
NextAuth 一直跳转到登录页面数据库 Session 版本与库表不一致确认 NextAuth 的 session strategy 是否正确,及required中间件路径配置
tRPC mutation 成功后数据列表不刷新没有调用 invalidate 或调用路径不对/写错 key打印utils.todo.getAll.getQueryData()检查对是否缓存在同一 key 下
type error in create-t3-app:Cannot use import statement outside a modulepackage.json 缺少"type": "module"检查 jest/vitest 配置文件,或改用tsx执行脚本
部署到 Vercel 后 API 返回 503服务端拿不到数据库连接在环境变量里检查 DATABASE_URL,加directUrl到 Vercel 的 Functions 选项

好几个问题表面看是 Node 版本或者网络原因,实际排查到最后都是环境变量或缓存 key 这类小问题。所以我现在遇到报错不会随便怀疑框架,而是先枚举环境变量清单,再确认查询 key 有没有写错,最后才翻框架源码。

6.2 实战中的独家避坑清单

说几条常规文档里不会明确写出来的经验。

第一条,不要在 getStaticProps 或服务端组件里直接使用 api hooks。tRPC 的 hooks 依赖 React Query 的 QueryClientProvider,这些 hooks 只能在客户端组件边界内使用。服务端要用 tRPC 数据,应该通过createCaller创建服务端调用器,或直接调用 router 的处理函数。我见过有人非要在服务端组件里useQuery,结果页面一直空白,控制台报 provider 缺失。

第二条,注意 tRPC 路径规则和文件名的大小写约定。Router 的 key 一般用小驼峰命名,比如postRouter字段名为post,前端调用api.post.getAll。如果哪天你看到api.userProfile.getProfile但代码里没有对应 key,最大可能性是后端在 router 注册时字段名写错或空值了。

第三条,不要把敏感逻辑放在 client component 里。React Query 的缓存数据是可以被浏览器扩展、DevTools 和任何人打开内存查看的,如果你的 procedure 返回了没有过滤的敏感字段(如用户手机号),它的类型约束并不能阻止别人查看网络请求的原始响应。还是要在后端控制返回字段的详细程度。

第四条,关于 Prisma 的扩展查询,比如分页与统计。业务逐步变多时,简单的findMany不够用。句法上 Prisma 提供了很好的过滤和排序链式调用能力:比如实现分页用skip和take,统计完成状态用条件where: { completed: true }的count。别等到页面卡了才去优化 SQL,直接把include、select写得精细一些,会比事后做 SQL analyse 痛快得多。

第五条,部署后验证 tRPC 端点是否正常。可以在浏览器里直接访问<你的域名>/api/trpc/todo.getAll?input={}看 JSON 返回(App Router 下可能需要 POST 或 HEAD)。这个方法比看前端界面更快定位到是前端还是后端的锅。

6.3 开发流程里的实用心得

最后我把使用 t3code 这套全栈实践几个月后的体会浓缩成几条经验,给正在考虑引入这套技术栈的朋友:

  • 先学 TypeScript,再学 T3。TS 泛型不熟的话,看 tRPC 的类型定义会像看天书。起码要理解infer、泛型函数、typeof这几个概念。
  • 从一个小需求开始练手。不要一上来就幻想做一个大型电商平台。用 Todo、记账本这种小工具跑通全链路之后,许多设计模式自然就理解了。
  • 不要再写 API 文档了。团队里应该有一套约定:改完后端函数签名,前端编译报错就是文档更新。如果还有人习惯用 Word 写接口文档,在 t3code 里属于冗余管理成本。
  • 留意依赖的升级节奏。Next.js 和 tRPC 都在快速迭代,大版本升级时注意 changelog,尤其是 procedure 的配置方式在不同大版本之间可能有 breaking change。

我自己的体会是,t3code 并不是银弹。它适合快速开发、团队规模小、对类型安全要求高的项目。但如果你想做那种需要复杂可查询接口、或多系统间大规模服务集成的项目,还是老老实实用 REST/GraphQL。选择工具永远是看场景,适合自己的才是最优解。

如果你正在从零开始尝试这套技术栈,我的建议是别犹豫,直接照这篇文章里的流程走一遍。第一天就完成”创建项目 + 建表 + 写一个 router + 前端调用“这个小循环,感受到类型安全从后端一路贯穿到前端的爽感之后,你自然会有动力继续深挖下去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询