1. 从"t3code"这个标题说起:它到底是什么
第一次看到"t3code"这个词,我脑子里蹦出来的第一反应是——这大概率是个技术圈里的缩写或者代号,而不是某个大众消费品。事实也确实如此。在开发者社区里,"t3code"通常指向的是一类围绕T3 技术栈(TypeScript + tRPC + Tailwind CSS,有时也泛指以 Next.js 为骨架的现代全栈方案)展开的代码实践、脚手架模板或者项目代号。它不是一个官方框架的名字,更像是圈内人给"用 T3 那套东西写出来的代码"起的一个顺口叫法。
那它到底能做什么?简单说,如果你正在用或者打算用 TypeScript 全栈方案搭一个 Web 应用,t3code 代表的这套实践能帮你解决几个非常具体的问题:类型安全从数据库一路贯穿到前端组件、API 层不用手写一堆重复的接口定义、样式方案统一且可维护。它适合谁?适合已经有一定前端基础、想往全栈方向走的中级开发者,也适合那些被"前后端类型对不上、改一个字段要改五个地方"折磨过的团队。
我写这篇东西的出发点很直接:网上关于 T3 栈的资料要么太散,要么太浅,要么就是官方文档那种"正确但没温度"的说明。我想以一个真正拿它做过项目的人的角度,把 t3code 这套东西从设计思路到落地细节,再到踩过的坑,完整地讲一遍。不管你是刚听说这个词,还是已经上手但总觉得哪里别扭,应该都能从下面这些内容里找到对你有用的部分。
2. t3code 的整体设计思路与选型逻辑
2.1 为什么是 TypeScript 打底,而不是"先写 JS 再说"
很多人一开始会觉得 TypeScript 是"额外的负担",写起来啰嗦。但 t3code 这套实践的核心价值,恰恰建立在 TS 之上。原因很实在:当你用 tRPC 把后端过程(procedure)的类型自动推导到前端时,如果底层不是 TypeScript,这套类型流转根本无从谈起。我见过太多项目,后端用 JS 写,前端也用 JS 写,结果接口字段改了没人通知前端,上线才发现undefined满天飞。
TypeScript 在这里扮演的角色不是"语法糖",而是契约的载体。你在后端定义一个返回{ id: string; name: string }的过程,前端调用时编辑器立刻就能提示你有哪些字段、类型是什么。这种体验一旦用过就回不去了。所以 t3code 的第一层设计逻辑就是:把类型当作一等公民,让编译器帮你抓错,而不是等用户帮你抓。
2.2 tRPC 的取舍:它解决了什么,又放弃了什么
tRPC 是 t3code 里最有争议也最核心的一环。它的思路很"激进":不要 REST,不要 GraphQL,直接让前端像调用本地函数一样调用后端。你写一个post.create的过程,前端trpc.post.create.useMutation()就能用,参数和返回值的类型全自动推导。
它解决的问题很明确——消灭手写 API 类型定义和接口文档同步的痛苦。传统 REST 你得先定接口、写文档、前端照着文档写类型,改一次同步一次。tRPC 把这些全砍了,因为类型是"长"在代码里的。
但它也有明确的放弃:它不适合对外提供公开 API。如果你的服务要被第三方调用,或者要支持非 TS 的客户端(比如原生 App),tRPC 就不合适了。所以选它之前想清楚:你的项目是不是"自己人用自己人"的闭环场景。是,就非常爽;不是,就别硬上。
2.3 Tailwind CSS 的定位:不是"偷懒",是约束
关于 Tailwind 的争论从来没停过。有人说它让 HTML 变得又臭又长,有人说它效率极高。在 t3code 的语境里,选 Tailwind 的逻辑是用约束换一致性。传统 CSS 里每个人起类名的方式都不一样,.card-wrapper、.wrapper-card、.cardContainer能同时存在。Tailwind 把样式收敛到一套固定的原子类上,团队里谁写都是flex items-center gap-4,风格自然统一。
而且它和组件化是绝配。你封装一个<Button>,内部用 Tailwind 类,外部只暴露variant这样的语义化属性。样式细节被锁在组件里,改起来只改一处。这就是 t3code 选择它的真实理由——不是为了少写 CSS,而是为了让样式有迹可循。
2.4 数据库层的选择:Prisma 为什么常被搭配进来
T3 栈里数据库层通常用 Prisma。它的价值在于类型安全的查询 + 可读的 schema 定义。你用schema.prisma描述数据模型,Prisma 自动生成带类型的客户端,查询时字段名写错编辑器直接报红。这和 t3code 整体的"类型贯穿"思路是一致的。
对比手写 SQL 或者用一些弱类型的 ORM,Prisma 的优势是迁移(migration)管理清晰。每次改 schema 生成一个迁移文件,团队协作时数据库结构变更可追溯。踩过的坑是:Prisma 对复杂查询(比如多表联查加聚合)的表达能力有限,遇到性能敏感的场景可能得退回原生 SQL。所以我的建议是:常规 CRUD 用 Prisma,复杂分析查询用$queryRaw兜底,两者不冲突。
3. 核心细节解析与实操要点
3.1 项目初始化:别急着写业务,先把骨架搭对
t3code 类项目的初始化,我强烈建议用现成的脚手架而不是手动一个个装。手动装最容易出的问题是版本不匹配——Next.js 的版本、React 的版本、tRPC 的版本之间有兼容矩阵,自己拼很容易踩雷。用create-t3-app这类工具,它会问你几个关键问题:要不要 tRPC、要不要 Prisma、要不要 NextAuth,然后按你的选择生成一套已经配好的骨架。
初始化时有个细节值得注意:选 App Router 还是 Pages Router。现在新项目基本都推荐 App Router,因为 Server Components 能让你在服务端直接查数据库,减少一层 API 往返。但 App Router 的心智模型和传统 React 差别不小,如果你是第一次接触,建议先花半天把 Server Component 和 Client Component 的区别搞清楚,否则后面会一直困惑"为什么这个 hook 用不了"。
3.2 目录结构:约定比配置更重要
t3code 项目里,目录结构本身就是一种"文档"。我习惯的划分是这样的:
src/server/api/routers/放所有 tRPC 的 router,按业务域拆文件,比如post.ts、user.tssrc/server/api/root.ts把所有 router 合并成一个大 routersrc/server/db.ts放 Prisma 客户端实例src/trpc/放前端调用 tRPC 的封装,包括 provider 和 hookssrc/components/放 UI 组件,纯展示的放ui/,带业务逻辑的放features/
这个划分的核心逻辑是按"职责边界"而不是"文件类型"组织。很多人喜欢把所有 hooks 放一个文件夹、所有 utils 放一个文件夹,结果改一个功能要在五个文件夹之间跳。按业务域组织,改帖子相关的东西就集中在post相关的几个文件里,效率高得多。
3.3 tRPC 过程的三层结构:public、protected、admin
写 tRPC 过程时,权限控制是绕不开的。t3code 实践里常见的做法是用**过程类型(procedure type)**来区分:
// 公开过程,谁都能调 export const publicProcedure = t.procedure; // 需要登录 export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); });这样在 router 里,publicProcedure.query(...)就是公开的,protectedProcedure.mutation(...)就自动带登录校验。好处是权限逻辑集中在一处,不会散落在每个接口里。我踩过的坑是:一开始图省事全用 publicProcedure,后面加权限时一个个改,漏了一个就是安全漏洞。所以从第一天起就把 protected 用起来。
3.4 输入校验:Zod 不是可选项
tRPC 的输入校验用 Zod,这不是"锦上添花",而是必须做的事。前端传来的数据永远不可信,哪怕是你自己写的前端。Zod 的用法很直观:
export const postRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ title: z.string().min(1).max(200), content: z.string().min(1), })) .mutation(({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), });这里.input()定义的 schema 会在运行时校验,同时自动推导出input的 TypeScript 类型。也就是说你写一次 schema,运行时校验和编译时类型都有了。注意min(1)这种约束别省,我见过因为没限制长度导致数据库字段溢出的情况。
3.5 前端调用:useQuery 和 useMutation 的取舍
前端调用 tRPC 主要用两个 hook:useQuery读数据,useMutation写数据。新手最容易犯的错是用 useQuery 做写操作,或者用 useMutation 做读操作。记住一个简单原则:不改变服务端状态用 query,改变状态用 mutation。
另一个细节是乐观更新(optimistic update)。比如点赞功能,用户点一下要立刻看到数字变化,不能等服务器返回。tRPC 的 useMutation 支持onMutate里手动更新缓存:
const likeMutation = api.post.like.useMutation({ onMutate: async ({ postId }) => { await utils.post.byId.cancel({ postId }); const prev = utils.post.byId.getData({ postId }); utils.post.byId.setData({ postId }, (old) => old ? { ...old, likes: old.likes + 1 } : old ); return { prev }; }, onError: (err, vars, ctx) => { utils.post.byId.setData({ postId: vars.postId }, ctx?.prev); }, });这段代码的逻辑是:先取消正在进行的查询避免覆盖,保存旧数据,乐观更新,出错时回滚。乐观更新能极大提升交互体验,但一定要写回滚逻辑,否则出错后界面和数据不一致,用户会以为操作成功了。
4. 完整实操流程:从零搭一个可用的 t3code 项目
4.1 环境准备与依赖安装
先说环境。Node.js 版本建议 18 以上,我实测 20 LTS 最稳。包管理器用 pnpm,因为 t3code 类项目依赖多,pnpm 的硬链接机制能省不少磁盘和安装时间。初始化命令大致是:
pnpm create t3-app@latest my-app cd my-app交互式问答里,我的选择通常是:TypeScript 选是、tRPC 选是、Prisma 选是、NextAuth 选是、Tailwind 选是。App Router 选是。生成完之后pnpm dev跑起来,能看到默认页面就说明骨架没问题。
数据库方面,本地开发我推荐用 Docker 起一个 PostgreSQL,比装本地服务干净:
docker run --name t3-postgres -e POSTGRES_PASSWORD=devpass -p 5432:5432 -d postgres:16然后把连接串写进.env的DATABASE_URL。注意.env一定要加进.gitignore,我见过把数据库密码提交到仓库的惨案。
4.2 定义数据模型并生成迁移
假设我们要做一个简单的博客,先写schema.prisma:
model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) } model User { id String @id @default(cuid()) name String posts Post[] }然后跑pnpm prisma migrate dev --name init。这个命令会做三件事:生成迁移 SQL 文件、应用到数据库、重新生成 Prisma 客户端。每次改 schema 都要跑一次,别手动改数据库,否则迁移历史会乱。
4.3 编写第一个 tRPC router
在src/server/api/routers/post.ts里写:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ all: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" } }); }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1) })) .mutation(({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), });写完记得在root.ts里注册:post: postRouter。这一步漏了的话前端调用会报"找不到过程",新手经常卡在这。
4.4 前端页面接入
在 App Router 里,服务端组件可以直接调用 tRPC:
// src/app/posts/page.tsx import { api } from "~/trpc/server"; export default async function PostsPage() { const posts = await api.post.all(); return ( <ul> {posts.map((p) => ( <li key={p.id}>{p.title}</li> ))} </ul> ); }注意这里是~/trpc/server而不是~/trpc/react。服务端组件用 server 版本,客户端组件用 react 版本。混用会报错,这是 App Router 里最常见的困惑之一。
客户端组件里创建帖子:
"use client"; import { api } from "~/trpc/react"; export function CreatePost() { const utils = api.useUtils(); const create = api.post.create.useMutation({ onSuccess: () => utils.post.all.invalidate(), }); // ...表单逻辑 }onSuccess里invalidate的作用是让列表数据失效并重新拉取,这样新建的帖子立刻出现在列表里。
4.5 部署前的检查清单
上线前我一般会过一遍这几项:环境变量是否都配了(尤其是DATABASE_URL和NEXTAUTH_SECRET)、pnpm build是否通过、Prisma 迁移在生产库是否已应用(用prisma migrate deploy而不是dev)、有没有把console.log里的敏感信息清掉。这几项看着简单,但每次部署翻车的十有八九就是它们。
5. 常见问题与排查技巧实录
5.1 类型推导失效怎么办
最常见的情况是前端调用 tRPC 时input或返回值变成any。原因通常是root router 没有正确导出类型,或者trpc/react里的类型引用路径写错了。检查root.ts是否export type AppRouter = typeof appRouter,以及前端trpc.ts里是否import type { AppRouter } from "~/server/api/root"。这两个地方对不上,类型链就断了。
5.2 "Cannot read properties of undefined" 的排查思路
这个报错在 t3code 项目里高频出现,八成是服务端组件里用了客户端 hook,或者在组件渲染前访问了还没加载的数据。排查方法:先看报错堆栈指向哪个组件,确认它是 server 还是 client 组件。如果是 client 组件里调api.xxx.useQuery(),确保它被包在TRPCProvider里。App Router 下 provider 通常放在layout.tsx。
5.3 数据库连接池耗尽
开发时热重载会反复创建 Prisma 客户端,导致连接数暴涨。解决办法是在db.ts里用全局单例:
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这段代码的作用是开发环境下复用同一个客户端实例,避免每次热重载都新建连接。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 前端调用报"过程不存在" | router 没在 root 注册 | 检查 root.ts 合并 |
| 类型全是 any | AppRouter 类型没导出或引用错 | 检查类型导出链 |
| 登录后 ctx.session 为空 | session 回调没配 user.id | 检查 NextAuth 配置 |
| 构建时报 Prisma 找不到 | 没跑 generate | 加prisma generate到构建脚本 |
| 样式不生效 | Tailwind content 路径没覆盖 | 检查 tailwind.config |
5.5 几个我踩过的坑
第一个坑是在 Server Component 里用useState,直接报错。记住 Server Component 不能有交互状态,需要状态就拆出 Client Component。
第二个坑是Zod schema 和 Prisma 类型不一致。比如 Zod 里title是可选,Prisma 里是必填,运行时才报错。建议 schema 定义时和数据库字段对齐,别偷懒。
第三个坑是忘记 invalidate 缓存。mutation 成功后列表不更新,用户以为没保存成功。养成习惯:每个 mutation 的 onSuccess 都想想需不需要 invalidate 哪些 query。
6. 关于 t3code 这套实践,我个人的几点体会
用 t3code 这套东西做了几个项目之后,我最大的感受是:它的价值不在于某个具体工具,而在于"类型贯穿全栈"这个理念。TypeScript、tRPC、Prisma、Zod,它们各自解决一部分问题,但组合起来形成了一条从数据库到 UI 的完整类型链。这条链一旦建立,改需求时的那种"改一处、编译器告诉你还有哪几处要改"的体验,是真的能省下大量调试时间。
当然它也不是银弹。项目小的时候,这套东西的配置成本可能比收益还高;团队里如果有人不熟 TypeScript,学习曲线也不平缓。我的建议是:先在一个中等规模、自己完全掌控的项目里试,跑通了再往团队推。别一上来就在核心业务上硬套,那样出问题会很被动。
最后分享一个小技巧:tRPC 的useUtils里有个prefetch,可以在服务端组件里预取数据,客户端组件用useQuery时就能直接命中缓存,首屏体验会好很多。这个细节官方文档提得不多,但实际项目里很实用,值得试试。