ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code全栈重构:T3 Stack类型安全实践与踩坑复盘

t3code全栈重构:T3 Stack类型安全实践与踩坑复盘

t3code 是我给那个内部任务看板重写项目起的代号。起因说起来有点丢人:老项目用的是 Express 后端加 React 单页,前后端全靠手写类型和接口文档撑着。后端同事有一天给任务列表接口加了个priority字段,我这边枚举值拼写错了,文档里也写的是high,实际后端要的是urgent,前端整整排查了两个多小时才定位到原因。这种问题不复杂,但出现频率一高,人很容易疲惫。

那段时间社区里不少人聊 T3 Stack,核心卖点是"前后端类型安全"。我一开始觉得这概念不稀奇,TypeScript 我也一直在用。真正让我决定动手试的,是 T3 把整套常用件都拼好了:Next.js 管渲染和接口入口,TypeScript 管全链路类型,tRPC 替代手写 REST 路由,Tailwind 管样式,Prisma 管数据库。从数据库到 React 组件,类型几乎全程跟着代码走,不需要再单独维护一份 API 类型定义。

这篇更像是我把 t3code 从脚手架到部署这段日子的决策记录和踩坑复盘。适合已经写过 React、也碰过后端 API,但还没系统用过 T3 这套组合的开发者。后面每节我都会先说"为什么这么选",再放可以直接抄走的代码和操作。

1. t3code 项目背景:我为什么决定用 T3 Stack 重写这个内部小工具

1.1 从 REST 到全栈类型安全的迁移动机

先交代一下旧架构。看板最早是 Express + React SPA:后端在routes/task.js里定义router.get('/tasks'),返回任务列表;前端在services/api.ts里手动封装fetch('/api/tasks'),同时维护一套Task接口类型。问题在于,这套类型全是手工同步的,两边一旦不同步,编译期完全看不出来。

举个例子,后端想把用户 ID 字段从assigneeId改成assigneeUid,前端Task类型还停在assigneeId,TypeScript 不会报错,因为运行时数据没有强约束。等到渲染函数里写task.assignee.name,可接口返回的却是assignee: null,页面在用户刷新瞬间突然白屏。这类 bug 在小项目里靠自觉还能忍,团队加进第五个人之后,接口文档基本就没人敢信了。

T3 Stack 解决的问题,不是消灭所有 bug,而是把"前后端不一致"这类问题提前到编译阶段暴露。数据模型定义完,Prisma Client类型自动生成;tRPC router 的输入输出类型由数据模型推导;前端调用trpc.task.list.useQuery(),拿到的返回值直接就是带类型的。后端改了返回字段,前端引用旧字段的地方立刻标红。这个反馈链路很值钱——改接口不再是"你改完告诉我一声",而是"编译器先帮我筛一遍"。我把这个重写项目取名为 t3code,就是想验证它在小团队内部工具里到底顺不顺手,顺便把旧看板的问题逐个清掉。

1.2 T3 Stack 四件套各自承担的角色

把这几件事的分工说清楚,后面读代码才不会迷路。

层工具在 t3code 里承担的事
语言TypeScript全链路类型约束,数据库到组件都吃同一套类型
框架Next.js页面渲染、API 入口、Server Component 数据获取
样式Tailwind CSS原子化样式,做内部工具基本够用
API 层tRPC类型化调用,替代手写 REST 路由
数据层Prisma数据库建模与类型化 ORM,表结构一变类型自动变

经常有人问我为什么不用 GraphQL。GraphQL 也能做到前后端类型同步,但引入 Schema、Resolver、Codegen 之后,配置成本明显更高。t3code 是个内部工具,团队人不多,最需要的是"少维护一层中间约定";GraphQL 相当于为了上电梯先修一栋楼,不划算。还有人提 NestJS,如果团队规模很大、模块化要求很重,NestJS 确实有优势;但就 t3code 这种业务量,T3 的扁平结构写起来更直接。说白了,选型不是比赛谁先进,而是看谁和你的团队结构匹配。这一点到脚手架部分会更明显——T3 默认帮你规避了很多选择困难。

2. 脚手架初始化:create-t3-app 的选项直接影响后面工作量

2.1 初始化命令和选项怎么选

创建项目走的还是官方脚手架:

pnpm create t3-app@latest

项目名我输入了t3code,框架默认走 App Router。交互式选项里,我按这个策略选的:

  • TypeScript:必选,不用犹豫。
  • Tailwind CSS:选上。除非你明确要自己写 CSS,否则配 ESLint、Prettier 很顺。
  • tRPC:选上,这是 T3 和普通 Next.js 模板区别最大的地方。
  • Authentication(Auth.js):选了。内部工具几乎必有登录,模板直接给你接入逻辑。
  • Prisma:选了。想少写 SQL,又不想引入太重的东西,Prisma 是目前比较省心的方案。
  • ESLint / Prettier:选了,团队代码风格靠它兜底。

新版脚手架可能还会问 Drizzle 还是 Prisma。我选了 Prisma,理由是 migration 体系成熟,团队其他人对 Prisma 也熟。这里多说一句:选好之后不要中途换,Drizzle 和 Prisma 的 API 风格差别太大,写了一半再搬家的成本很伤。

2.2 初始化后最值得先读的几个文件

生成完目录,先别急着写页面,把下面这几个文件过一遍,后面的困惑会少一半:

src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ auth.ts db.ts api/ trpc.ts routers/ root.ts trpc/ react.tsx server.ts

api/trpc/[trpc]/route.ts是所有/api/trpc/*HTTP 请求的入口,Next.js 收到请求后转发给 tRPC 处理。这个文件基本不用改,但它的存在决定了 tRPC 在 App Router 里的工作方式。server/db.ts是 PrismaClient 单例,关键点在于开发模式热重载时,不会为每个组件重新创建数据库连接池。server/auth.ts放着 Auth.js 配置,session 回调、Provider 都在这。server/api/trpc.ts是 tRPC 初始化的核心文件,publicProcedure、protectedProcedure都在这里定义,后面所有路由的权限控制都依赖它。trpc/react.tsx把 tRPC 和 React Query 绑在一起,导出带类型的前端trpc对象,前端所有数据请求都从它来。

理解这几个文件之后,你会发现 create-t3-app 真正的价值不是帮你写代码,而是帮你建立了一套"数据层怎么分层"的规范。我第一次用的时候,最大的收获其实是这套目录划分——它逼着你把路由、数据库、认证边界分开。

2.3 初始化后第一件要先改的配置

脚手架生成的是能跑的模板,不代表能直接上线。按我自己的顺序,第一个要动的是.env:

DATABASE_URL="postgresql://user:password@localhost:5432/t3code" AUTH_SECRET="$(openssl rand -base64 32)" AUTH_URL="https://t3.example.com"

注意AUTH_URL(旧项目里是NEXTAUTH_URL)最好一开始就写成未来线上要用的域名,别先用http://localhost:3000然后指望后面会想起来改。这个坑我在部署阶段踩得很深,后面专门写了。第二个要留意的是next.config.mjs,如果后面要展示外部图片,记得先配images.remotePatterns,否则 Next.js 会直接报错拒绝加载。第三个是tailwind.config.ts,改一下主题色和字体变量,内部工具至少看着不像默认模板。另外,用 pnpm 装完依赖后如果 postinstall 脚本报错,先查是不是原生模块,比如sharp、bcrypt这类,大概率是系统缺编译工具,不是项目问题。

3. 数据模型与 tRPC 路由设计:把 CRUD 做成类型闭环

3.1 先定义 Prisma Schema

t3code 的业务我拿"书签收藏"当例子展开。内部工具经常要收藏各种链接,这个模型简单,但覆盖了 CRUD 的典型场景:

model Bookmark { id String @id @default(cuid()) title String url String @unique description String? userId String user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

改完 Schema 之后执行:

npx prisma migrate dev --name init

这一步会生成 SQL migration 文件和新的 Prisma Client。之后所有ctx.db.bookmark的类型都是根据模型推导出来的,字段拼错了会在编辑器里直接标红,不用等请求发出去再被数据库报错。对新手来说,这种"改模型立刻改类型"的体验,比传统 ORM 舒服很多。

3.2 tRPC router 的落地写法

t3code 里对应的路由文件长这样:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const bookmarkRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { return ctx.db.bookmark.findMany({ orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), url: z.string().url(), description: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.bookmark.create({ data: { ...input, userId: ctx.session.user.id, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.db.bookmark.delete({ where: { id: input.id } }); return { success: true }; }), });

简单解释一下:createTRPCRouter是路由容器,procedure是一个接口。query对应读操作,mutation对应写操作。输入为什么要用 zod?因为 zod 的 schema 推导出的类型会同时暴露给前端,等于后端校验规则对前端是可见的。前端的title如果是个空字符串,发送请求前编辑器就能标出来,不必等后端返回 400。

写完后在根路由注册:

export const appRouter = createTRPCRouter({ bookmark: bookmarkRouter, });

同时记得导出AppRouter类型:

export type AppRouter = typeof appRouter;

很多人改完 router 后前端没反应,多半是忘了重启 dev server,或者编辑器还没识别到根路由的类型变化。tRPC 能做到类型同步,靠的就是这个AppRouter类型被前端的react.tsx引用,属于"类型即 API"。

3.3 protectedProcedure 中间件的写法

protectedProcedure是 create-t3-app 已经帮你写好的权限校验中间件,但它的默认实现只是判断ctx.session是否存在。我的建议是再加一层判断,不要用!断言:

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

TRPCError的code会被 tRPC 自动映射成 HTTP 状态码,UNAUTHORIZED对应 401。前端挂一个全局onError处理,就能统一展示登录过期或未授权的情况。

这块最常见的翻车点是:只判断ctx.session而不判断ctx.session.user,导致 session 对象存在但 user 字段为空时,后续ctx.session.user.id直接抛错。另一个坑是手贱在中间件里写next({ ctx: { session: ctx.session! } }),然后抱着侥幸心理往下传。类型断言掩盖的不是类型问题,是逻辑漏洞,迟早会在线上还回来。我个人的习惯是中间件第一行就亮出来:if (!ctx.session || !ctx.session.user),看着啰嗦,但一天跑几千次的接口经得起这种保守。

4. 认证接入环节:Auth.js 与 tRPC 中间件协同的完整踩坑记录

4.1 Provider 选择:内部工具优先考虑 OAuth

t3code 做的是内部看板,我直接选 GitLab OAuth:团队日常登录已经依赖 GitLab,加一个第三方认证入口,密码管理成本几乎为零。Auth.js 里配置 GitLab Provider 大概是这样:

import GitLabProvider from "next-auth/providers/gitlab"; export const authConfig = { providers: [ GitLabProvider({ clientId: process.env.GITLAB_CLIENT_ID, clientSecret: process.env.GITLAB_CLIENT_SECRET, }), ], };

如果你用的是 Auth.js v5,session 策略默认是jwt,这也是 create-t3-app 模板的默认值。想换成数据库 session(用@auth/prisma-adapter存 Session 表)也不是不行,但要评估:多了一套表,登录的查询链路变长,对小工具来说收益不大。我最后仍采用 jwt 策略,理由很简单——内部工具最重要的是登录稳定和回调配置简单,JWT 天然无状态,部署时少一个 Session 表要维护。

4.2 session 回填 userId 的那处关键配置

这是我在 t3code 里印象最深的一个坑。用 jwt 策略时,session回调的第二个参数是token,不是user,所以网上那种抄session.user.id = user.id的写法,在默认模板下根本拿不到user。正确做法是先往 token 里塞字段,再从 token 里取:

callbacks: { jwt: ({ token, user }) => { if (user) { token.id = user.id; } return token; }, session: ({ session, token }) => { if (session.user) { session.user.id = token.id as string; } return session; }, }

如果不回填id,ctx.session.user.id会一直为空,tRPC 中间件里userId永远拿不到。最难受的是,代码逻辑完全没问题,前端也不报错,直到创建数据时userId为 undefined,数据库约束给你一个措手不及的错误。这类问题在开发环境出现的概率很低,因为很多示例数据根本不走登录;一旦接上真实登录,马上暴露。

4.3 tRPC context 里正确获取 session

src/server/api/trpc.ts里的 context 构造函数,是 tRPC 拿到 session 的关键:

export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authConfig); return { db, session, headers: opts.headers }; };

Auth.js v5 里也可以直接用auth(),效果类似。需要留意的是,部分版本要求把opts.headers传给auth()或getServerSession,否则在代理环境下 session 可能解析不出来。如果你发现本地登录正常、线上登录一直是 anonymous,优先怀疑这个位置少传了 headers。

还有个小陷阱:AUTH_SECRET没设的情况下,Auth.js 启动时会报MissingSecret。这个错误在本地跑一般不出现,因为模板会生成一个开发用的随机值;但部署到服务器时,如果你没把环境变量写进生产环境的.env,而只是写在.env.local里,build 时一切正常,运行时却会突然报错。建议直接把AUTH_SECRET作为独立环境变量注入,不要指望.env文件能跨环境迁移。

4.4 反代和回调地址:登录成功为什么跳回 localhost

我把 t3code 部署在 Nginx 后面,第一次测登录就翻车了:GitLab 回调成功后,浏览器地址却跳到了http://localhost:3000/api/auth/callback/gitlab,页面 404。排查半天,两个原因:

  • AUTH_URL没设置成外部域名,Auth.js 按照请求的 Host 头来拼回调地址,代理把内部端口传过去了。
  • Auth.js v5 默认不信任反向代理的X-Forwarded-Host头,需要显式开启。

解决方案是在生产环境设置:

AUTH_URL="https://t3.example.com" AUTH_TRUST_HOST=true

同时 Nginx 配置要保留 Host 头并记录原始协议。如果外部走的是 HTTPS,Nginx 没配X-Forwarded-Proto https,Cookie 的secure属性也会出问题,表现为登录成功但 Cookie 不写入浏览器,刷新后又回到未登录状态。这个问题排查起来很磨人,因为前端完全看不到报错,只是"永远登不上"。我的经验是,遇到登录类问题不要先怀疑代码,先把AUTH_URL、AUTH_TRUST_HOST、代理三件套检查一遍,比看半天堆栈有效率。

5. 数据刷新策略:React Query 缓存失效和"改完看不见"的解法

5.1 tRPC hooks 的常规用法

t3code 前端组件里调用接口很简单:

"use client"; import { trpc } from "@/trpc/react"; export function BookmarkList() { const utils = trpc.useUtils(); const { data, isLoading } = trpc.bookmark.list.useQuery(); const createMutation = trpc.bookmark.create.useMutation({ onSuccess: () => utils.bookmark.list.invalidate(), }); if (isLoading) return <div>加载中...</div>; return <ul>{data?.map((item) => <li key={item.id}>{item.title}</li>)}</ul>; }

这里的trpc.bookmark.list是类型推导出来的,所以前端不需要一个独立的 API 封装文件。invalidate()是 React Query 里的缓存失效操作,作用是标记对应 key 的缓存为过期,并重新请求。tRPC 把完整的路由地址作为 key,因此可以做到很细粒度的失效——只刷新bookmark.list,不需要全页面重新拉。

5.2 并不是每次写操作都值得 invalidate

invalidate很好用,但如果列表已经很大,每次删一条都要重新拉 100 条,其实不划算。在 delete 这种操作里,我更推荐直接操作缓存:

const deleteMutation = trpc.bookmark.delete.useMutation({ onSuccess: (_data, variables) => { utils.bookmark.list.setData(undefined, (old) => old?.filter((item) => item.id !== variables.id), ); }, });

这样删除后没有多余请求,列表直接少一行。但是要注意,setData的第一个参数是 query 入参。bookmark.list没有入参,所以传undefined;如果你的列表带筛选条件,比如useQuery({ filters }),要把同样的 filters 对象传进去,否则更新的是无入参的缓存,界面上毫无反应。这个细节很容易写错,我一开始就在这里卡过一刻钟。如果对数据一致性要求更高,可以删完后再补一个invalidate兜底,onSuccess里两个都调用。代价是一次重复请求,换来的是一致性更强,具体取舍看你的业务。

5.3 乐观更新:按钮立刻反馈,后台再校验

t3code 里有一个"标记书签为已读"的操作,一开始每次点击都等服务器返回再更新 UI。网络状况不稳定的时候,按钮反馈明显慢半拍。后来改成乐观更新,体验提升了一个档次:

const updateMutation = trpc.bookmark.update.useMutation({ onMutate: async ({ id, title }) => { await utils.bookmark.list.cancel(); const prev = utils.bookmark.list.getData(); utils.bookmark.list.setData(undefined, (old) => old?.map((item) => (item.id === id ? { ...item, title } : item)), ); return { prev }; }, onError: (_err, _input, ctx) => { if (ctx?.prev) { utils.bookmark.list.setData(undefined, ctx.prev); } }, onSettled: () => utils.bookmark.list.invalidate(), });

这里有几个关键点。cancel()的作用是取消进行中的 refetch,避免旧数据把刚改过的缓存覆盖掉;getData()把旧数据存起来,方便 onError 回滚;onSettled里的invalidate()是最后的兜底,即使乐观更新逻辑有偏差,服务器数据最终还是会把前端拉回真实状态。这个模式的执行顺序不要乱调整,尤其不能把invalidate放到onMutate前面,否则 UI 会先闪一下再被旧缓存覆盖。

5.4 服务端组件里调 tRPC 用独立网关

App Router 之下,服务端组件如果也想直接查数据,不要用 hooks,而是用服务端网关:

import { api } from "@/trpc/server"; export default async function Page() { const bookmarks = await api.bookmark.list.query(); return <div>{bookmarks.length} 条书签</div>; }

api是createCaller()生成的服务端调用器,它可以绕过 HTTP,直接执行路由函数。但是注意:服务端调用默认没有浏览器上下文,session 可能拿不到,所以只适合读公共数据,或者确保createTRPCContext里正确注入了请求 headers。我在 t3code 里用服务端数据获取做首页的默认展示,需要登录态的请求仍然走客户端 hooks,这样既省了一次客户端加载的闪烁,又不会把权限模型搞乱。

6. 部署阶段经验修正:本地跑通 Build 之后还会翻车的地方

6.1 Build 失败多半和代码无关

t3code 第一次提交到 CI 跑构建,报了一个让我摸不着头脑的错误:PrismaClientInitializationError: Can't reach database server。本地明明跑得好好的,为什么 build 时突然要连数据库?

原因在于 Next.js 的服务端代码在构建阶段就会执行一部分逻辑,包括 PrismaClient 的初始化。如果你的 CI 环境变量里DATABASE_URL指向一个不存在的数据库,或者那个数据库不允许 CI 容器的 IP 连入,build 就直接失败。解决办法很简单:构建前确认DATABASE_URL在 CI 里可见且可达。如果在 GitHub Actions 里用 service container 开一个临时 Postgres,把环境变量指过去就能跑。这条经验后来帮工作室另外两个项目省了不少时间。

6.2 数据库迁移顺序:deploy 不是 dev

生产环境的数据库迁移,一定要用prisma migrate deploy,而不是prisma migrate dev。dev会先尝试重置数据库,还可能要求交互确认;deploy只会按顺序应用 migration 文件。我建议把迁移放到构建流程里,顺序是:

npx prisma generate npx prisma migrate deploy pnpm build

如果数据库是托管的(比如 Supabase、Neon、RDS),记得把部署机器的 IP 或 VPC 网段加进白名单,否则 migration 会在连接阶段就超时。还有一点:如果多台机器同时部署,不要让多实例同时跑 migrate,数据库锁竞争会造成一次成功的 migration 被另一个实例跳过。最好由 CI 单独控制迁移步骤,应用实例只负责启动。

6.3 standalone 模式漏掉静态资源

create-t3-app 默认生成的next.config.mjs没什么特殊设置,但为了部署体积,我给 t3code 加了output: "standalone"。这个配置会把运行时代码打包到.next/standalone目录,部署时只需要复制这个目录和.next/static就能跑。

问题来了:standalone目录默认不包含.next/static。直接运行node .next/standalone/server.js后,页面 HTML 能出来,JS 和 CSS 全部 404,刷新还白屏。我第一次部署时在这个问题上卡了整整一晚,最后翻到官方文档才意识到要手动复制静态资源:

cp -r .next/static .next/standalone/.next/static

这条已经写进了 t3code 的postbuild脚本里,避免下次再忘。如果你用的是 PM2 守护进程,记得启动命令里带环境变量文件,比如pm2 start ecosystem.config.js,否则.env不会被自动读取。

6.4 线上日志排查的经验顺序

线上出问题,我一般按这个顺序排查,效率最高:

  1. 先看进程日志:500 是代码异常、连接超时还是数据库错误。
  2. 再确认 tRPC 接口是否通畅:直接用 curl 打一个接口,返回UNAUTHORIZED就是 session/cookie 问题,返回P1001就是数据库问题。
  3. 最后查 session cookie:域名、secure 属性、过期时间三个维度逐一检查。

举个例子,t3code 上线第一天有个页面报 500,日志里是 Prisma 连接超时,但数据库面板显示连接数不高。排查半天发现,我用的是默认连接池配置,高并发请求时连接被占满。后来在 schema 的连接串里加了几条 pool 参数,问题才解决。这类问题不到线上不会暴露,尽早做一轮压力测试是值得的。

最后分享一个过程里养成的习惯:我把 t3code 里所有 mutation 相关的缓存处理集中在一个模块里,用useUtils()统一暴露给页面组件。刚开始觉得多此一举,后来发现只需要维护一个文件,就能把 invalidate、乐观更新、回滚这三件事理清楚,排查问题时不用翻遍每个页面。另外一个感受是,遇到 tRPC 类型标红,不要急着as any。绝大多数情况是 zod schema 或 Prisma 模型写错了,类型报错本身就是最有用的调试信息,顺着它改根因,比绕过去舒服得多。后面如果有时间,再把 t3code 的定时任务和 WebSocket 通知方案整理出来发一篇。

RELATED READING

延伸阅读

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