ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code 项目实战:T3 技术栈全栈 TypeScript 开发与部署指南

t3code 项目实战:T3 技术栈全栈 TypeScript 开发与部署指南

1. 从“t3code”这个关键词说起:它到底指什么

第一次看到“t3code”这个词,很多人会一头雾水。它不像“Python教程”或者“React入门”那样一眼就能看出领域归属,反而带着一种内部代号的味道。我在几个技术社区和代码托管平台翻了一圈,发现这个词的指向其实相当集中:它通常被用来指代一类基于 T3 技术栈的代码实践、项目模板或脚手架工具。这里的“T3”不是某个单一框架,而是指Next.js + TypeScript + tRPC + Tailwind CSS + Prisma这一套组合拳,最早由 Theo Browne 等人推广开来,后来社区里就把遵循这套架构的项目统称为“T3 栈项目”,而“t3code”则常被用作这类项目的仓库名、CLI 命令名或者代码片段的标签。

换句话说,当你看到“t3code”时,大概率面对的是一个全栈 TypeScript 项目,它强调端到端的类型安全、极简的 API 层和快速的开发体验。它解决的问题很具体:传统前后端分离项目里,接口类型靠手写或代码生成,容易漂移;REST 或 GraphQL 的样板代码多;状态管理和数据获取逻辑分散。T3 栈通过 tRPC 把后端函数直接暴露给前端调用,类型自动推导,省掉了中间层。而“t3code”作为关键词,往往出现在开发者分享项目结构、初始化命令或者踩坑记录的语境里。

这篇文章适合谁看?如果你已经写过 React 或 Next.js,但对 tRPC、Prisma 的配合还不太熟,或者你接手了一个名为“t3code”的仓库却不知道从哪下手,那接下来的内容就是为你准备的。我会从项目初始化、目录结构、核心依赖的协作逻辑、常见报错排查到实际部署前的检查,一步步拆开讲。所有内容基于社区常见实践和我自己在多个 T3 栈项目里的操作经验,不保证是唯一解,但能让你少走弯路。

提示:本文提到的“t3code”泛指 T3 栈项目的代码实践,不特指某一个具体仓库。如果你的项目名恰好叫 t3code,可以把文中的路径和命令按实际情况替换。

2. 初始化一个 t3code 项目:命令背后的选择逻辑

2.1 为什么推荐用 create-t3-app 而不是手动搭

手动从零搭建一个 T3 栈项目不是不行,但你会花大量时间在配置文件的互相引用上。create-t3-app这个脚手架的价值在于,它把 Next.js、tRPC、Prisma、Tailwind、NextAuth 等模块的初始化顺序和类型声明文件都提前对齐了。我试过手动配 tRPC 的 router 和 client,光是处理AppRouter的类型导出就折腾了半小时,而脚手架生成的代码里,server/api/root.ts和utils/api.ts之间的类型链路是直接打通的。

运行命令很简单:

npm create t3-app@latest t3code # 或者 pnpm create t3-app@latest t3code

执行后 CLI 会问你几个问题:要不要用 TypeScript(默认 yes)、要不要加 tRPC、要不要加 Prisma、要不要加 NextAuth、要不要加 Tailwind。我的建议是全选 yes,除非你明确知道某个模块用不上。比如你只是做个纯静态页面,那 Tailwind 可以留,tRPC 和 Prisma 可以去掉;但如果你打算做带数据库的全栈应用,全选能省掉后续手动集成的麻烦。

这里有个细节:CLI 会问你是否使用src目录。我习惯选“是”,因为把源码收在src下,根目录只放配置文件和静态资源,视觉上更干净。但这不是硬性要求,团队里如果有人习惯根目录直接放app或pages,统一就行。

2.2 生成后的目录里,哪些文件是“活”的

初始化完成后,你会看到一个典型的 T3 项目结构。我拿一个刚生成的 t3code 项目举例,关键路径如下:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ │ └── post.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ └── utils/ │ └── api.ts ├── .env ├── next.config.js ├── package.json └── tsconfig.json

其中src/server/api/root.ts是所有路由的汇总入口,你每加一个 router,都要在这里注册。src/trpc/react.tsx导出了前端用的api对象,它带着完整的类型信息。src/utils/api.ts是给服务端组件或 API 路由用的辅助函数。这三个文件构成了 tRPC 的类型闭环,改任何一个都要留意另外两个的引用。

.env文件里默认有DATABASE_URL和NEXTAUTH_SECRET等占位符。如果你选了 Prisma,第一件事就是把DATABASE_URL改成你本地或远程数据库的连接串。我见过有人直接跑pnpm dev然后报错说找不到数据库,其实就是忘了改这个。

2.3 依赖安装与首次启动的坑

脚手架生成后,先别急着pnpm dev。按顺序做这几步:

  1. pnpm install(或 npm/yarn,看你用哪个)
  2. 修改.env里的DATABASE_URL
  3. 运行pnpm db:push把 Prisma schema 推到数据库
  4. 再pnpm dev

db:push这个脚本是脚手架预置的,对应prisma db push。它适合开发阶段快速同步表结构,不需要生成迁移文件。但如果你要上生产,应该用prisma migrate dev来生成版本化的迁移记录。我踩过的坑是:在开发环境用db:push改了字段,然后直接部署到生产,结果生产数据库没有对应的迁移历史,后续想回滚都难。所以从项目一开始就养成用 migrate 的习惯,哪怕麻烦一点。

另一个常见问题是端口占用。Next.js 默认跑 3000,如果被占会提示换端口。但 tRPC 的客户端配置里如果写死了http://localhost:3000/api/trpc,换端口后就会请求失败。检查src/trpc/react.tsx里的url设置,通常它用的是相对路径/api/trpc,那就没问题;如果是绝对路径,记得改成环境变量。

3. tRPC 在 t3code 里的实际工作方式:类型安全不是玄学

3.1 一个完整的请求链路拆解

很多人第一次看 T3 项目,会被api.post.getAll.useQuery()这种写法迷惑:前端怎么知道后端有这个函数?答案藏在 TypeScript 的类型推导里。整个链路是这样的:

  • 后端在src/server/api/routers/post.ts里定义了一个createTRPCRouter,里面有个getAll过程。
  • 这个 router 被注册到src/server/api/root.ts的appRouter里。
  • appRouter的类型AppRouter被导出。
  • 前端在src/trpc/react.tsx里用createTRPCReact<AppRouter>()创建了api对象。
  • 于是api.post.getAll在编辑器里就有了完整的类型提示,包括输入参数和返回值。

这个过程不需要你手动写接口类型,也不需要代码生成步骤。TypeScript 编译器在后台完成了所有工作。我实测下来,只要root.ts里注册正确,前端调用时参数传错类型会立刻标红,比等运行时才报错强太多。

3.2 定义 router 时的输入校验:Zod 不是可选项

T3 栈默认用 Zod 做输入校验。在post.ts里你会看到类似这样的代码:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id } }); }), });

这里的.input()不是装饰品。如果你省略它,input的类型就是unknown,你在函数体里访问input.id会报错。而且 Zod 会在运行时校验传入的参数,如果前端传了个数字而 schema 要求字符串,tRPC 会直接返回 400 错误,不会进到你的查询逻辑。我建议每个 procedure 都加 input 校验,哪怕是空对象也写z.object({}),这样类型和运行时都安全。

3.3 服务端调用与客户端调用的区别

在 t3code 项目里,你有两种方式调用后端逻辑:

  • 客户端组件:用api.post.getAll.useQuery(),走 HTTP 请求到/api/trpc。
  • 服务端组件或 API 路由:用src/trpc/server.ts导出的api,直接调用函数,不经过 HTTP。

服务端调用适合在 RSC(React Server Components)里预取数据,减少客户端瀑布流。但要注意,服务端调用时没有 React Query 的缓存和重试机制,每次都是直接执行。我通常会在页面级组件里用服务端调用拿初始数据,然后在客户端组件里用useQuery做交互更新。两者共享同一个 router 定义,类型完全一致。

注意:服务端调用api.post.getAll()返回的是 Promise,你需要await它。而客户端useQuery返回的是包含data、isLoading等字段的对象,别搞混了。

4. Prisma 与数据库:schema 设计决定后期维护成本

4.1 schema.prisma 里的关键配置

Prisma 的 schema 文件是 t3code 项目里最需要认真对待的部分。一个典型的schema.prisma开头是这样的:

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") }

provider可以是postgresql、mysql、sqlite等。开发阶段用 SQLite 很方便,一个文件搞定,但生产环境通常用 PostgreSQL。我建议开发和生产用同一种数据库类型,避免 SQLite 和 PostgreSQL 在字段类型上的差异导致迁移失败。比如 SQLite 没有原生enum,Prisma 会用字符串模拟,换到 PostgreSQL 后行为可能不同。

4.2 模型定义与关系字段的实操细节

假设你要做一个博客的 t3code 项目,Post和User的模型可能长这样:

model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model User { id String @id @default(cuid()) email String @unique name String? posts Post[] }

这里有几个经验点:@default(cuid())比自增整数更适合分布式场景,也避免暴露业务量。@updatedAt让 Prisma 自动维护更新时间,不用手写。关系字段author和posts是虚拟的,不会在数据库里生成列,但 Prisma Client 会据此生成关联查询方法。

改完 schema 后,运行pnpm db:push或pnpm db:migrate。如果数据库里已有数据,加一个非空字段会失败,需要给默认值或者先清空数据。我遇到过在已有Post表里加published Boolean没给默认值,迁移直接报错。后来改成published Boolean @default(false)才通过。

4.3 在 tRPC 里使用 Prisma Client 的正确姿势

src/server/db.ts里通常这样初始化 Prisma Client:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这段代码的目的是避免开发环境下热重载时反复创建 PrismaClient 实例,导致数据库连接数暴涨。globalThis缓存是官方推荐的做法。日志配置里,开发环境打印 query 能帮你看到实际执行的 SQL,排查性能问题很有用;生产环境只留 error,减少日志量。

在 tRPC 的 context 里,ctx.db就是这个db实例。你可以在src/server/api/trpc.ts里看到 context 的创建逻辑。如果你需要加用户会话信息,也是在这里注入。

5. 开发过程中最容易卡住的几个报错与排查路径

5.1 “Type 'AppRouter' does not satisfy the constraint” 怎么解

这个报错通常出现在你新增了一个 router 但忘记在root.ts里注册,或者注册时写错了键名。比如你建了comment.ts,导出了commentRouter,但在root.ts里写成了comment: commentRouter而实际导入名是commentRouter的别名。TypeScript 会提示AppRouter不满足约束,因为它找不到对应的类型。

排查步骤:

  1. 打开src/server/api/root.ts,确认appRouter里每个键值对都正确。
  2. 检查新 router 的导出名和导入名是否一致。
  3. 如果用了路径别名(如~/server/api/routers/comment),确认tsconfig.json里的paths配置正确。

我自己的习惯是每加一个 router,先在root.ts里注册,再写具体逻辑。这样类型错误会立刻暴露,不会等到前端调用时才发现。

5.2 Prisma Client 找不到模型或字段

改了schema.prisma后,如果没重新生成 Prisma Client,ctx.db.post可能报“Property 'post' does not exist”。解决方法是运行pnpm prisma generate。db:push和db:migrate通常会自动触发 generate,但如果你手动改了 schema 又没跑迁移,就需要单独执行。

另一个情况是字段名拼写错误。Prisma 的模型字段是大小写敏感的,createdAt和createdat是两个不同的东西。编辑器有自动补全时不容易错,但手写迁移脚本时要格外小心。

5.3 前端调用返回 500 但控制台没细节

tRPC 的错误默认在生产环境会隐藏堆栈,开发环境则会在终端打印。如果你在浏览器看到 500,先去跑pnpm dev的终端窗口看有没有红色报错。常见原因有:数据库连接失败、Zod 校验失败但没处理、Prisma 查询抛异常。

我建议在src/server/api/trpc.ts里配置errorFormatter,把 Zod 的错误信息格式化后返回给前端。这样前端能直接看到哪个字段校验不通过,不用猜。具体做法是在initTRPC.context().create()时传入errorFormatter,社区里有现成的代码片段可以参考。

提示:开发阶段可以把TRPCError的cause打印出来,但生产环境记得关掉,避免泄露敏感信息。

6. 从开发到部署:t3code 项目上线前的检查清单

6.1 环境变量与构建命令的对应关系

T3 项目部署时,.env文件不会被打包,所以所有环境变量必须在部署平台的后台设置。需要检查的变量包括:DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL(如果用了 NextAuth)。NEXTAUTH_URL在生产环境必须是你实际的域名,否则登录回调会失败。

构建命令通常是pnpm build,启动命令是pnpm start。但如果你用了 Prisma,构建前需要确保prisma generate已经执行。可以在package.json的build脚本里加上prisma generate && next build,这样每次构建都会重新生成 Client。

6.2 数据库迁移在生产环境的执行时机

生产环境不能用db:push,要用prisma migrate deploy。这个命令会执行所有未应用的迁移文件,不会修改 schema 也不会生成新迁移。你需要在部署流程里加一步:在启动应用之前运行pnpm prisma migrate deploy。很多部署平台支持release command或pre-deploy钩子,把迁移放在那里最合适。

如果迁移失败,应用不应该启动。我见过有人把迁移和应用启动并行执行,结果应用启动时表还没建好,直接报错。顺序必须是:先迁移,再启动。

6.3 类型检查与 lint 在 CI 里的必要性

T3 栈的核心卖点是类型安全,但如果 CI 里不跑tsc --noEmit,类型错误可能被 Next.js 的构建过程忽略(Next.js 默认在构建时做类型检查,但可以关闭)。我建议在 CI 里显式加一步pnpm typecheck,脚手架通常已经配好了这个脚本。另外pnpm lint也要跑,ESLint 能抓出一些 tRPC 使用上的反模式,比如在循环里调用useQuery。

部署前的最后检查:本地跑一遍pnpm build,确认没有类型错误和 lint 错误。这一步能拦下大部分低级问题,比上线后回滚强。

7. 一些让 t3code 项目更顺手的个人习惯

我在多个 T3 栈项目里反复调整后,固定了几个小习惯。第一个是把 router 按业务域拆分,而不是按 CRUD 拆分。比如post.ts里既有getAll也有create,而不是建一个queries.ts和一个mutations.ts。这样改一个功能时只动一个文件,心智负担小。

第二个是在src/server/api/routers/下建一个_app.ts或者index.ts来统一导出,但root.ts仍然手动注册。这样导入路径短一些,但注册关系依然显式。我不推荐用动态导入自动注册 router,因为类型推导会变弱,而且出错了不好定位。

第三个是给每个 procedure 加 JSDoc 注释,说明输入输出和副作用。tRPC 的类型提示虽然强,但不会告诉你这个 mutation 会发邮件还是会扣库存。注释是给未来的自己和同事看的。

第四个是定期运行pnpm outdated检查依赖更新。T3 栈的各个包版本耦合较紧,比如 tRPC 和 React Query 的版本要匹配,Prisma 和@prisma/client要一致。升级时最好一次只升一个,跑完测试再升下一个。我试过一次性升级所有依赖,结果 tRPC 的 API 变了,改了半天才恢复。

最后分享一个排查类型问题的技巧:当AppRouter类型报错但看不出哪里不对时,在root.ts里把appRouter的类型显式写出来,或者用satisfies操作符约束一下,TypeScript 会给出更具体的错误位置。这个办法帮我省过不少时间。

RELATED READING

延伸阅读

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