☰
t3code:基于T3 Stack的TypeScript全栈类型安全工程实践
2026/10/26 8:43:15 网站建设 项目流程

1. t3code到底是什么:我不想要又一个脚手架

先说结论:t3code不是某个官方框架,也不是一个成熟的开源项目名称。它是我对自己一个长期维护的 TypeScript 全栈工程仓库的代号,核心思路是把 T3 Stack 这一套技术栈沉淀成一套真正可复制、可演进、可交底的前端+后端一体化代码基座。

我在过去两年里频繁接手新的全栈项目,发现团队里每个人初始化项目的习惯都不一样:有人用 Next.js 官方模板,有人自己拼 Vite + Express,有人喜欢把所有数据请求都堆在组件里。代码风格混乱不说,类型安全也基本靠自觉。项目一多,我就越来越反感"每次从零配置一遍"这件事。于是我自己搭了一套底层模板,把路由、数据库、API、认证、样式这几层全部用 TypeScript 生态里最稳定的一批工具串起来,代码仓库代号就叫t3code。

这套东西适合谁?如果你正在做或准备做以下类型的项目,它可以作为直接参考:中后台管理系统、内部工具平台、需要登录和权限区分的内容型应用、以及一切以 CRUD 为主但又不希望被接口文档拖慢进度的全栈应用。如果你只是写一个静态展示页,或者你的团队前端和后端严格分离、各自部署,那 t3code 的很多设计对你来说反而过度了——这点我后面会具体说明。

我给它起名叫 t3code,并不是为了蹭热度。"T3"代表的 T3 Stack(create-t3-app 所承载的 TypeScript 工具链组合)之所以吸引我,在于它把"类型安全"从一个口头承诺变成了一条完整的链路:数据库表结构是类型、接口入参是类型、前端拿到响应也是同一个类型。而"code"这个后缀,是因为我在这个仓库里的工作重点不是搭个能跑的 demo,而是把工程规范、目录约定、异常处理、权限模型、部署脚本都沉淀成代码和文档。这个项目演进到今天,已经不只是一个模板,更像是我个人对"一个中型全栈项目该长什么样"的答案。

2. 骨架搭建:目录、包管理和环境变量

2.1 基础设施选型

t3code 的核心技术栈遵循 T3 Stack 的主流选择:

  • Next.js 14(App Router 模式)作为应用框架
  • tRPC 负责 API 层,替代传统的 REST 路由
  • Prisma 作为 ORM,直接对接数据库
  • Tailwind CSS 负责样式
  • NextAuth.js 处理认证会话

但我在package.json里做了三处和官方 create-t3-app 默认模板不同的调整,这是整个仓库最关键的决策,没有之一。

第一,包管理器固定为 pnpm。原因很实际:Next.js 的依赖树非常深,npm 安装同样一套依赖通常比 pnpm 多花 50% 以上的时间,而且 node_modules 体积大一倍。pnpm 的硬链接和隔离式 node_modules 结构在 CI 环境下优势极其明显。我在package.json里写上了"packageManager": "pnpm@9.x.x",用 Corepack 锁版本,避免团队成员因为 pnpm 版本差异装出不同结果的依赖树。

第二,强制使用 Node.js 20 LTS 以上版本。Next.js 14 对 Node 版本的要求本来就比较高,但更重要的是 Prisma 在 Node 18 早期版本下偶尔会出现引擎加载异常的偶发问题。为了少接 support 工单,我只支持两个版本:20.x 和 22.x,并在 CI 脚本的engines字段里做了声明。

第三,所有环境变量自带 zod 校验。这个想法来自 tRPC 社区的一个讨论:与其在到处process.env.AUTH_SECRET然后担心它没定义,不如在入口处一次性校验全部环境变量。t3code 的env.mjs文件里定义了一套 zod schema,启动时如果缺少必须字段直接报错,错误信息直接指出缺了什么。这一条建议所有全栈项目都抄走,省下来的排查时间远超实现成本。

2.2 目录约定:一个路由就是一个完整功能切片

t3code 的目录结构没有采用常见的"前端放 components、后端放 server"的物理隔离方式,而是按照业务功能切片组织。这是我的刻意取舍。

src/ ├── app/ # Next.js App Router │ ├── (auth)/ # 登录/注册相关路由组 │ ├── dashboard/ # 仪表盘页面 │ └── [project]/ # 业务主模块,动态参数 ├── components/ │ ├── ui/ # 基础组件:按钮、输入框、弹窗 │ └── shared/ # 跨模块共享的业务组件 ├── server/ │ ├── api/ # tRPC router 定义 │ │ ├── root.ts # 根 router │ │ └── routers/ # 按业务域拆分的子 router │ ├── auth/ # NextAuth 配置 │ ├── db/ # Prisma client 实例 │ └── services/ # 业务逻辑层 ├── stores/ # 前端状态管理(以 server state 为主) └── styles/ # 全局样式

这里有两个容易被新手误解的点。第一,server/services不是多余的一层。tRPC 的 resolver(处理函数)写完业务逻辑完全可以跑,但一旦业务变复杂,你会在多个 router 里重复写同一段"判断用户有没有权限 + 查出数据 + 做权限过滤"的逻辑。我抽了一个薄薄的 service 层专门放这类代码,router 只负责参数校验和调用 service,这让单元测试非常好写。

第二,stores/目录名看起来像传统的 redux 状态管理,但 t3code 实际上是空壳。因为 tRPC + React Query 已经把服务端状态管得很好,本地状态我只用组件级 state。99% 的应用根本不需要全局状态管理库,这是我在项目里观察到的规律:当你不被"需要个 store"的先验观念绑架时,代码量会直线下降。

2.3 环境变量与配置约定

大量全栈项目的环境变量管理是灾难现场:有人把生产环境密钥写进.env然后误提交,有人不知道该设DATABASE_URL还是DATABASE_URL_PROD。t3code 做了以下约定:

  • .env.example是唯一权威模板,内含所有变量名和注释说明
  • .env仅用于本地开发,且永远不允许被提交
  • 通过 zod schema 区分必填和可选变量,比如本地开发时AUTH_SECRET可以填一个随机字符串,但DATABASE_URL必须在 Prisma 能连上数据库之前就被校验

本地开发我建议装dotenv-cli工具,配合 pnpm script 使用:

{ "scripts": { "dev": "dotenv -e .env -- next dev", "db:generate": "dotenv -e .env -- prisma generate", "db:migrate": "dotenv -e .env -- prisma migrate dev" } }

为什么要这样写而不是直接用 Next.js 内置的 env 加载?因为 Prisma CLI 不认识 Next.js 的.env加载规则,你不加dotenv -e前缀的话,跑迁移时可能读不到DATABASE_URL,报错信息还特别让人迷糊。这是我第一次搭 t3code 时踩的坑,后来写进了项目的 README 里供人避雷。

3. 端到端类型安全链路:tRPC、Prisma 和 React Query 的配合

3.1 核心链路拆解

T3 Stack 最吸引人的卖点就是"端到端类型安全",这个概念说人话就是:你在前端调用一个函数拿数据,不需要去找接口文档确认返回字段,也不需要在联调环境里等后端同事把接口联好——函数的输入和输出类型都直接从后端定义推过来,前端代码里写错了字段名,编译器当场就会报错。

这条链路在 t3code 中的具体实现是三段接力:

第一段,Prisma 根据数据库 schema 自动生成 TypeScript 类型。你改了schema.prisma里的字段,跑一次prisma generate,PrismaClient.xxxDelegate的返回类型就跟着变了。

第二段,tRPC 的 router 在注册时把每个input和output的 zod schema 暴露出来,这些 schema 在运行时做校验,同时自动推导出静态类型。

第三段,前端通过trpc.reactClient调用 query 或 mutation,React Query 自动缓存结果。类型会穿过server.routers的类型引用,一直作用到你的组件 prop 上。

用一张对比表来看它和传统 Web API 开发模式的区别:

传统 REST + 手写类型tRPC 链路
后端定义接口,写文档后端定义 zod schema 即文档
前端手写类型,经常过期类型自动推导,无法过期
运行时若字段缺失,返回 500运行时若字段缺失,前端口径不匹配会联动报错
接口改动需要前后端同步通知接口改动编译期直接暴露问题

3.2 一个具体的 tRPC 业务模块代码

我用 t3code 里最常见的"项目列表"功能做示例。先写 zod schema 和 tRPC 子 router:

// src/server/api/routers/project.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { prisma } from "../../db"; // 查询条件 schema const projectListInput = z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), keyword: z.string().trim().optional(), }); export const projectRouter = createTRPCRouter({ list: protectedProcedure .input(projectListInput) .query(async ({ ctx, input }) => { // 这里用 ctx 拿当前用户信息 + service 层做权限过滤 const where = { userId: ctx.session.user.id, ...(input.keyword ? { name: { contains: input.keyword } } : {}), }; const [items, total] = await Promise.all([ prisma.project.findMany({ where, skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { updatedAt: "desc" }, }), prisma.project.count({ where }), ]); return { items, total }; }), });

再看前端的调用。React Query 的 key 由 tRPC 自动生成,你不用手动管它:

// src/components/project/project-table.tsx "use client"; import { api } from "~/utils/api"; import { useDebounce } from "~/hooks/use-debounce"; export function ProjectTable() { const [keyword, setKeyword] = useState(""); const debouncedKeyword = useDebounce(keyword, 300); const { data, isPending } = api.project.list.useQuery({ page: 1, pageSize: 10, keyword: debouncedKeyword, }); // data 的类型是 { items: Project[]; total: number } | undefined // 这个类型是从后端 router 的 output 自动推导的 if (isPending) return <TableSkeleton />; return ( <div> <Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索项目名称" /> <Table> {data.items.map((project) => ( <TableRow key={project.id}> <TableCell>{project.name}</TableCell> <TableCell>{project.updatedAt.toLocaleDateString()}</TableCell> </TableRow> ))} </Table> </div> ); }

这里有个很容易被忽略的细节:data.items里的Project类型直接来自 Prisma 的Project模型,而Project模型里有id、name、updatedAt等字段。如果你在组件里想访问一个数据库里根本不存在的字段,编译器会直接告诉你这个属性不存在。这就是"端到端类型安全"带来的实际开发体验——接口字段变更不再是一颗定时炸弹。

但必须强调,类型安全不代表运行时安全。zod 校验仍要保留,因为外部请求可以绕过前端直接打你的服务。t3code 的安全原则是:编译期靠 TypeScript 保证,运行期靠 zod 和会话校验保证,两者不互为替代。

4. 我踩过的坑:类型推断、依赖版本和部署

4.1 第一个大坑:tRPC 路由类型没刷新,前端一直报错

t3code 刚搭好的第一天,我遇到过一个特别诡异的场景:后端 router 新增了一个getById方法,前端调用时 TypeScript 却始终提示该方法不存在。我把 router 注册代码反复检查,确认一切正确,prisma generate也跑过了,问题依旧。

排查链路是这样的:

  1. 先看src/utils/api.ts里createTRPCReact挂载的是哪个.ts文件。我发现它引用的是src/server/api/root.ts导出的AppRouter。
  2. 再看root.ts是否正确注册了新增的projectRouter。这一步也没问题。
  3. 最后怀疑到 Next.js 的缓存。我停掉 dev server,重新pnpm dev,问题消失了。

结论很明确:Next.js 开发模式下对.ts类型引用的缓存偶尔会滞后。t3code 里我专门加了一个 npm script"dev:clean": "rm -rf .next && next dev",凡是遇到"代码明明改了但类型还停留在旧状态"的情况,先用这个脚本重启,百试百灵。这种坑在大型项目里比想象中的频繁,尤其是当你同时改了 schema.prisma 和 router 时,缓存会导致类型链路不同步。

4.2 第二个大坑:Prisma 的关联类型返回了错误的字段级权限

t3code 里有这样一个场景:一个用户可以创建多个文档,文档有公开和私密两种可见性。需求是"文档详情接口只在文档属于当前用户或者文档公开时返回内容,否则抛 404"。

我最初的实现是这样写的:

detail: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const doc = await prisma.document.findUnique({ where: { id: input.id }, }); if (!doc) throw new TRPCError({ code: "NOT_FOUND" }); // 权限过滤 if (!doc.isPublic && doc.userId !== ctx.session.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } return doc; });

逻辑上没问题,但有一个隐患:如果Document模型中存在secretNote这类敏感字段,即使当前用户无权限查看,我们已经把它从数据库查出来了,万一后续代码里忘记过滤直接透传呢?更关键的是,Prisma 查出的Document类型默认包含全部模型字段,前端拿到的不只是"公开数据",而是整条数据库记录。

更稳的做法是通过select来控制返回字段,把权限判断下沉到查询层:

detail: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const userId = ctx.session.user.id; const doc = await prisma.document.findFirst({ where: { id: input.id, OR: [{ isPublic: true }, { userId }], }, select: { id: true, title: true, content: true, createdAt: true, updatedAt: true, }, }); if (!doc) throw new TRPCError({ code: "NOT_FOUND" }); return doc; });

这个改动有两个额外收益:一是findFirst的OR条件让"记录不存在"和"无权访问"统一返回 404,避免了信息泄露;二是select限定了返回字段,前端 TypeScript 类型里自然就不会出现敏感字段。这条经验我后来写进了 t3code 的 README 里,叫"权限过滤要在查询条件里做,不要等查出来再切"。

4.3 第三个大坑:React Query 的缓存和 tRPC 的 query key 冲突

t3code 里有个功能:更新项目名称后,左侧项目列表和顶部导航栏的"项目数"都要同步刷新。最初的做法是在 mutation 成功后调用utils.project.list.invalidate(),但奇怪的是,有时列表刷新了,顶部计数器没变;有时两个都没变,需要手动刷新页面。

我用 React Query devtools 查看缓存发现,计数器的 key 是["project","count"],而列表的 key 是["project","list"],两者前缀相同但内容不同。原因是 tRPC 的 key 结构是[routerPath, procedurePath, input],invalidate()默认精确匹配某个 key,并不会因为前缀相同就同时失效。

解决方案是给计数器单独写一个独立 procedure 并精确失效:

// 正确做法:精确失效刚涉及的数据 const utils = api.useUtils(); await utils.project.list.invalidate(); // 同时失效相同前缀的其他 query await utils.project.invalidate();

utils.project.invalidate()会把projectRouter下所有查询标记为失效,代价是可能需要重新请求多个接口。对于 t3code 这种中小型内部系统来说,这种"简单粗暴"反而比精确到每条 query 手动维护 key 更可靠。如果你的应用接口数量庞大、需要性能极致优化,再考虑按 key 精确控制。

这个坑的本质是:tRPC 的类型安全不包含 React Query 缓存失效策略的类型安全。前者是静态层面的保障,后者是运行时策略层面的问题,必须有意识地设计。

4.4 部署时的坑:Prisma 的引擎文件在 Docker 里缺失

最后说一个部署相关的教训。t3code 如果直接 Docker 打包发布,一个非常常见的失败原因是 Prisma 的 query engine 没有打包进去。我在Dockerfile里一开始只写:

COPY --from=builder /app/node_modules ./node_modules

结果运行容器时 Prisma client 报错,说找不到libquery_engine.so.node。这是因为 npm 包安装时,Prisma 的 engine 二进制是跟随具体操作系统平台下载的,如果你在 macOS 上构建镜像、在 Linux 上运行,二进制文件就对不上。

修复方式是在schema.prisma里明确指定二进制目标:

generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] }

然后把构建阶段放在 linux 环境下,或者干脆用 prisma 官方提供的 debian slim 镜像作为基础镜像。t3code 的 Dockerfile 最终用了多阶段构建:builder 阶段跑prisma generate并保留引擎文件,runtime 阶段只拷贝必要文件。这个坑我栽过一次之后,每次新建项目都会在部署脚本里加一条prisma generate --no-engine的检查命令,确保产物不携带无关平台的二进制。

5. t3code 的后续演进和一些个人体会

5.1 已经规划好的演进方向

t3code 目前不只是我一个人在用,团队第二个项目已经直接从这个仓库 fork 出去改造了。我自己这边的演进计划有三个方向:

第一个是把认证从 NextAuth 迁移到独立方案。不是 NextAuth 不好,而是当业务里出现外部回调地址、多租户隔离、API token 和浏览器会话同时存在时,它的抽象层会带来限制。我现在更倾向于做一个薄薄的、基于数据库 session + HTTP-only cookie 的自定义认证中间件,把控制权拿回来。

第二个是在 service 层引入消息队列。当前项目的业务越来越需要异步任务(比如导入导出、生成报表),直接在 tRPC procedure 里同步跑不是不能做,但用户体验很差。计划是用 BullMQ 加 Redis 做任务队列,再把任务状态通过 tRPC subscription 推送到前端。这一步会让 t3code 从"CRUD 工具链"进化成"业务系统底座"。

第三个是抽离出可独立发布的 UI 组件库。现在 t3code 里的components/ui和 Tailwind 配置深度耦合,换个项目就得手动复制。未来打算拆成单独包,用 tsup 打包发布到公司内部 registry,这样其他项目也可以引用同一套组件规范。

5.2 关于"少依赖"和"稳依赖"的选择

最后分享一个心态层面的体会。很多人喜欢把项目做得"极简",能不用库就不用库,但 t3code 的路线是相反的:在核心链路依赖上,我宁可用成熟库,也不用自己造轮子。理由很简单:业务代码里的轮子会一直长在你身上,工具库的轮子起码有人帮你修。

但"稳依赖"不等于"堆依赖"。t3code 里我只用了一层 ORM、一套 API 中间层、一个样式框架,避免在同一层塞两套方案。每新增一个依赖,我都会问三个问题:

  • 它是解决当前真实存在的痛点,还是"以后可能用到"?
  • 它和现有依赖的类型系统是否兼容(尤其是不是都需要 zod/ts 类型)?
  • 如果它停更一年,我手动替代的成本是否可控?

按照这个标准,t3code 的依赖列表至今保持在一个很克制的水平。实际操作中你会发现,删掉一个无关依赖的快乐,远大于装一个"锦上添花"依赖的快乐。

如果你现在正处于"想搭一套 TypeScript 全栈项目但不知道从哪里开始"的状态,你可以直接拿 t3code 的思路作为骨架,重点不是抄我的文件结构,而是理解它的三层逻辑:用 Prisma 管数据、用 tRPC 管接口、用 React Query 管前端缓存。这三者的类型是贯通在一起的,你维护的只有一个事实源。这是目前我认为中小型全栈项目最优的开发体验,也是我强烈推荐 T3 这条路线的原因。

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

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

立即咨询