ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code实战:基于T3 Stack的全栈类型安全开发指南

t3code实战:基于T3 Stack的全栈类型安全开发指南

正文开始了,这篇博文是关于“t3code”的。在动笔之前,我先把话说在前面:下面我会像自己博客那样,直接把我最近接触 t3code 这个项目的完整过程、理解、实操细节都铺开来写。它不是一个大而全的框架,而是一套“组合拳”式的全栈技术栈约定,文章里你会看到我怎么从一脸好奇到逐步跑通,再到踩坑、校准认知的全过程。这篇内容会包含大量命令、配置片段,以及我踩过之后才懂的细节,如果你正在学习 T3 Stack 或者准备接手类似结构的前端全栈项目,这篇应该能让你少走一点弯路。

1. t3code 到底是什么:先把它从“神秘代号”拆成技术组合

第一次看到“t3code”这个词的时候,我也愣了一下。这到底是一个框架名、一个代码仓库的代号,还是某个公司的内部项目缩写?带着这个疑问我先从命名习惯去拆:T3 这个前缀,在近几年的前端圈子里几乎已经默认指向了那个由 Theo Browne 带火的T3 Stack;后面接上“code”,很大程度上说明它不是一个完整的产品,而是一个围绕这套技术栈沉淀下来的代码仓库、个人项目模板,或者是某个在线练习任务的约定名字。我接触到的 t3code 项目,实际上就是一套把 TypeScript 语言优势发挥到极致的全栈应用骨架。

如果只用一句话向别人介绍 t3code,我会说:它是一个用 T3 Stack 组织起来的现代 Web 应用代码库。但这里要强调一下,它并不是一个独立发明的“新语言”或者“新数据库”,它更像是把一堆本来就很优秀的工具,以一套严谨的类型安全规范组装起来。核心成员就是:Next.js(负责页面和应用壳)、TypeScript(作为整个代码库的血液)、tRPC(负责 API 层的类型直达)、Tailwind CSS(负责样式层的低摩擦开发),再加上 Prisma 这类 ORM 来衔接 Postgres 数据库。

对于熟悉传统前后端分离模式的人来说,t3code 带来的是完全不同的体验。传统模式下,前端同学写 API 调用时,得先拿一份接口文档或者打开 DevTools 去看返回的数据结构,然后为了安全,往往还得人肉维护一份 TypeScript 类型定义,跟后端同学约定好字段就叫data.result.list[].name。这中间只要有一个环节没对齐,就会出现外观调试了很久、结果原型干干净净的老问题。而 t3code 这整套设计,它的核心思路是“不让你同时维护两套事实”:数据库表结构是事实,API 函数的入参出参是事实,前端拿到手的那一份 TypeScript 类型也应该是从这个事实推导出来的,中间省掉大量“文档转代码”的翻译工作。

我觉得要看懂 t3code,就必须先接受它背后的一个基本立场:少做重复劳动,让工具来保证正确性。它不追求那种把所有逻辑都揉进一个巨型单项工程的“大而全”,也不追求像微服务那样高度的物理拆分,而是追求一种代码库里处处都是通的、处处都有类型提示的开发体验。说白一点:这份代码 Code Review 起来很舒服,因为你在前端写一行const user = trpc.user.getById.useQuery({id: 1}),后端只要保证getById的返回类型是确定的,调用方完全不需要猜测数据长什么样。

t3code 的另一个特点是门槛分明。它不是让新手直接上手也能三分钟点出一个 Demo 的那种低代码平台,它面向的是已经理解了 HTML/CSS/JS,并且愿意在 TypeScript 上花一点时间的人。如果你是一个刚刚开始接触全栈开发的学生,或者是一个被“API 类型不一致”折磨过很久的后端转前端工程师,那么这类技术栈会带给你一种久违的踏实感——前后端的分界线不再是一条滑溜溜的线,而是由 TypeScript 编译器画出来的一道粗实线。

2. 跑起 t3code 项目:环境准备的细节和启动时机的校准

拿到 t3code 这种仓库的第一步肯定不是马上读代码,而是想办法让它在本地转起来。不过,如果你希望下载下来一行npm run dev就能打开一个完整网站,那很可能会失望——这也是我最初栽过的小跟头:一个成熟的 T3 项目往往依赖外部服务,尤其是数据库。先把这件事理顺,再去碰启动,你会舒服很多。

2.1 环境里必须先补齐的四个家伙

我自己的开发机是 macOS,系统里已经装了 Node.js 和包管理器,但这不代表万事大吉。因为 t3code 这套技术栈里,Prisma 对 Node 版本是有隐性要求的,我在第一次安装依赖的时候就碰到过一串编译报错,最后把 Node 从 19 升到 20 LTS 版本才顺利通过。如果你在npm install的过程中遇到关于prisma引擎或者 Rust 相关的原生模块报错,第一时间去检查 Node 版本,这比重新安装十次依赖都有用。

除了 Node.js,我建议你提前准备:

  • 包管理器:T3 项目没有强制用哪个,但仓库里如果有pnpm-lock.yaml,优先用 pnpm,它处理依赖的方式更严格,也更能复现团队环境。没有的话就用 npm,避免混用导致 lockfile 反复冲突。
  • Docker(可选但强烈推荐):因为 t3code 样板往往默认连接 Postgres 数据库,如果你本地不想特地安装一个 PostgreSQL 服务,用 Docker 起一个实验数据库是最快的。我通常跑docker run --name t3code-db -e POSTGRES_PASSWORD=password -e POSTGRES_USER=postgres -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16-alpine,一条命令,要清除的时候直接docker rm -f就完事了。
  • 一个现代浏览器(带 React DevTools 最好):这个看起来像是废话,但 tRPC 的调试面板和 Next.js 的错误覆盖层都需要较新的浏览器内核,老旧浏览器只会让你莫名多出一些“白屏”问题。

2.2 环境变量配置:别问,先照着模板抄

T3 项目里总会有个.env.example或者.env文件。t3code 的数据库连接字符串和认证密钥都在里面。我见过太多人卡在这一步:直接把模板里的DATABASE_URL原封不动留下,结果启动时 Prisma 报连接拒绝。这里要花三十秒改一下:把localhost:5432的端口、用户名、密码,换成你自己本机 Docker 容器对应的那套。而像NEXTAUTH_SECRET这种值,不能留空,否则登录相关功能会崩。我自己的习惯是先打开终端,跑一个openssl rand -base64 32生成一串随机字符,再粘到环境变量里,省事也安全。

配置好之后还有一个细节:NEXT_PUBLIC_ 开头的那类环境变量,会被打包进浏览器端代码。你如果在改动之后想看到效果,不要觉得刷新一下页面就能生效,必须重启 dev server 或者重新npm run build,否则很容易出现“我明明改了,为什么页面没变化”的诡异情况。

2.3 数据库同步:直接 push 最快,迁移要沉住气

T3code 的默认操作顺序,一般建议是先npx prisma db push把当前 Schema 的模型同步到数据库,再npm run dev。这里很多人会问:为什么不是先生成迁移文件,而是 push?我的理解是,对于还在开发初期、没有上线压力的项目,db push 就像直接对着画布涂改,快速迭代模型非常方便。它不需要你管理 migration 的版本历史,改完 schema 再 push 一次,表结构就变成新的了。

但这也引出一个避坑点:db push 有可能会翻转掉表的某些约束或字段索引。如果你已经进入测试阶段,表里有重要数据,那就不要用 push,而是用npx prisma migrate dev --name init去生成正式的迁移文件。t3code 这种仓库的实际工作流通常也分两段:雏形期 push,稳定后 migrate。你要能分清自己处于哪个阶段。

把这几步做完,才轮到npm run dev打开 http://localhost:3000。如果看到页面正常渲染,数据库连接没报警,那你才算是真正把“环境”这一步迈过去了。接下来才到最有意思的部分——顺着代码看看它是怎么做到类型如此丝滑的。

3. 深挖 t3code 的类型安全主线:从数据库到浏览器的单向数据流

前头说过 t3code 的最大卖点是“类型安全”,这句话到底落实到什么程度,得看真实链路。我拿到项目之后的第一件事,不是着急改业务代码,而是把这条路从头到尾走一遍,看看 TypeScript 的类型到底在哪一层被“掐断”了。你要是也想快速掌握这类代码库,我建议按下面的顺序去读。

3.1 数据库层的“真相源头”:Prisma Schema

打开prisma/schema.prisma,你会看到一套类似数据字典的模型定义。例如:

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

在 t3code 里,这个文件可以被看成是绝对的“类型发源地”。它身上的每一个字段,比如title: String,最终会经过 Prisma Client 的生成代码变成 TypeScript 里的字符串类型,接着通过 tRPC 的output validation和input validation一路“保鲜”到浏览器。如果说整个 t3code 是一条江,那 Prisma Schema 就是这条江的源头。读代码的时候,先不要东看一块西看一块,从源头读起是最顺的。

我特别想提醒的一点是,Prisma 的@default和@updatedAt这类标记会影响你在写创建和更新逻辑时的入参类型。比如你在创建 User 的时候,TypeScript 会告诉你id不需要传,因为它是自动生成的;如果你尝试传一个id过来,编译器反而会给你报错。这种“约束”本质上就是类型安全送给你的礼物:当你写代码的手感变得像“被导轨推着走”,说明你已经在正确理解这套体系了。

3.2 API 层:tRPC Router 是怎么把函数变成“前端可直接调用”的

从 Prisma Schema 往下走,就到了src/server/api/routers目录。这里每一个文件通常对应一个域模型,比如post.ts里可能定义了一个getAll的 query:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { const posts = await ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); return posts; }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, }); }), });

这一段代码在我眼里就是 t3code 的“任督二脉”。publicProcedure.query的返回类型会被 tRPC 自动推断出来,不需要你额外声明type PostResponse = ...。然后在 React 组件里,你只需要写:

const { data } = api.post.getAll.useQuery();

接下来神奇的事出现了:data的每一项都会带上id、title、content、published、authorId、createdAt这些字段的类型,并且是精确到字符串和布尔值的。这已经不是“碰巧字段名一致”那么简单了,而是编译器在给你背书。如果你在后端返回了一个前端不认识的字段,前端照样访问不到;如果你要取一个不存在的字段,IDE 里会直接画红波浪线。

很多从 REST 世界过来的人第一次遇到这个会有点不适应:API 不需要写 interface 了吗?不需要手动声明返回模型吗?在 t3code 里确实不需要。它替你省下的那部分工作,正是“手工维护两套类型”的活儿。这份“推导”省下来之后,你重构的时候胆量都会大一些:改一个数据库字段名,用 IDE 的全局引用搜索去找,所有用到它的地方都会被编译器提醒改掉,而不是等接口报错才发现。

3.3 校验层:zod 作为边界上的守门员

光有 TypeScript 的静态检查还不够,因为 TypeScript 的类型系统在运行时是不存在的。也就是说,一个用户完全可以在浏览器 DevTools 里篡改请求体内容,后端如果只靠类型定义去接受数据,那一定会出问题。t3code 选择用 zod 做运行时校验。

在postRouter的getById里,input(z.object({ id: z.string() }))这句话实际上做了两件事。第一,它告诉 tRPC:“这个接口的入参必须长成这样”。第二,当请求真实到达后端时,tRPC 会在处理后端函数之前先执行这个 zod schema,如果入参不符合规范,会直接返回一个 400 或者 422 错误,而不是让你的数据库查询收到一个undefined或者类型错乱的对象。

这种“运行时校验”的理念我其实也经历了从不理解到真香的过程。早期写接口,我总觉得“只要前端控制好逻辑,后端就不会收到坏数据”。但现实是,一旦 API 被公开出去或者被爬虫扫描到,什么稀奇古怪的请求体都会出现。zod 就相当于安保室门口的证件识别机:类型对了才放进门,不对就礼貌地挡住。而它带来的额外好处是,因为 zod schema 是可以直接推导出 TypeScript 类型的,所以前端在调用的时候,IDE 弹出的参数提示可以精确到“这个字段应该是一个字符串”。这种感觉就像在填写一张自带校验的电子表单,非常踏实。

3.4 前端消费:react-query 加持下的数据请求

t3code 的前端不直接裸用 fetch 调接口,而是经过 tRPC 的 React 客户端封装,底层再借助 @tanstack/react-query 管理缓存、请求状态和失效刷新。这种设计让数据请求的行为不再是“发一次请求、拿一次数据、关掉 loading”这么简单,而是引入了“缓存”“重新验证”这些概念。

在组件里你会很常见到这样的代码:

const utils = api.useUtils(); const { mutate } = api.post.create.useMutation({ onSuccess: async () => { await utils.post.getAll.invalidate(); }, });

这里的关键动作是invalidate,它表达的语义是:“这个接口背后的数据已经过期了,请重新去拉一次”。它比你自己手动去setState更新本地数据要优雅很多,因为你不需要关心具体的数据结构,只要告诉 react-query:嘿,那条查询结果的缓存可以丢了。这种模式下,代码库里的状态管理变得特别轻,大部分数据流都被 tRPC 和 react-query 接管了,你的注意力可以完全放在业务上。

我读 t3code 代码的时候,最大的感慨是:它的每一层都在努力把“手动同步”这件事从开发者身上拿掉。Prisma 拿走数据库字段同步,tRPC 拿走接口类型同步,zod 拿走运行时校验逻辑同步,react-query 拿走缓存失效策略的同步。当这四样东西组合在一起,代码里剩下的,反而是纯粹的业务表达,这大概是 t3code 这类项目最打动人的地方。

4. 从目录结构开始读懂 t3code 的骨架设计

跑通只是第一步,真正让你能熟练修改项目、往里加业务功能的关键,是彻底搞清楚 t3code 的目录划分和分工。这套骨架看起来“目录多、嵌套深”,但每一条路径都是有讲究的。我顺手画过一张心中的地图,按照这张地图去走,基本不会迷路。

4.1 先分清“服务端类型”和“客户端交互”:src/server 与 src/app 的划分

t3code 的src目录下最显眼的两个分区就是src/server和src/app(或者src/pages,取决于用的是新式 App Router 还是老式 Pages Router)。我建议先养成一个习惯:任何代码放在哪个目录下,决定了它能访问什么资源。

src/server里放的是只能在服务端跑的代码,比如db.ts里的 Prisma Client、auth.ts里的鉴权配置、api/trpc.ts里的 tRPC 中间件上下文。你在里面直接写process.env.DATABASE_URL是安全的,因为这个文件不会被下发到浏览器。而src/app下面则是页面组件和客户端组件,是你直接编辑 UI 的主要地方。如果你想做一个新的页面,多半是在src/app下新建一个路由文件夹,然后往里丢page.tsx。

在这里我想单独提醒一条经验:不要把数据库查询逻辑直接写进客户端组件里。t3code 的约束之一是,如果某个组件用了"use client"指令,它就不能直接访问ctx.db或process.env.DATABASE_URL。你可以通过 tRPC 的 query 来拿数据,也可以通过 Next.js 的 Server Component 先查好数据再传给子组件。这个约束初看有点烦,但它实际上是逼着你维持好服务端和客户端的安全边界,避免一个不小心把数据库连接字符串暴露到浏览器 Network 面板里。

4.2 tRPC 路由表的聚合逻辑:每个业务域一个 Router

上面提到src/server/api/routers目录,这个目录的命名和风格直接影响项目可维护性。t3code 的项目里,我见过两种常见组织方式。第一种是“按领域划分”,比如userRouter、postRouter、commentRouter,每种业务模型对应一个文件;第二种是“按页面划分”,比如home.ts、dashboard.ts,每个页面功能需要的接口聚合在一起。

我个人站在长期维护的角度更推荐第一种,原因很简单:当你有好几个页面都要读取user信息时,按页面划分很容易让同一个查询逻辑被复制多份;而按领域划分,你只需要改一个userRouter就够了。t3code 的根路由通常会在src/server/api/root.ts里把这些子路由合并成一个appRouter,类似这样:

export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, comment: commentRouter, });

这个聚合动作是前端调用路径的基础。你在 React 组件里写api.user.getById.useQuery(...),这个点号分隔的路径就对应了上面的嵌套结构。读这份代码的时候,一旦明白了“路径即路由层级”,你就能很快定位到具体某个接口的实现文件,而不用满项目搜索。

4.3 组件层的复用思路:components/ui 与 features

如果你见过一些大型 React 项目,一定对components目录里堆满了一百多个文件感到头疼。t3code 这类新式项目在这方面有的会采用“UI 基础组件”和“业务功能组件”分离的策略。src/components/ui下面放的通常是按钮、输入框、弹窗这些与业务无关的基础零件;而src/components下更靠近页面语义的,往往是“某块业务功能的组装件”。

这种分层带来的直接好处是改样式不改业务、改业务不动样式。比如你想统一把全站的按钮圆角改掉,只需要去ui/button.tsx动一刀;而你想调整“用户卡片”组件的数据来源,去业务组件里改 tRPC 的 query 即可,二者互不打扰。

4.4 别忘了 middleware 与鉴权上下文

T3 code 里你还会看到src/server/auth.ts,一般封装的是 NextAuth 的配置。这个东西的价值在于把“当前登录用户是谁”注入到 tRPC 的 context 中。当你在后端定义一个protectedProcedure时,它会在执行前先检查登录态,没有登录就直接抛错:

export const protectedProcedure = createTRPCRouter({ me: protectedProcedure.query(({ ctx }) => { return ctx.session?.user; }), });

这里我想说一个真实调试过的场景:如果我们把session相关字段漏配,前后端都会出现诡异的表现——要么是ctx.session一直是null,要么是前端调接口时压根不带 cookie。检查顺序我一般是:先确认浏览器里有没有登录后的 cookie → 再看 auth 的 secret 配置 → 最后看 tRPC context 里有没有把 session 塞进去。这个排查链路几乎能解决 80% 的 t3code 登录类问题。

5. 实操踩坑合集:我在 t3code 上踩过的雷和完整排查链路

无论你看了多少理论,真正让自己成长的都是那些调试到深夜的报错。t3code 虽然整体设计得比较顺滑,但它毕竟是由很多工具组合起来的,工序一多,就容易在某些衔接处出现摩擦。我把自己跑 t3code 时印象最深的几个坑整理出来,并且尽量还原当时的排查过程,而不是直接告诉你一个答案。

5.1 数据库连接失败:排查链路从端口到连接池

有一次我在本地启动npm run dev,前端页面正常渲染,但一调用 tRPC 接口,服务端日志就开始报Can't reach database server at \localhost:5432`。我第一反应是 Docker 容器挂了,于是docker ps检查了一下,发现容器在运行。接着我用psql -h localhost -p 5432 -U postgres` 试着手动连接,发现居然能连上。

问题就变得有意思了:数据库明明在线,为什么 Prisma 连不上?后来我在 Prisma 的文档里看到,它连接时需要用到特定的sslmode和连接池配置。有些情况下docker run里的 Postgres 默认没开 SSL,而 Prisma 拿到DATABASE_URL里带的?sslmode=require参数,双方一协商就崩了。解决方式是把连接串改成postgresql://postgres:password@localhost:5432/t3code?sslmode=disable,本地开发环境下完全没问题。

这个坑最大的教训是:看报错时不要只看“数据库连接失败”这六个字,要把整个错误堆栈拉到最底下,看 Prisma 引擎输出的那一行具体原因。有时候是网络层不通,有时候是 SSL 配置不对,有时候是数据库名称拼错了,原因五花八门,但排除链路永远是:容器状态 → 端口监听 → SQL 客户端直连 → 连接串参数 → Prisma 引擎输出。

5.2 tRPC 类型推断失灵:根因多数出在“手动引入了错误类型”

还有一次,我把一个 tRPC 的 query 返回结果从类型Post[]变成了(Post & { author: User })[]。在 Prisma 里我用的是include: { author: true },后端代码本身没问题,前端也拿到了数据。但是 IDE 的自动补全却一直提示data.author不存在。

我花了不少时间检查是不是 tRPC 的 transformer 出了问题,最后发现原因特别基础——我在前端组件里用as断言把data强行声明成了一个不存在的结构。一旦手动用as或者@ts-ignore去覆盖 tRPC 的推断,编辑器里的提示就会退化,甚至会显示一个很大的联合类型导致代码提示卡顿。

从此我给自己定了一条纪律:**在 t3code 项目里,尽量信任自动推断,不要轻易用as断言去处理 tRPC 数据。**如果确实需要扩展返回结构,应该去后端增加 zod schema 或者 Prismainclude,走正规的类型流,而不是在前端生造一个“长得像的类型”。

5.3 Next.js 编译缓存导致的“改不动”现象

这类问题通常表现为:改动了一个环境变量或next.config.js文件,重启 dev server 之后页面行为没变化。一开始我以为是电脑缓存闹鬼,后来才发现是.next目录里存在旧编译产物。解决方式就是删掉.next再重启,一条命令解决:

rm -rf .next && npm run dev

对于带 tRPC 的项目,我还要特别提醒:如果你改了src/server/api下的文件,并且有几个浏览器标签页正开着页面,一定不要让所有标签页都保持活跃状态。旧标签页会保留旧的内存引用,可能导致请求打到旧的 API 路径上,出现“代码改了但接口 404”的错乱。把不用的标签页关掉,只留一个调试页,能少很多精神内耗。

5.4 样式图层级被重置:Tailwind 和 Ant Design 的打架现场

t3code 有些改造版本里会有人为了省事引入现成的组件库,这时候 Tailwind 的 preflight 样式很可能把组件库的基础样式重置得一干二净。我自己就经历过一个按钮库的样式全“裸奔”的事件。当时打开浏览器,所有按钮都没有默认背景色,看起来像是 CSS 文件没加载,但 Network 面板里明明加载了。

排查最后把矛头指向了 Tailwind 的 preflight 和组件库样式加载顺序。办法是在全局 CSS 里显式地控制@layer的顺序,或者给组件库单独引入它的 reset,抑制 Tailwind 对相关元素的覆盖。这个坑对纯前端比较专业,但如果你在 t3code 上引入 UI 库时遇到“样式看起来乱了”,第一反应不要是删掉 Tailwind,而是先去查样式加载顺序和层叠上下文。

5.5 尝试快速定位错误的策略总结

纸上得来终觉浅,实际项目里的报错是千变万化的。我把自己的排查习惯总结成了一张表,遇到问题可以按图索骥:

症状优先排查对象其次排查对象
接口 500 报错tRPC router 内的输入校验Prisma 查询语法或数据库字段变更
页面白屏、无报错控制台查看 React 渲染错误Next.js 的 .next 缓存
环境变量无效是否重启 dev server是否用了 NEXT_PUBLIC_ 前缀
登录状态丢失ctx.session是否注入cookie domain 和 secret
样式表现异常Tailwind preflight 顺序组件库 CSS 加载顺序

这种“一层层缩小范围”的方式,远比大海捞针式地搜索报错片段要高效。因为报错信息往往只告诉你在哪一层出现了问题,不会直接告诉你哪一处代码写错了。

6. 改造成定制项目时,如何保持 t3code 的“初心”

很多人在跑通一个模板之后,会直接照着脑中的业务蓝图大改特改,改着改着发现代码库变得越来越拧巴。这不是因为 t3code 不好,而是没有理解它“类型安全优先”的设计意图。我经历过几个改造阶段,这里讲讲怎么在保留它的优势的同时,把业务塞进去。

6.1 业务模型扩展:先改 Schema,再顺手改 Router

假设你要给 t3code 加一个“标签 Tag”功能。常规做法会去写页面,但其实最快的路径是:

  1. 在prisma/schema.prisma里新增 Tag 模型,同时建立与 Post 的多对多关系。
  2. 执行npx prisma db push更新数据库。
  3. 在src/server/api/routers新建一个tag.ts,写上基础的getAll、create等 procedure。
  4. 在root.ts中把tagRouter挂载上。
  5. 前端组件里直接用api.tag.getAll.useQuery()拉数据。

这个顺序中的每一步都有前一步的“类型”铺垫。如果你跳过了第一步直接写 Router,你会发现 tRPC 调用 Prisma 时根本没有db.tag这个属性,整个链路就会断掉。反过来说,只要你按顺序走,从写 Schema 到前端拿到数据,中间不需要任何手工翻译。这种开发体验,用惯了之后再回到传统模式真的会不习惯。

6.2 业务复杂度上升后:考虑 monorepo 还是保持单体

t3code 作为一种“单体全栈”的组织方式,当团队和业务规模变大时,有人会问:要不要把它拆成前端单独一个仓库,后端单独一个服务?我的看法是,如果团队在十人以内,业务复杂度和协作冲突都可控,那么没有必要过早拆。t3code 这种单体内聚的模式,开发调试成本是最低的。你只需要跑一条 dev 命令,整个前后端都同时工作,不需要开两个 IDE、两个终端去互相指向对方地址。

当确实出现需要独立部署 API 或复用服务给 App 端的情况时,再考虑把 tRPC Router 部分抽出去做成一个独立包,前端在另一个仓库里通过 httpBatchLink 指向它。这算是一个平滑迁移的过程。但我不建议为了“微服务架构的正确性”而没事找事,提前把 t3code 这种结构强行拆散,那样只会徒增部署和联调成本。

6.3 加入测试的时候要怎么设计

t3code 的代码结构天然对测试比较友好,尤其是 tRPC 的 procedure 非常容易做单元测试:直接创建一个内存数据库的 Prisma Client,然后调用 router 的函数。测试时不用起来整个 Next.js 服务,只要引用appRouter并传入一个 mock 的 ctx 即可。

我在给 t3code 项目补测试时,最喜欢用的方式是在测试文件里创建一个真实的 Postgres 测试库,然后每个用例跑之前先把表清空,跑完之后再清理。这样能保证测试之间不互相污染。要注意的是,如果要测试protectedProcedure,需要手动 mock ctx 里的 session 对象,否则会遇到“没有登录态”的异常。这种可测试性的优势,其实也来自 t3code 分层清晰、依赖注入明确的架构设计。

7. 部署和性能优化:从 dev 到生产要注意的差别

看到这里,你应该已经能够在本地把 t3code 当成一个自定义项目来操作了。但距离一个可以被朋友点开、被用户访问的“网站”,还差部署和生产环境下的一系列细节。这部分如果你没接触过服务端渲染项目,可能会觉得比较超纲,但其实掌握几个关键点就够了。

7.1 构建阶段常见问题:静态生成与动态数据的取舍

Next.js 允许你在构建时通过generateStaticParams或者缓存策略来决定哪些页面是静态的,哪些是服务器端动态渲染。t3code 模板一般默认是动态渲染的,因为它可以实时拿到数据库最新数据,但这也意味着每个请求都会消耗一次服务端资源。如果你的博客页面很少变化,就可以考虑把部分页面改成静态生成,降低服务器压力。

不过这里很容易踩团队协作的坑:有些人觉得既然能缓存,就全站都缓存;有些人担心缓存导致数据不新,又拼命加revalidate。我在实际项目中会先把页面分成两类:强交互类页面(需要实时数据的控制台)走动态渲染;内容展示类页面(文章、用户公开主页)走带有revalidate时间的 ISR。你可以把revalidate理解为一个“冰箱保鲜期”,到了时间它就自动去后台刷新一次数据,兼顾性能和新鲜度。

7.2 部署到 Vercel 时的环境变量传递

t3code 部署到 Vercel 非常顺滑,毕竟 Next.js 本身就是 Vercel 家的产品。但有一个细节经常让新手抓狂:你在.env.local里配置好的变量,Vercel 构建时如果没在后台面板配置,那线上环境会拿不到。所以你需要在 Vercel 项目的Settings -> Environment Variables里把DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL都补上,而且NEXTAUTH_URL一定要填线上域名,不能用http://localhost:3000,否则第三方登录的回调地址会对不上。

关于数据库,如果继续用 Docker 容器挂在本地,那生产环境是连不到你的电脑的。我一般会建议给 t3code 项目配一个云数据库服务,比如 Supabase 或者 Neon。它们兼容 Postgres 协议,直接给你一串连接字符串,填到 Vercel 环境变量里就完事。数据库迁移则可以用构建命令里的prisma migrate deploy,在发布前自动执行。

7.3 运行时性能:留意 tRPC 的批量请求和查询合并

在页面功能变复杂之后,你会发现一个页面可能同时发起七八个 tRPC query。虽然 react-query 会缓存它们,但首次进入时仍然会产生多个网络请求。t3code 推荐的优化方式是用 tRPC 的httpBatchLink,它能把一定时间窗口内的多个请求合并成一个 HTTP 请求发送到服务端。配置起来非常简单,在创建 tRPC Client 的时候把links从httpLink换成httpBatchLink即可。

我个人经验是,批量请求确实能显著减少瀑布式网络往返,尤其是在弱网环境下效果明显。但请注意:如果某些查询体积特别大或者需要流式响应,就不太适合批处理。实际项目中我会先默认开启httpBatchLink,遇到个别接口再单独调整为普通 link。

7.4 检查部署质量前,先分清“编译时报错”与“运行时错误”

有不少朋友在部署上线后遇到问题,第一反应是翻 logs。但对于 t3code 这类项目,有些问题在构建阶段就会被拦截。如果你在npm run build时看到 TypeScript 报错,那基本是类型层面的问题,最好在代码里就修掉;如果你看到 tRPC 的运行时异常,那多半是环境变量配置或数据库连接的问题。我给自己定了一个指标:线上环境在页面加载时,浏览器控制台不能出现任何跟prisma或next-auth有关的红色报错。只要有,就说明环境变量还差东西,不能用“只是有点报错但页面还能用”来搪塞。

8. 关于 t3code,我个人最后的几点手记

每次探索一个新的技术组合,都会让我的知识地图又扩充一点。t3code 在我个人的技术路线图里,不仅是一个仓库名,而更像是一面镜子,映照出未来全栈开发的一种方式:尽可能少地重复声明,尽可能多地让类型告诉机器。你也许不会直接用 t3code 这个名字做自己的产品,但你很可能正在使用 Next.js + tRPC + Prisma 这套组合,而这套组合的精髓就藏在这个小小的代号里。

如果让我给第一次接触它的人一个建议,我会说:不要急于去修改页面样式,先花一个下午把schema.prisma和routers目录里的文件逐行读一遍,然后把 tRPC 的一个 procedure 从前端到后端的完整类型流走一遍。你会被这种“联动感”震撼到,之后再去加自己的业务模块,就会有一种顺水推舟的踏实感。

我现在的日常开发里,遇到简单的个人项目,已经不太想回到以前那种 MongoDB + Express + 手动写接口文档的模式了。t3code 带给我的最大改变,是让我相信工具可以替人做很多“记住接口长什么样”的枯燥工作,而把精力真正放到值得思考的业务逻辑上。如果你也有类似被接口文档折磨过的经历,不妨把 t3code 这类模板跑起来体验一次。技术上要学的东西永远追不完,但好的开发体验和那份“写起来很放心”的感觉,是值得尽早拥有的。

RELATED READING

延伸阅读

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