ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code 全栈解决方案:端到端类型安全实战指南

t3code 全栈解决方案:端到端类型安全实战指南

1. 项目整体拆解:t3code 到底在解决什么问题?

1.1 什么是 t3code,它和常规脚手架有什么区别

先说结论:t3code 是我近期整理的一套基于 T3 Stack 核心思路的前端全栈项目快速搭建方案,也可以理解成一套带最佳实践的工程模板。它的名字看起来像一个代码仓库代号,实际上强调的是"Type-Safe Code",也就是全程类型安全的编码链路。

做过全栈开发的朋友应该都体会过这种状态:后端写好了接口,前端拿着接口文档开始 mock 数据,联调阶段对字段、改类型、调参数,一套流程下来,时间至少浪费三分之一。t3code 想解决的核心问题,恰恰就是这种前后端协作中的“类型割裂”和“接口文档漂移”。

我接触过很多脚手架和样板项目。传统的脚手架,比如前端 vue-cli、后端 express-generator,本质上是“目录结构生成器”——把文件夹和基础文件铺好,之后怎么做全凭个人。t3code 的核心思路不一样,它把类型安全贯穿到整条业务链路上,从数据库表结构、服务端接口、RPC 调用到前端页面状态,全部由 TypeScript 的类型系统打通。用一句大白话说:数据库里定义了什么字段,前端组件里拿到的就是什么类型,中间任何一层都不会出现“文档说返回 Id,代码里却是 ID”这种低级问题。

具体而言,这套方案以 Next.js 作为应用框架,支撑页面渲染和 API 路由;用 tRPC 替代传统的 RESTful 接口层,让前后端直接共享类型定义;用 Prisma 作为数据库 ORM,确保数据库表和 TypeScript 类型之间的映射;再用 Tailwind CSS 解决样式层面的工程化问题。这套组合就是社区里常说的 T3 Stack,t3code 相当于是 T3 Stack 的落地代码集合体,比抽线教程更完整,比官方 starter 多了一些业务侧的实战经验。

1.2 技术选型背后的取舍:为什么偏偏是 Next.js + tRPC + Prisma + Tailwind

很多人在选型时喜欢追新、追多。我的建议是反向思考:先定清楚应用类型、团队水平和维护周期,再回来选工具。t3code 最终选定的每一项,背后都有明确的取舍逻辑。

第一,框架选 Next.js,而不是纯 Vite SPA 或者纯后端模板。原因很直接:全栈应用需要服务端渲染的位置,比如首屏加载、SEO 抓取、社交分享卡片,都需要一些服务端能力。Next.js 的 App Router 既能写后端逻辑又能写前端组件,RSC 和 Server Actions 也让前后端的边界变得更加灵活。如果用纯前端框架,遇到需要隐藏业务逻辑的接口就得再单独搭一个 Node 服务,维护成本翻倍。选 Next.js 可以把一部分后端逻辑放在同一套代码库里,但又不至于像传统单体那样僵硬。

第二,交互层选 tRPC,而不是 REST API 或者 GraphQL。这个选择可能是 t3code 使用者最需要理解的一点。REST API 最大的问题不是不能做类型安全,而是需要额外的手动代码生成或者重复定义类型。就算你可以用 openapi-generator 把后端 schema 转成前端类型,也只是“接口层的类型同步”,一旦涉及鉴权上下文、错误处理、批量查询,REST 的细分表达能力就不够用了。GraphQL 解决了一部分问题,但又引入了复杂的 schema 学习和客户端缓存管理成本。tRPC 的思路是在不发明新协议的前提下,用 TypeScript 的 typeof 和 inference 能力,直接把 router 的类型推断给客户端。好处是什么都不用配,写服务端代码的同时,客户端自动获得类型。坏处也是明显的:要求全项目 TypeScript 覆盖,不能有 JS 文件乱入。

第三,数据库层用 Prisma。其实 TypeORM 和 Drizzle 也能做类似的活儿,但 Prisma 的 schema 文件自带可读性,迁移工具成熟,开发环境下初始化数据库非常省心。平时我们写 SQL 表结构设计还得分心考虑迁移脚本怎么写,Prisma 直接生成所有 DDL,Ctrl+S 存一下 schema 文件就自动同步。

第四,样式层锁定 Tailwind CSS。原因比较朴素:在多人协作的场景下,Tailwind 的原子类天然防止了样式互相覆盖的问题,也减少了“组件类名冲突导致 UI 错乱”这种低级 bug。虽然有些人觉得 Tailwind 写起来像内联样式,但配合组件封装其实非常高效,尤其是做响应式布局,断点在类名里一键控制,不用总是切文件。

从整体上看,t3code 不是追求某一单项性能的极限,而是追求“开发体验”和“类型安全”的综合收益。这几个工具组合在一起,正好覆盖了全栈应用从数据库到浏览器样式层最核心的链路,任何一项都是经过生产验证的,没有特别实验性的东西。

1.3 t3code 适合谁,不适合谁

先说适合的群体。如果你要做的项目是:中小型 SaaS 应用、企业内部工具、个人全栈产品、或者教育类演示项目,t3code 非常合适。这类项目的特点是数据模型不会特别复杂,团队规模也不大,但业务需求变化快,需要快速迭代。类型安全在这种场景下收益最大——因为代码频繁改动,类型错误能在编译阶段暴露问题,而不是上线之后接口报错才发现字段对不上。

如果完全不了解 TypeScript,之前只用过 JavaScript,直接上手 t3code 会有一定的学习曲线。不是说完全学不会,而是你会在 tRPC 的类型推断环节搞不清楚原理,遇到问题也不知道去哪排查。建议先把 TypeScript 泛型基础补一补。

如果项目本身是高并发、大数据量、海量微服务拆分,t3code 这套单体全栈架构就不可能继续适用于你。微服务的场景下,跨服务调用还是得走 HTTP/gRPC,不能指望 tRPC 对面的服务端也用 TypeScript。这一点大家在选型时就要判断清楚,不要为了技术优雅而牺牲架构合理性。

2. 由浅入深拆解 t3code 的项目结构

2.1 目录设计:模块边界放在哪,公共代码放在哪

t3code 的目录结构不是普通脚手架那种一眼望到底的src/views、src/router扁平排布。它刻意划分了几个边界敏感的层次,日常开发几乎不需要犹豫某个文件放哪。

先看顶层模块:

  • src/app:Next.js App Router 的页面和路由层,一般只放页面组件和路由布局,不写业务逻辑。
  • src/server:所有服务端代码,包括数据库操作、鉴权逻辑、业务服务、RPC 路由注册。
  • src/trpc:tRPC 相关的 router 组装、context 创建、类型导出,相当于前后端类型链路的桥梁。
  • src/components:前端可复用的 UI 组件,重点是无状态、无业务逻辑的展示组件。
  • src/lib:不区分前后端的通用工具,比如日期格式化、枚举定义、常量配置。
  • src/styles:全局样式入口,Tailwind 的配置和少量自定义 CSS。

这种分层的核心好处,是把“会触及数据库的代码”和“只负责展示的前端代码”严格区分开。你不会在页面组件里直接写 Prisma 查询,也不会在服务端 router 里写 UI 逻辑。对新手来说,这像一个路标系统,跟着路径走,代码自然归位。对于老手来说,这种约束减少了 Code Review 时讨论“这部分应该放哪”的无效沟通。

2.2 tRPC 路由的组织方式:单文件还是多文件拆解

很多 tRPC 示例项目只是把一两个查询写在文件里演示,真实项目的路由数量动辄几十个上百个,如果全部堆在root.ts里就不可维护了。t3code 的做法是垂直拆分,按领域模块建路由文件。

举个例子,一个包含用户、订单、商品三个模块的项目,目录大概是这样的:

  • src/server/api/routers/user.ts:用户注册、登录状态、个人资料查询
  • src/server/api/routers/order.ts:订单列表、订单详情、创建订单
  • src/server/api/routers/product.ts:商品分页查询、商品详情、库存更新
  • src/server/api/root.ts:汇总所有子路由并生成 appRouter 实例

每个独立路由文件内部使用createTRPCRouter定义,内部再细分query和mutation。query 负责读数据,mutation 负责写数据。这个概念对应 REST 里的 GET 和 POST/PUT/DELETE,但比 REST 更严格:一个 query 绝对不应该修改数据库,一个 mutation 也绝对不应该只是无副作用的查询。

在管理大批路由时,有一个小技巧:不要试图把所有路由放在同一个文件里反复横向滚动。一个路由文件超过 200 行,就开始考虑是否拆成更细的模块。比如订单路由拆成order.query.ts和order.mutation.ts,虽然多了一个文件,但每次改动时定位更加精准。

2.3 类型导出的双向链路:前端如何拿到服务端类型

t3code 的核心亮点之一,是前端组件可以直接 import 服务端的类型。这个能力依赖于 TypeScript 的类型推断,而不是运行时数据传输。原理链路如下:

服务端定义 router → TypeScript 根据 router 的定义推断出 input 类型和 output 类型 → 前端通过import type { AppRouter } from '../server/api/root'拿到类型 → tRPC client 实例根据 AppRouter 泛型生成带类型的 hooks。

这就像两个人在不同楼层办公,但他们共享同一张办公桌的抽屉钥匙:无论哪一边改了抽屉的排列,另一边用钥匙打开时,新布局自动出现在眼前。相比之下,REST API 的接口文档更新经常滞后于代码实现,前端发现字段变了只能靠自己猜。

有一点需要注意:类型导入必须用import type,不能直接用import。如果写成普通 import,TypeScript 可能会要求该模块被实际执行,导致服务端代码被拉到客户端 bundle 里,这是新手经常踩的坑。虽然现在的打包工具会做一些 tree-shaking,但养成写import type的习惯是最保险的。

2.4 环境变量与服务端配置:不要把密钥塞进前端代码

任何一个真实项目都必须认真处理环境变量。t3code 把环境变量分为公开和私密两类。公开的以NEXT_PUBLIC_开头,可以在客户端代码里直接访问;私密的没有这个前缀,只能通过 Next.js 服务端读取。举几个典型变量:

  • DATABASE_URL:数据库连接字符串,只服务端可用
  • NEXTAUTH_SECRET:会话加密密钥,只服务端可用
  • NEXT_PUBLIC_APP_NAME:站点名称,客户端展示用

配置环境变量时,最忌讳的是把私密信息硬编码在代码里。比如有人为了方便,直接把数据库密码写在src/lib/db.ts里,一旦代码推送到公共仓库就泄密了。也有人在.env.local里存放配置但忘了加入.gitignore,结果密钥被同步到了远端。

我建议在项目根目录准备一份.env.example,里面只放变量名不放真实值,提交到仓库。新同事接手时,复制.env.example为.env.local,再填入本地值,避免因为缺少环境变量导致项目启动不了。

3. 实操全记录:从零到可用,t3code 搭建细节

3.1 初始化项目:命令选择与依赖安装

从零启动一个 t3code 项目,不需要手动逐个拼装依赖,直接用社区的 create-t3-app 脚手架即可,它会把 Next.js、tRPC、Prisma、Tailwind 全部配好。官方命令如下:

npm create t3-app@latest my-app cd my-app npm install npm run dev

如果你愿意多花几分钟探索选项,脚手架创建时会有几个交互式问题。我通常以保证依赖最小化为原则,只勾选必要项:Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。其他功能如 Stripe、Upstash、Docker 暂时不接入,等到真正需要时再添加。

需要注意:create-t3-app 的版本持续更新,初始化后的目录结构可能和本文描述的略有差异,但核心的分层思路不变。不要死记路径,而是理解模块职责。

3.2 数据库模型设计与 Prisma schema

项目初始化后,核心的数据库定义集中在prisma/schema.prisma。第一次跑项目不要马上写一堆模型,先把最小的用户模型建好,后面再迭代。

以一个轻量商品应用为例,schema 文件核心部分长这样:

model User { id Int @id @default(autoincrement()) email String @unique name String? createdAt DateTime @default(now()) orders Order[] } model Product { id Int @id @default(autoincrement()) name String price Decimal stock Int @default(0) description String? createdAt DateTime @default(now()) } model Order { id Int @id @default(autoincrement()) userId Int user User @relation(fields: [userId], references: [id]) total Decimal status String @default("PENDING") createdAt DateTime @default(now()) }

设计时我从一开始就定了几个约定:主键都用自增 Int,不用 UUID 除非有分布式需求;金额字段一律 Decimal 而不是 Float,避免浮点精度问题;时间字段统一DateTime @default(now()),方便后面排序列查询。

写完后执行迁移命令生成数据库表:

npx prisma db push

开发阶段用db push足够,因为它直接同步 schema 而不生成迁移文件。等到接近生产环境,再改用prisma migrate dev生成正式迁移历史。

3.3 定义 tRPC 路由:从查询到变更的完整链路

路由文件是 t3code 的骨架。以商品模块为例,先定义一个最基础的查询路由:

// src/server/api/routers/product.ts import { z } from 'zod'; import { createTRPCRouter, publicProcedure } from '../trpc'; import { prisma } from '../db'; export const productRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { return prisma.product.findUnique({ where: { id: input.id }, }); }), list: publicProcedure .input( z.object({ cursor: z.number().optional(), limit: z.number().min(1).max(50).default(10), }) ) .query(async ({ input }) => { const products = await prisma.product.findMany({ take: input.limit + 1, skip: input.cursor ?? 0, orderBy: { createdAt: 'desc' }, }); let nextCursor: number | undefined = undefined; if (products.length > input.limit) { nextCursor = input.cursor! + input.limit; products.pop(); } return { products, nextCursor }; }), create: publicProcedure .input( z.object({ name: z.string().min(1), price: z.number().positive(), stock: z.number().int().default(0), description: z.string().optional(), }) ) .mutation(async ({ input }) => { return prisma.product.create({ data: input }); }), });

这里做了三件很关键的事情:

第一,所有入参用 zod 定义。zod 不仅做运行时校验,它的类型还能被 tRPC 转成 TypeScript 类型。这样客户端传入参数时,编辑器就能提示字段名和类型。

第二,分页查询使用 cursor 模式,而不是传统页码。cursor 模式在高数据量场景下性能更好,因为它是基于偏移量或者唯一键定位的,不会被中间插入的数据影响结果。虽然上面的示例用了简单的 skip 方式,但接口形态已经为后续切换到真正的 cursor 留了空间。

第三,mutation 和 query 分开,语义明确。create 操作被定义为 mutation,就算客户端误用了 query 调用,类型系统也会直接报错。

在root.ts汇总子路由:

// src/server/api/root.ts import { productRouter } from './routers/product'; import { userRouter } from './routers/user'; import { createTRPCRouter } from '../trpc'; export const appRouter = createTRPCRouter({ product: productRouter, user: userRouter, }); export type AppRouter = typeof appRouter;

3.4 客户端接入 tRPC:hooks 调用与类型提示

服务端代码准备好了,前端调用非常直接。在 Next.js 的组件文件里,只需要引入 tRPC hooks,然后像调用本地函数一样调用远端方法:

'use client'; import { api } from '~/trpc/react'; export function ProductList() { const { data, isLoading, error } = api.product.list.useQuery({ limit: 10, }); if (isLoading) return <div>加载中...</div>; if (error) return <div>加载失败: {error.message}</div>; return ( <ul> {data?.products.map((product) => ( <li key={product.id}> {product.name} - ¥{product.price} </li> ))} </ul> ); }

这句话需要划重点:“调用一个远端方法”和“调用本地函数”的体验在这一刻几乎完全一致。没有 URL、没有 method、没有请求体拼装,类型是天然带过来的。当服务端 product 的 price 类型变成 Decimal 时,前端组件的类型也随之更新,你不可能再犯低级的字段名拼写错误。

如果你需要写操作,调用方式同样高度统一:

const createMutation = api.product.create.useMutation(); async function handleCreate() { await createMutation.mutateAsync({ name: '新款机械键盘', price: 1299, stock: 20, }); }

这比手写 fetch 请求再手动设置 Content-Type、序列化 JSON body 要省太多事了。

3.5 权限控制:从 publicProcedure 到 protectedProcedure

大多数项目不会让所有接口都对匿名用户开放。t3code 里权限控制通过封装不同层级的 procedure 来实现。脚手架默认生成的是publicProcedure,代表任何人可调用。需要登录才能访问的路由,使用protectedProcedure,它的内部会检查会话状态,未登录时直接抛出错误。

在路由文件里写一个登录后才能看到的订单列表:

import { protectedProcedure } from '../trpc'; export const orderRouter = createTRPCRouter({ listMine: protectedProcedure.query(async ({ ctx }) => { return prisma.order.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: 'desc' }, }); }), });

这里的ctx.session.user.id是从 session 里取用户信息,不是前端传上来的用户 ID。这样设计能防止水平越权:无论前端如何构造请求,后端只会返回当前登录用户自己的订单。如果项目里面有基于角色的权限,可以在protectedProcedure基础上再包一层adminProcedure或动态判断,t3code 的封装结构允许你无痛扩展。

3.6 样式层的接入:Tailwind 的原子类使用节奏

很多首次接触 Tailwind 的人觉得它写得乱。其实关键在于使用节奏:先搭结构布局,再用组件封装类名,最后处理细节。

以商品卡片为例,一个收敛得比较舒服的写法是:

<div className="max-w-sm rounded-lg border border-gray-200 bg-white shadow-sm hover:shadow-md transition"> <div className="p-4"> <h3 className="text-lg font-semibold text-gray-800">{product.name}</h3> <p className="mt-1 text-sm text-gray-500 line-clamp-2">{product.description}</p> <div className="mt-3 flex items-center justify-between"> <span className="text-xl font-bold text-indigo-600">¥{product.price}</span> <button className="rounded-md bg-indigo-600 px-3 py-1.5 text-sm text-white hover:bg-indigo-700"> 加入购物车 </button> </div> </div> </div>

如果没有 Tailwind,这段样式需要写一个 CSS 文件,再去想类名,再想办法解决 hover 状态。Tailwind 把高频样式直接放在类名里,虽然初次阅读时有点密集,但熟悉后反而比切换文件更流畅。尤其当多个开发者同时改动组件时,因为类名都写在同一个节点上,冲突概率显著下降。

3.7 部署配置:Node 运行环境与 Prisma 迁移

t3code 项目部署时比纯前端项目多几件事。构建阶段要确保prisma generate被正确执行,否则数据库客户端类型和查询代码不匹配。运行阶段要配置数据库环境变量,并在应用启动前执行迁移。

常见的部署平台是 Vercel。在 Vercel 上部署 Next.js 项目几乎没有门槛,但需要注意:数据库连接不能使用本地 localhost,必须使用线上数据库,比如 Railway、Neon、Supabase 等托管的 Postgres。Vercel 的构建环境默认不安装额外系统依赖,如果你的 Prisma 版本需要 openssl,记得在构建命令里加入openssl相关处理,否则会报 Prisma 初始化失败。

4. 高频问题与排错记录

4.1 类型不同步:服务端改了字段,前端还是老的类型

这是 t3code 项目中最高频的问题,十次有八次出在这里。现象是服务端 prisma schema 更新了,也执行了prisma db push,但前端编辑器里依然提示旧字段。

原因通常是:Prisma Client 的生成类型没有刷新到node_modules/.prisma/client。t3code 中 tRPC 的类型推断依赖 Prisma 客户端类型,而客户端类型是在prisma generate时生成的。

解决办法:每次调整 schema 后,完整执行一次npx prisma generate,并且重启 Next.js 开发服务。如果使用 VS Code,有时候 TS Server 缓存了旧类型,需要手动执行TypeScript: Restart TS server命令。

还有一个细节:如果多个窗口同时打开同一个项目,其中一个窗口执行了 schema 变更,另一个窗口的 TS 进程未必感知,这是常见的"为什么我改了没反应"的原因。

4.2 NextAuth 会话类型:ctx.session 上没数据

用 NextAuth 做登录后,在 protectedProcedure 里拿不到预期中的 session 信息是常见问题。通常不是 NextAuth 配置错了,而是 TypeScript 类型没有正确合并。

默认情况下,NextAuth 的session.user只有name、email、image三个字段。如果你想往 session 里塞id或者role,必须自己扩展类型声明。在项目里加一个src/types/next-auth.d.ts:

import { DefaultSession } from 'next-auth'; declare module 'next-auth' { interface Session { user: { id: number; role: string; } & DefaultSession['user']; } } declare module 'next-auth/jwt' { interface JWT { id?: number; role?: string; } }

然后记得在authOptions的 callbacks 里把 JWT 中的字段映射到 session:

callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id; token.role = user.role; } return token; }, async session({ session, token }) { if (token?.id != null) { session.user.id = token.id; } return session; }, },

如果不做这个映射,代码里访问session.user.id会报 undefined。新手拦在这个坑里最多的时间就是查 typescript 报错,其实问题根源在运行时数据不存在。

4.3 Decimal 类型序列化:前端拿到的是字符串

Prisma 的Decimal类型在数据库层面是精确小数,但通过 tRPC 返回给前端时,序列化成 JSON 会变成字符串,而不是 number。很多第一次使用 t3code 的人会困惑:"为什么我的价格字段不是 number?加个 1 反而变成字符串拼接了。"

这个行为其实是故意的。Decimal 本身是为了避免浮点误差,它以字符串形式传输可以保留精度。如果前端需要参与计算,可以先把字符串转成 number,或者使用 decimal.js 工具类处理。不要为了计算方便就改用 Float,电商场景下金额精度是底线问题。

我通常的做法是:DTO 输出层把 Decimal 统一转成字符串,前端展示时使用格式化函数,需要参与价格计算时再解析为整数分单位。用"分"做最小单位比直接用元浮点数更快也更安全。

4.4 开发环境热更新卡住了:Prisma 引擎反复重启

开发阶段改 schema 之后,有时 Prisma 引擎在后台会不断重启,控制台疯狂刷错误信息,页面一直转圈。这个现象多半是 Prisma 版本与本地 Node 版本不兼容,或者是 Windows 环境下二进制文件被安全软件拦截。

我的经验做法:优先使用 Node 18 LTS 或 Node 20 LTS;Windows 用户尽可能使用 WSL2 环境运行 prisma 相关命令;如果使用 Docker,Prisma 引擎镜像要选带 openssl 的版本。

4.5 构建失败:类型错误掩盖了真正的问题

Next.js 生产构建时如果存在 TypeScript 错误,会直接失败。很多团队被这个策略卡住了:明明功能开发完成,构建却因为几个小类型报错失败。

t3code 项目中,我建议在项目早期就在 CI 里加入tsc --noEmit检查,不要等最后构建再爆雷。开发阶段用 VS Code 编辑器时,只要留意问题面板有红色波浪线,就应该考虑立即修复。类型错误越拖越多,最后集中解决非常消耗士气。

另外要说的是,如果项目引用了服务端文件,但忘记加import type,在生产构建时会提示模块找不到或者循环依赖。排查时可以优先检查所有 service 层的 import 语句,确保只导类型时都用了import type。

5. 从实际项目中沉淀的心得与建议

t3code 这套方案,我自己前后在四五个真实项目里完整落地过,从工具链完善度、团队上手速度到后期维护成本,都有比较明确的感知。

第一,类型安全的收益在一周之后才真正显现。头几天大家都在适应 tRPC 和 Prisma 的使用方式,感觉和之前差不多,甚至因为新概念增加了负担。等业务代码量上来之后,你会发现自己几乎没有再翻过接口文档,前端传参数也不用先去查字段名。这种收益是积累性的,不是爆发性的。

第二,团队规模在五到十个人的时候,体验最佳。如果只是一个人做小项目,直接用 t3code 也会很顺手;但如果团队超过二十人,且分成前后端两个独立团队,“共享类型”这个优势就未必成立,因为类型定义文件可能被两边修改,反而容易产生冲突。

第三,t3code 让我最舒服的是重构体验。以前改一个字段,要在 API 文档、前端接口定义、mock 数据、测试用例里全部改一遍。现在重构时,服务端模型改了,类型错误会像地雷一样在编辑器里标出来,你只需要逐个排除。这种“被编译器盯着走”的感觉,其实比自由散漫的开发方式更能保证质量。

如果后续想扩展,可以考虑接上 tRPC 的 subscription 能力做实时通知,或者在 Next.js 的 Server Components 里尝试直接调用 tRPC 服务端,进一步减少客户端请求。留意保持极简依赖和核心类型安全链路,你会感受到这种工程模式带来的长期价值。

RELATED READING

延伸阅读

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