ARTICLE DETAIL

资讯详情

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

打造t3code:基于T3 Stack的全栈类型安全样板工程

打造t3code:基于T3 Stack的全栈类型安全样板工程

1. t3code的选型逻辑:为什么我把筹码押在了T3技术栈上

年初的时候,朋友找我帮忙搭一个小型团队管理系统,需求倒不复杂——成员管理、任务跟踪、数据看板。真正难受的是他们那套老系统:前端Vue2、后端Java、接口文档靠口头约定,改一个字段能拉扯半小时。我那时候就在想,能不能用一个统一的技术标准把这套链路整个收编了。后来我花了两周时间,基于T3技术栈从头搭了一套完整的全栈样板工程,名字就叫t3code。

先解释一下t3是什么。T3 Stack这个概念来自create-t3-app,核心是三个字母:TypeScript、TailwindCSS、tRPC,加上Next.js作为框架底座。它不是一个官方框架,而是一套社区认可的、强调端到端类型安全的技术组合。最早是Theo Broussard在社区里推起来的,主张用最小依赖、最少样板代码,把前后端类型安全做到极致。t3code这个名字没有官方出处,它是我给自己这套工程起的代号——T3标准下的完整可复用代码库,你可以把“t3”理解为技术底座,“code”理解为业务代码和工程实践,合在一起就是一套能吃透、能复制、能上生产的全栈模板。

为什么选这套组合而不是其他方案?我当时的对比维度有几个:类型安全覆盖的深度、上手成本、生态成熟度、以及部署维护的复杂程度。如果你用传统方案,比如Next.js配REST API、再配一坨手写的类型定义,前后端类型不联动已经是常态——后端改了字段,前端编译期毫无感知,等联调跑起来才发现崩了。如果上GraphQL,类型联动是解决了,但引入Schema、Resovler、Codegen一整套额外工具链,对于一个小团队来说成本不低。而tRPC这套思路更朴素:你直接在服务端定义函数,客户端像调用本地函数一样调用它,类型推断从服务端函数签名直接流向客户端,中间没有任何手写契约。这是我选T3最核心的理由——类型安全不是靠规范约束出来的,而是靠架构设计出来的。

除了技术合理性,我还考虑了工程可持续性。Next.js作为React全栈框架,Vercel商业化运营多年,文档、生态、社区支撑都足够稳定;TypeScript做静态类型、Prisma做ORM、Tailwind做样式——每一层都有明确的替代方案和不低的迁移成本,但它们组合在一起,就是一个非常顺滑的开发体验。t3code这套代码跑起来之后,我从零开始实现业务模块到部署上线,整体体感是“路径依赖非常强”——需求一进来,我就能立刻猜到数据模型该长什么样、代码文件该落在哪个目录、接口该在哪个router里声明。这种确定性,对日常开发效率的提升是实打实的。

需要提醒的是,t3code并不追求什么高深复杂的技术创新,它更像一个“把正确技术在一套工程里落到位”的样板。适合的读者有两类:一是想快速搭一套全栈应用、又不想被前后端接口联调拖慢的独立开发者;二是团队打算统一技术栈、想有参考模板的前端或全栈工程师。后面我会把t3code从初始化、数据建模、业务实现、到部署上线的完整过程和踩坑记录拆开来讲。

2. 工程初始化:create-t3-app之后我做了什么调整

2.1 初始化命令和模块选择

t3code的第一步是用create-t3-app建的壳。命令很直接:

npm create t3-app@latest t3code

执行之后交互式界面会问你选哪些模块。我的选择是:Next.js、TypeScript、Prisma、tRPC、TailwindCSS、ESLint。没勾NextAuth——原因是t3code这套样板在多数场景下只需要一个简单的会话身份标识,NextAuth对于内部小工具来说配置负担偏重,我后面会单独用一套基于JWT或Cookie的轻量鉴权方案。也没勾Docker,因为初期部署目标是Vercel或单台云主机,容器化不是刚需,等项目确实要迁K8s了再补也不迟。

这里有一个容易忽略的细节:create-t3-app初始化出来的项目已经有非常完整的目录结构、依赖版本和ESLint配置,但它默认装了ts-rest没有装任何数据请求库(React Query是后来以tRPC依赖形式带进来的)。如果你平时习惯在项目里push一堆自己的工具函数,建议先别——保持t3code初期“只用官方模板给的”这个原则,跑顺了再逐步自定义。

2.2 环境变量的类型校验

初始化之后,模板会在.env.example里给你列出所有环境变量,比如DATABASE_URL、NEXTAUTH_SECRET(如果选了NextAuth)。t3code做了一个我认为最值得保留的做法:用zod校验环境变量。模板里有个src/env.mjs文件,它会读取process.env、按你定义好的zod schema做解析和类型推断,让所有环境变量在使用时都是类型安全且经过校验的。

import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.string().min(1), NODE_ENV: z.enum(["development", "test", "production"]), }); const env = envSchema.parse(process.env); export default env;

我当时改动了几处:一是把NODE_ENV的枚举补上了test,方便跑单元测试;二是新增了一些业务侧环境变量,例如OSS的Bucket名称、Redis连接串等。这个文件是t3code的“环境变量总闸门”,后续所有新增变量必须先在这里登记,否则编译直接fail——团队的同事一开始不习惯这种“强约束”,但习惯之后他们自己都承认,不再出现那种生产环境少配了一个key导致半夜告警的事故了。

2.3 目录结构的落地约定

create-t3-app的模板目录已经很清晰,t3code在这之上做了一套更偏业务的分层:

  • src/server/api/routers/:所有tRPC路由按领域拆分,比如user.ts、article.ts、dashboard.ts;
  • src/server/db/:Prisma client实例和数据库相关工具;
  • src/server/auth.ts:自定义的鉴权上下文逻辑;
  • src/components/:页面级的UI组件,按业务模块建子目录;
  • src/pages/或src/app/:Next.js路由层(取决于用Pages Router还是App Router,模板默认已切到App Router);
  • src/trpc/:服务端和客户端的tRPC初始化逻辑。

我给团队定的“铁律”是:业务卡片只能出现在两个位置——src/server/api/routers/(后端逻辑)和src/components/(前端渲染),其他位置的代码尽量只承接数据流转和复用能力。这样做的原因是,当需求需要改动时,你从UI所在目录能倒推出对应的router,代码检索路径非常短。后期t3code增加了几个模块,同事说最爽的体验就是“改一个字段能从数据库到页面全程用IDE跳转追完,不需要全局搜索”。

3. 数据库建模与迁移:Prisma Schema设计的真实链路

3.1 数据模型规划

t3code里我设计了一套贴近“团队任务管理”场景的数据模型。以核心的两张表为例:

  • User(用户):id、email、name、avatarUrl、role、createdAt、updatedAt;
  • Task(任务):id、title、description、status(枚举:pending/doing/done)、assigneeId(关联User)、creatorId、priority、dueDate、createdAt、updatedAt。

在写Prisma Schema之前,我用表格把这套关系梳理了一遍:

关系类型模型说明
一对多User 1->N Task一个用户可以负责多个任务
一对多User 1->N Task(creator)创建者和负责人可能是不同人
多对多Task <-> Tag任务打多个标签,标签可复用

Schema里的任务关联用了@relation("Assignee")和@relation("Creator")两种命名关系,来区分用户与任务之间的两个语义维度。

model User { id String @id @default(cuid()) email String @unique name String? tasks Task[] @relation("Assignee") createdTasks Task[] @relation("Creator") } model Task { id String @id @default(cuid()) title String description String? status Status @default(PENDING) assignee User? @relation("Assignee", fields: [assigneeId], references: [id]) assigneeId String? creator User @relation("Creator", fields: [creatorId], references: [id]) creatorId String dueDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这个设计看似简单,但里面有个值得注意的点:assigneeId是可选,creatorId必填。因为任务可以先被创建出来、暂时不指派,而创建者必须存在。字段可空性和业务语义是一一对应的,不要为了省事全设成必填或全设成可选,那会导致业务边界模糊。

3.2 索引与查询路径的对齐

很多人写Schema容易漏掉索引。t3code里我明确要求:每个@relation的外键字段、以及高频查询的过滤字段都必须建索引。Prisma Schema中默认会为@unique字段建索引,但普通字段不会。比如Task的assigneeId如果没有索引,那么按负责人拉任务列表时,数据库就需要全表扫描。数据量小的时候没啥感觉,到了几万条记录就明显变慢。

我在Schema里补了几个复合索引:

model Task { // ... @@index([assigneeId, status]) @@index([creatorId, createdAt]) }

@@index([assigneeId, status])直接覆盖了最典型的查询:某个用户当前负责的各种状态任务列表。复合索引的字段顺序很重要——左侧的字段用于等值过滤,右侧的用于范围或排序,如果顺序反了,这个索引基本就废了。为这事我还吃过亏,早期给Task建过@@index([status, assigneeId]),结果查WHERE assigneeId = ? AND status = ?时索引失效,后来排查执行计划才发现顺序问题。

3.3 迁移与种子数据的实操细节

Schema写完之后,跑迁移:

npx prisma migrate dev --name init

这个命令会做两件事:生成一个新的SQL迁移文件,并重新生成Prisma Client。本地开发阶段建议每次调整Schema后都跑一次,让迁移历史保持线性的可追溯状态。注意一个常见坑:如果你在团队协作中,别人改了Schema而你本地没拉迁移文件就启动服务,Prisma Client版本和数据库状态不匹配会直接报错,这时先git pull再跑npx prisma migrate dev即可。

种子数据我用了一个独立的TS脚本,配合tsx运行:

// prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.create({ data: { email: "dev@t3code.dev", name: "T3 Developer", }, }); await prisma.task.create({ data: { title: "First task", status: "PENDING", creatorId: user.id, }, }); } main().finally(() => prisma.$disconnect());

种子脚本的价值不只是造几条假数据,而是给前端的列表、详情、状态切换等交互提供真实的联调素材。我建议种子数据尽量贴近真实业务——字段值该长则长、该短则短,比如任务描述里可以故意放几段超长文本,顺便验证UI的截断与换行表现。跑种子的命令:npx prisma db seed,数据库里立刻有一组可以直接展示的数据,省去一个个手填的时间。

4. tRPC路由设计与边界校验:类型安全是如何穿透全栈的

4.1 路由拆分与命名规范

tRPC在t3code里的组织方式,是整套工程的心脏。模板默认在./src/server/api/routers/下有post.ts和root.ts两个示例文件,我把它们清空后按自己的业务域建了三个router:userRouter、taskRouter、dashboardRouter。

业务域的拆分标准不是“数据库表越多越好”,而是“调用方视角的一致性”。比如dashboard虽然底层要聚合任务、成员、统计三类数据,但我仍然单独建了一个router——因为它面向的是“看板页面”这一套UI需求,聚合逻辑放在一个入口里,前端调用更加直观,类型返回也更加可控。

每个router内部我定义了明确的procedure:

export const taskRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ userId: z.string().optional(), status: z.enum(["PENDING", "DOING", "DONE"]).optional() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { assigneeId: input.userId, status: input.status, }, orderBy: { createdAt: "desc" }, include: { assignee: true, creator: true }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), description: z.string().max(1000).optional(), assigneeId: z.string().optional(), dueDate: z.date().optional(), })) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, description: input.description, assigneeId: input.assigneeId, dueDate: input.dueDate, creatorId: ctx.session.userId, }, }); }), });

对于routers的命名我自己有一条“一眼看懂”的规则:router文件名用单数名词且与数据库模型名保持一致,taskRouter、userRouter,下层procedure用动词或工业界习惯的动宾结构,list、create、update、delete、stats。debug的时候看到路由名就能猜到作用,减少沟通成本。

4.2 Zod输入校验:服务端边界的第一道防线

tRPC的输入校验默认接zod,这也是t3code类型穿透的关键一环。在procedure上挂一个input定义,类型会自动推断到前端调用处的参数类型。比如task.create的入参类型是{title: string; description?: string; assigneeId?: string; dueDate?: Date},前端调用时如果传入title: 123,TS编译期直接报错,不经任何运行时判断。

但这里有一个zod用得不好就容易踩的坑:日期类型。如果你用z.date()校验dueDate,前端传JSON序列化时经常变成ISO字符串,运行时zod会直接拒绝。实际项目中更稳妥的做法是定义成z.iso.datetime()或先z.string()再在service层转换。t3code里我用的是z.iso.datetime().pipe(z.coerce.date()),把ISO字符串解析成Date对象,兼顾了传输格式和存取格式的一致性。

还有一点值得展开:校验不只是判空,而是要“把约束写进代码”。t3code中我给每个procedure的输入校验都定义了min/max长度,比如title最多100字、description最多1000字。这不仅仅是为了用户体验,也是为了数据库层面的安全——Postgres虽然对text类型没有限制,但超长内容会导致存储膨胀和索引失效。校验写在入口处,比在下层每个查询里做防御要省事得多。

4.3 受保护的Procedure与鉴权上下文

t3code里所有需要登录态的procedure都用了protectedProcedure。它在模板的src/server/api/trpc.ts里定义,逻辑很直白:请求进来先走一个中间件,检查ctx.session是否存在用户ID,不存在则抛UNAUTHORIZED错误,存在就放行。

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

这里的ctx是怎么注入的?在createContext里,t3code使用Next.js的getServerSession(或自定义的cookie解析)读取请求中的会话信息。我之前自己写过一套基于JWT的方案,核心是在登录接口里签发token,前端每次请求带上cookie,服务端解析后把userId塞进session。因为t3code内部项目不依赖OAuth的社交账号体系,这套轻量方案比引入NextAuth更直接。

鉴权上下文还承担了一个职责:把“会话身份”变成“业务查询的隐式过滤条件”。例如task的list接口,理论上传入任意userId就能看别人的任务,但实际中绝大多数场景都是“查看当前登录用户的任务”。t3code里如果入参没传userId,默认会用ctx.session.userId作为过滤条件,这样前端调接口少传一个参数,又不会因为疏漏泄露全量数据。这是控制访问范围的一个低成本手段。

4.4 客户端调用链路上的真实调试体验

t3code前端调用的模式,相比传统REST有一个很大的体验差:不需要维护任何API URL、请求方法、参数拼接。React组件里直接用api.task.list.useQuery({status: "PENDING"}),类型、返回值、loading状态全部自动推导。这个“无URL”的开发体验,我用了一个多月之后,再回去写REST接口竟然有点不习惯。

但也不是没有坑。最常见的调试问题有两个:

第一,服务端抛错后前端的错误信息非常笼统。tRPC默认会把error序列化成TRPCClientError,如果你在服务端抛了自定义message,前端只能拿到一个message字段,堆栈和状态码容易丢。建议在中间件里做一层错误格式化,把code、httpStatus、path都带上:

function errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, stack: process.env.NODE_ENV === "development" ? error.stack : undefined, }, }; }

第二,React Query的缓存策略。useQuery默认是stale-while-revalidate,即数据不是实时刷新而是后台更新。如果你在任务创建成功之后立刻查询列表,很可能拿到的是旧缓存。正确做法是使用utils.invalidate()手动让列表失效:

const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: async () => { await utils.task.list.invalidate(); }, });

这两个小坑是t3code实际开发中前三天必踩的项目之一,提前知道能省半天时间。

5. 部署与上线前的最后检查:从构建优化到生产配置

5.1 部署平台的选择

t3code的目标部署环境取决于项目规模。如果是给团队内部用的小工具,我建议优先Vercel——因为它和Next.js天然契合,推送代码即部署,环境变量在面板上配置即可。如果项目涉及内网数据库、或数据不能出网,那就迁到一台云主机上自托管,跑next start或Docker化部署。

两种方式的取舍,我列一个简单的对比:

维度Vercel自托管云主机
部署速度秒级分钟级(需构建+重启)
静态资源CDN自带需自己接CDN
数据库连通性需公网访问可走内网
运维成本低,几乎为零高,需自己守护进程、日志、备份
适用场景对外demo、SaaS企业内网、私有数据

t3code最终在Vercel上跑了两个星期,后来因为数据库在内网,迁回了云主机。迁移过程很简单——把生产环境变量配到.env.production,然后npx prisma migrate deploy(注意生产环境不能用dev命令,它会在数据库变更时要求交互确认),再构建启动。

5.2 生产环境变量与Prisma迁移的正确姿势

这里有一个很多人踩过的坑:直接在云主机上跑npx prisma migrate dev。这个命令是面向开发环境的,会在你不知情时重置数据(如果有冲突),千万不要在生产环境用。正确做法是:

  1. 本地生成迁移文件并提交到Git仓库;
  2. 部署时在生产目录执行npx prisma migrate deploy,它只应用尚未执行的迁移文件,不触碰已有数据;
  3. 启动服务前确认Prisma Client版本和迁移历史里的版本一致。

还有一点,生产环境的DATABASE_URL尽量使用连接池串,例如用PGBouncer配套的?pgbouncer=true参数(Prisma官方推荐)。直接用裸连接串在高并发场景下会打满Postgres的连接数,表现为间歇性超时。这个问题在一台小机器上不明显,但一旦压测到100并发就暴露得很彻底。

5.3 构建体积与页面性能

Next.js默认的构建输出已经在做路由级代码分割。t3code里我再做了三件具体的事来压体积:

  • 所有UI组件库组件改为按需导入,不整包引@radix-ui/*;
  • 在next.config.mjs里开启reactStrictMode和compiler的removeConsole(生产环境剔除console.调用);
  • 使用next/dynamic懒加载非首屏的大组件,比如图表模块(ECharts体积较大)。
// next.config.mjs const nextConfig = { reactStrictMode: true, compiler: { removeConsole: process.env.NODE_ENV === "production", }, };

用next build之后观察产物,首屏JS从默认模板的180KB压到了130KB左右(gzip后),对于t3code这种中后台工具来说已经够用了。Lighthouse的Performance跑下来,桌面端在95分以上,移动端因为首屏包含表格和图表,分数稍微低一点,约80分。核心瓶颈反而是图片和字体——如果后续要追求更高分,得换CDN图片和预加载字体,这属于锦上添花了。

5.4 上线后的监控闭环

t3code上线后我在监控上做了三件事:

第一,接入了日志上报。服务端在errorFormatter里把TRPCError的堆栈输出到标准输出,前端加了window.onerror和unhandledrejection将错误POST到一个自定义的日志接口,存到一张app_log表。虽然原始,但比完全盲跑好得多。

第二,数据库慢查询日志。Postgres的log_min_duration_statement设成500ms,超过500ms的SQL都会打到日志里。上线第一天就抓到了两个没走索引的查询,顺手在Schema里补了索引。

第三,定期检查任务积压数。因为t3code的业务核心是任务状态流转,我写了一个简单的dashboard接口,统计每个状态的task数量,每5分钟跑一次,如果pending数量持续上涨说明业务处理有积压。这个“业务健康指标”比单纯看CPU和内存更贴合这个项目的实际情况。

6. 给后来者的复盘清单:t3code踩过的坑和对新框架的控温建议

我在t3code这个项目上踩过的坑不少,挑几个有代表性的复盘一下,给打算做类似T3全栈工程的朋友提个醒。

第一个坑是tRPC的错误处理链路比想象中长。刚开始我以为类型安全能挡住大部分错误,后来发现类型安全挡的是“字段结构错误”,挡不住“业务状态冲突”。比如任务从DONE状态不能直接回退到PENDING,这类规则需要在service层单独校验,zod只能解决入参形状,解决不了状态机的约束。后来我用一个专门的validateStatusTransition函数处理状态流转,才把这类问题收口。

第二个坑是React Query缓存失效的时机。T3生态里tRPC和React Query是深绑定的,useQuery的缓存确实香,但如果你在多个页面共享同一个query key,在A页面改了数据,B页面的缓存不会自动失效。t3code里我研发了一套约定:所有写操作(mutation)的onSuccess回调里,必须invalidate对应领域的所有query,宁可多失效也不留脏数据。刚开始同事觉得麻烦,后来发现不写invalidate的后果更麻烦——页面上数据显示陈旧,用户刷新了才好,体验非常割裂。

第三个是数据库索引别照着前端查询写。早期t3code的索引设计完全按“前端可能传什么参数”来建,比如给status和priority分别建了独立索引。后来发现实际查询往往是组合条件,单列索引发挥不出作用。调整成复合索引之后,慢查询数量降了一个数量级。这里我的经验是:索引设计必须基于真实的查询模式,而真实查询模式要通过日志和慢查询分析来确认,不要靠猜。

第四个是关于“新框架热情”的控温建议。T3这套组合非常顺滑,顺滑到有时候你会有一种“所有问题都被解决了”的错觉。但实际上,Prisma强在CRUD和类型安全,弱在复杂聚合;tRPC强在端到端类型,但如果你要做公开API给第三方调用,它反而没有REST或GraphQL直观,因为第三方不一定用TypeScript。所以我在t3code里留了一条设计原则:只要是面向内部系统的接口,优先用tRPC;如果有朝一日要对外暴露接口,可以考虑在tRPC之上再包一层REST路由,或者单独抽一个API服务。技术选型永远是在边界内做权衡,而不是追着某个框架跑。

最后说一下我的个人体会。t3code这套样板真正改变我日常开发习惯的,不是某个框架或工具,而是“类型安全从数据库一路穿透到页面”这个体验。后端改了Schema、前端连字段名都能跟着变,不需要对着接口文档憋半天;新增一个查询、调整几个过滤条件,IDE的补全直接把可选项列出来。对这种“少了一堆琐碎联调”的爽感,一旦习惯了就很难回去了。不过我也要提醒:t3code适合做内部工具、中小型全栈应用,如果业务复杂度上去、团队人数多了,还是需要在上层引入更完整的流程约束。工具能帮你省掉一部分精力,但它不会替你思考业务边界。

返回列表