☰
T3 Stack实战:用TypeScript全栈链路重构内部报表工具
2026/10/14 7:37:11 网站建设 项目流程

t3code 是我最近一个内部项目重构的代号——用 T3 Stack 把公司那个传了三代的数据报表工具彻底重写了一遍。T3 Stack 这两年在前端圈出镜率相当高,核心就是把 TypeScript、Tailwind CSS、tRPC、Next.js 组合在一起,官方给的三条原则是类型安全、务实、周密。这篇博文献上从零搭到上线压测的完整过程,包括关键设计决策、踩过的坑和最后的选型判断,希望能给正在纠结"要不要上 T3 Stack"的你一些参考。

1. 为什么要把内部工具重写为 t3code:一个真实项目的决策过程

1.1 老代码的三个致命问题

这个报表工具公司用了快三年,后端是 Express,前端是 React 函数组件搭配手工拼的接口。提需求的人越来越多,代码越来越乱,最让我头疼的是三个问题:

  • 类型断层:前端调接口的时候手写 interface,后端一改字段结构,前端完全不知道。经常是页面白屏之后打开 Network 面板,才发现接口返回的字段名变了。
  • 接口拼凑:一个报表详情页要调五六个 REST 接口,然后在组件里手工 join 数据。多一次网络往返就多一层概率性故障,接口之间对不上的情况几乎每周都出现。
  • 重复请求:组件挂载以后在 useEffect 里 fetch,依赖数组稍微写错一点,要么数据不刷新,要么无限循环。两个组件同时需要同一份数据时,没有缓存层,只能各自拉一遍。

这还不是最致命的。最致命的是每次我改完接口,还要打开一个十几页的在线文档去手动更新接口说明,而前端同事早就放弃看文档了。整个团队的协作模式变成了"后端改完喊一声,前端自己猜"。

1.2 T3 Stack 的三原则是怎么说服我的

真正让我决定认真评估 T3 Stack 的,是它提出的三条原则——类型安全、务实、周密。翻译成大白话就是:能靠编译器拦住的问题不要拖到运行时,能用简单直接的方式解决就不要引入额外的复杂度,每一步取舍都想清楚后果。

先说类型安全。T3 Stack 从数据库到服务端到客户端,全程 TypeScript,最吸引人的地方是 tRPC。它是真正意义上的"函数即接口":后端写一个函数,前端 import 这个函数的类型之后直接调用,参数校验、返回类型、错误处理全部自动推导,不需要手写 OpenAPI 规范,不需要生成 SDK,更不存在"文档过期"这件事。

再说务实。T3 Stack 默认用的是 Next.js,这套东西在 React 生态里足够主流,SSR、静态生成、边缘函数都有现成方案,不用自己拼搭建的零件。Tailwind CSS 虽然争议不小,但对于一个以表单、表格、图表为主的内部工具来说,它的原子类写法确实是出效果最快的选择,团队已经用了一年多,不存在额外的学习成本。

至于周密,我在初始化项目的时候体会最深。create-t3-app 会让我在创建项目时就明确选哪些模块,而且默认配置都是经过实战检验的。比如它默认不做 Redux,不做 styled-components,不为还没发生的问题提前上重型方案,用最小集合先把事情跑通。

我给这个项目起代号叫 t3code,一方面是这个技术栈的简称,另一方面也是在提醒团队:写逻辑之前先想类型,让编译器做我们的第一个 code reviewer。

2. 用 create-t3-app 初始化项目:命令、选项和目录结构

2.1 初始化命令与模块选择

初始化本身很简单,一条命令:

npm create t3-app@latest

随后会出现交互式选项,我需要逐个确认要包含的模块。发起初始化的时候我把 TypeScript、Next.js、Tailwind CSS、tRPC、Prisma、NextAuth 全部勾上了,这样的好处是一次性把全链路类型安全打通,缺点是初始化时间明显变长,依赖安装完接近几百兆。如果只是想先体验一下,完全可以把 Prisma 和 NextAuth 留到第二步,毕竟把一条空的链路跑起来之后再接数据层,心理压力会小很多。

勾选完成后,脚手架会提醒我配置环境变量。这一步实际挺关键,因为后续生成出来的代码里,数据库连接串、认证密钥、站点地址全都要靠.env提供。模板里还有一个.env.example,我建议把它当成必填清单,一行行核对过去,避免后面运行测试时出现一长串环境变量缺失的报错。

2.2 目录结构里的设计边界

初始化完成之后,src 下的目录结构是这个样子:

src/ components/ pages/ api/ trpc/[trpc].ts server/ api/ root.ts routers/ db.ts auth.ts styles/ utils/

最需要注意的是server/这个目录。脚手架默认它只跑在服务端,所有涉及数据库、密钥、服务端上下文的代码都要放在这里,客户端组件绝对不能 import 里面的内容,否则打包时会有非常明确的报错。刚开始我图省事,直接在前端组件里 import 了 Prisma client,结果在浏览器 console 里看到了数据库连接串相关信息,这个问题如果上线会非常严重。正确做法是只通过 tRPC 暴露出去的数据接口来获取服务端数据。

另一个设计点是pages/api/trpc/[trpc].ts这个 route handler。tRPC 的所有请求都会汇聚到这个单一接口下,前端发出的调用本质上都是一个 POST 请求。对于习惯了 REST"一个资源一个接口"的人来说,这种做法需要时间适应,但它带来的好处是:所有 RPC 请求天然支持 HTTP 批量合并,一个页面多个查询可以同时发出去,减少了网络往返。

我还做了一次"最小闭环"验证。在没有写任何业务代码之前,先启动开发者服务器,打开默认欢迎页,在页面里调用api.health(脚手架现场生成的可替换示例 query),确认请求从浏览器发出、经过 Next.js API 路由、进入 tRPC router、返回结果,整条链路是通的。这步虽然不起眼,却能隔离掉"环境问题"和"代码问题",后续写业务时心里有底。

3. 数据库建模与 tRPC 查询:TypeScript 类型如何一路贯通

3.1 Prisma schema 设计和迁移流程

T3 Stack 默认用 Prisma 做数据库访问层。在设计报表工具的 schema 时,我第一步先把核心实体写出来:

model Report { id String @id @default(cuid()) title String description String? status ReportStatus @default(DRAFT) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId, status]) }

这里的ReportStatus是枚举类型,把状态固定为 DRAFT、REVIEWING、PUBLISHED、ARCHIVED 四种。用枚举而不是字符串,对业务的好处是:状态流转变得可校验,后端在查询时可以按枚举过滤,前端拿到的数据里状态字段自动带上字面量类型,写 switch 判断时享受自动补全。

写完 schema 后执行:

npx prisma migrate dev --name init

Prisma 会生成迁移 SQL 并同步数据库,同时会根据 schema 生成完整的 TypeScript 类型。从这一刻起,只要我在代码里写prisma.report.findMany(),返回结果的字段就全部被类型约束,"数据库字段和代码类型不一致"这种问题直接从根源上消失了。

3.2 tRPC router 的写法:从 query 到 protectedProcedure

接着我在server/api/routers/report.ts建立了报表模块的第一个 router:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const reportRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ status: z.enum(["DRAFT", "REVIEWING", "PUBLISHED", "ARCHIVED"]).optional(), page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx }) => { const where = { authorId: ctx.session.user.id, ...(input.status ? { status: input.status } : {}), }; return ctx.db.report.findMany({ where, orderBy: { createdAt: "desc" }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }); }), });

注意我在导出的方法里用的是protectedProcedure,它不是普通 procedure,而是一个已经接入会话检查的中间件包装。后面讲 NextAuth 时会展开说它的实现。这里先提一点:tRPC 的input用 zod 定义,这对前端是透明的。React 端调用api.report.list.useQuery({ status: "PUBLISHED" })时,TS 会自动推导出input的合法形状,传一个status: "NOT_EXIST"在编译阶段就会直接爆红。

3.3 第一次类型报错的完整排查链路

第一次把reportRouter接入页面时,我遇到了一个印象深刻的类型报错。前端调用一切正常,但在query回调里面写ctx.session.user.id时,TypeScript 提示说session可能是null,user也可能没有id属性。

排查过程我完整复述一遍,因为这个坑几乎每个第一次用 NextAuth 配 tRPC 的人都会踩:

  • 第一步,查看createTRPCContext的返回类型。在server/api/trpc.ts里,上下文对象是{ db, session },而session来自getServerSession(),它的类型是Session | null。
  • 第二步,确认 NextAuth 的默认类型定义。默认情况下,session.user只有name、email、image三个字段,id根本不在里面,所以哪怕运行时数据库里有 id,类型上也不存在。
  • 第三步,修复策略。在auth.ts中扩展Session和JWT类型,并在session回调里把用户 id 塞进去:
// src/types/next-auth.d.ts import type { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }

再在 NextAuth 配置的callbacks.session里返回带id的 session:

callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub!; } return session; }, }
  • 第四步,回到trpc.ts里,protectedProcedure的实现中增加一道守卫,确保无会话时直接抛出明确的错误码,而不是让代码在运行时才炸:
import { TRPCError } from "@trpc/server"; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session }, }); });

这一套组合拳打完,ctx.session.user.id的类型推导立刻变成了string,而且未登录用户会被挡在查询之外。从此以后,前端拿到的数据默认就是"当前登录用户自己的数据",再也不会看到别人家报表的字段。

4. Next.js 页面与 SSR:在服务端调用 tRPC 的几个细节

4.1 为什么我选 getServerSideProps 而不是 useEffect

报表工具虽然是个内部系统,但"首屏数据从哪来"这个问题依然值得认真考虑。很多习惯了纯客户端渲染的团队会直接在组件里写:

const { data } = api.report.list.useQuery({ page: 1 });

然后 useEffect 里再处理 loading。对小项目这没问题,但当一个页面需要同时展示汇总数据、列表数据、用户信息时,客户端渲染会造成页面先空白,数据到了才渲染,刷新时还会有一闪而过的 loading 闪烁。

我在 t3code 里做了一个折中方案:首屏核心数据走 getServerSideProps 预取,列表翻页和筛选走客户端查询。这样用户每次打开页面,拿到的是完整 HTML,感官上更快,也不需要为每个页面写大量 loading 状态。

于是我在服务端直接创建 tRPC 的 caller:

import { createServerSideHelpers } from "@trpc/react-query/server"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; export const getServerSideProps = async () => { const helpers = createServerSideHelpers({ router: appRouter, ctx: await createTRPCContext({ req, res }), }); const reports = await helpers.report.list.fetch({ page: 1, pageSize: 10 }); return { props: { trpcState: helpers.dehydrate() } }; };

这里最需要注意的细节是:getServerSideProps 的req和res必须传给createTRPCContext,这样服务端才能拿到 cookie、构建出带 session 的上下文。我最初踩过一个坑,贪图省事直接传了一个空对象上下文,结果服务端调用到了 protectedProcedure 时抛了 UNAUTHORIZED。

4.2 服务端调用不要直接享受批处理,但数据水合是免费的

服务端调用和客户端调用的一个区别是:客户端useQuery天然支持请求批处理,多个 query 会合并成一个 HTTP 请求,但createServerSideHelpers里的fetch是直接在 Node 进程里执行的,不走 HTTP,所以不存在"合并"问题,也没必要考虑。

真正爽的是数据水合。服务端把 tRPC 查询结果通过trpcState传给客户端后,客户端用api.report.list.useQuery()同参数调用时,会直接命中服务端已经查询过的数据,不会发第二次网络请求。

也就是说,我在服务端 fetch 了一遍,在客户端再用 hooks 取同样的数据,React Query 会自己判断数据已存在,直接进入 success 状态。这也意味着组件内的isLoading逻辑可以写得很干净,不需要担心刷新时闪 loading。

4.3 SSR 页面还要注意的一个隐藏问题

服务端调用 tRPC 时,如果某个 query 返回的数据体积很大,会被直接序列化进 HTML,哪怕客户端根本不展示。我在一个详情页里犯过这个错误,把一整个报表的所有明细行全部塞进了 getServerSideProps 的结果里,页面 HTML 一下子多了几 MB。

后来我把明细数据的加载拆成了客户端 query,只在用户滚动到详情区域时才请求,首屏 HTML 体积立刻降下来了。所以我的经验是:SSR 预取只做首屏真正需要的数据,不要把整条查询链全都搬到服务端。

5. 认证集成:NextAuth 与 tRPC 的中间件联动

5.1 NextAuth 配置里最容易忽略的三个点

T3 Stack 默认集成的登录方案是 NextAuth。我选择了 GitHub Provider 加 Prisma adapter,主要原因是内部团队本来就全员用 GitHub,少维护一套密码体系。

配置整体不复杂,但以下三个点我在实测里都被绊过:

  • SESSION 策略要选 database。NextAuth 默认是 jwt 策略,jwt 用起来简单,但配合 Prisma adapter 时,两个用户几乎同时登录会偶发会话归属错乱。我在多台设备上同步测试时发现,database 策略配合数据库存储 session,稳定性明显更高,而且可以在后台做会话吊销。
  • 环境变量必须完整。NEXTAUTH_SECRET和NEXTAUTH_URL缺一不可。我经历过一次因为.env没同步到部署环境,登录跳转后被 NextAuth 拦截报安全错误的诡异情况,排查半天才意识到是两套环境变量不一致。
  • Provider 的 Client ID 和 Client Secret 不要写进代码库。这个可能看起来像废话,但我在 README 里见过直接把 provider 配置贴出来的仓库,敏感信息一旦进 Git 历史,后面回收成本很高。

5.2 protectedProcedure 的权限控制怎么设计

认证模块接入 tRPC 之后,我只在根路由里定义了一次上下文:

const createTRPCContext = async ({ req, res }) => { const session = await getServerSession({ req, res }); return { db, session }; };

由于我会在正确位置调用getServerSession,整个应用里的所有 procedure 都能拿到 session。需要登录的查询和变更,统一使用protectedProcedure,不需要登录的公开数据使用publicProcedure。这样写的好处是可以很直观地在 router 定义处看出这个接口的暴露面。

我还加了一层更细的权限控制。报表工具里有"查看者"和"管理员"两种角色,管理员能改配置项,查看者只能看数据。于是我在 protectedProcedure 的基础上扩展了一个adminProcedure:

const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next(); });

角色字段我存在数据库用户表里,通过 session 回调带进 session 对象,权限判断全部收敛到中间件层,业务代码只需要选对 procedure 就行。这样一来,前端组件里就不要再写一大堆if (user.role === "ADMIN")的分支判断了,因为数据入口已经做了控制。

6. 部署与优化:打包体积、缓存策略和真实体感

6.1 部署环境选择与 Serverless 函数拆分

t3code 最终部署在 Vercel 上,数据库用一个独立的 PostgreSQL 实例。选择 Vercel 不是因为它是最快的,而是因为 Next.js + tRPC + Prisma 这条链路在 Serverless 环境里已经被大量验证过,踩坑成本最低。

不过 Serverless 环境下需要额外注意一个点:Prisma client 的体积会影响冷启动时间。首次部署时我发现页面加载偶尔要等一两秒,排查之后发现是函数包里塞了 Prisma 的二进制引擎文件。

优化方式是两板斧:

  • 在package.json构建脚本中加入prisma generate,确保生成的 client 匹配当前平台。
  • 在next.config.js里配置serverComponentsExternalPackages: ["@prisma/client", "bcrypt"],避免 Next.js 打包器把 Prisma 二进制文件重复打包,造成体积膨胀。

优化完后冷启动时间从一秒多压到了可接受范围。另外我把一些非核心但耗时的查询(比如按月生成报表汇总)拆到了单独的 serverless function,通过定时任务触发,避免用户点击时即时计算。这种做法对内部工具尤其重要,因为报表数据会随业务增长越来越重,提前做一层异步化会让体验稳定很多。

6.2 数据请求层的性能优化细节

部署完成之后,我重点优化了列表页的请求模式。最初所有明细数据都走一个listquery,数据量一涨就卡,后来做了三件事:

  • 列表改成分页查询,用 React Query 的keepPreviousData保持翻页时不会闪空。
  • 统计数据单独一个 query,加refetchInterval: 30000让它 30 秒自动刷新一次,不影响列表交互。
  • 筛选条件全部通过 tRPC 的 input 传给后端,由数据库完成过滤,而不是前端把全量数据拉到本地再 filter。这就是 tRPC + zod 的好处:input 的校验逻辑是服务端和前端共享同一份代码,类型安全不是纸面承诺。

还有一个小细节是httpBatchLink。T3 Stack 默认配置里就启用了请求批处理,同一个事件循环里发起的多个 tRPC 请求会被合并成一个 HTTP POST。我在页面里同时调用report.list和report.stats时,Network 面板里只能看到一个请求,页面的首屏加载时间因此少了一次往返。

6.3 上线压测后的真实体感

压测工具我用了简单的 k6 脚本,模拟 20 个并发用户持续访问列表页和详情页,持续 10 分钟。观察到的结论是:Serverless 函数在并发增加时会自动扩展,数据库连接池成了瓶颈。

我后来给 Prisma 的连接池配置加了比较保守的上限,并且开了 PgBouncer 做事务池。对内部工具来说,20 个并发已经是日常峰值,这套配置在压测里 CPU 和内存曲线都平稳,页面 P95 响应时间维持在 700ms 左右。坦白说这个数字并不是极致性能,但对一个内部报表工具而言,稳定可维护远比极限并发有意义。

7. 什么样的项目适合 T3 Stack:选型边界与我的最终建议

7.1 适合与不适合的场景

t3code 上线之后,我经常被同事和朋友问到同一个问题:T3 Stack 适合所有新项目吗?我的判断是"一定不是",下面这张表是我在实际使用后整理出来的参考:

适合的场景不适合的场景
全栈 Web 应用,前后端由同一团队维护需要向第三方系统大规模暴露 API 的开放平台
后台管理、内部工具、数据看板纯内容展示站,几乎无交互逻辑
以表单、CRUD、权限控制为核心的业务系统需要多语言异构服务、团队各自为政的大规模微服务
团队全员 TypeScript,不想维护接口文档已有成熟 REST 体系和 SDK 生成链路的组织

第三方的 API 消费方如果完全不是 JavaScript 技术栈,tRPC 路由暴露出去的意义就不大,因为对方没法直接 import 类型,最终还是得面向 OpenAPI 文档,这时候 tRPC 相对 REST 的优势就没了。

7.2 给初次尝试 T3 Stack 的团队三条建议

按我踩过的坑,如果你决定上 T3 Stack,这三个建议应该能帮你少走弯路:

  • 先把最小闭环跑通,再慢慢加模块。初始化的时候可以只选 TypeScript、Next.js、Tailwind,等熟悉了 tRPC 的写法,再引入 Prisma 和 NextAuth。一次全选会让"环境问题"和"学习问题"混在一起,排错时很痛苦。
  • 数据库 schema 是你的契约,不是后端的私有实现。Prisma schema 一改就牵动整个类型链路,所以前期花在设计 model 上的时间非常值得。我后来养成一个习惯:改 model 之前先想清楚这个字段会被哪个 tRPC query 消费、会对前端类型产生什么影响。
  • 不要把 tRPC 当成万能胶水。跨服务调用、消息队列、定时任务这些场景绕过 tRPC 直接调用内部函数或 Prisma 没任何问题,硬要在服务之间用 tRPC 反而会造成耦合。t3code 里的定时汇总任务就直接用 Prisma 操作,没有套 tRPC 壳子。

7.3 项目落地之后的一点感想

t3code 这个项目从决策到上线前后用了三周左右,最直观的变化是团队协作模式变了。以前每次后端接口有变更,群里至少要艾特一圈人,现在改完 Prisma schema,TypeScript 类型会立刻同步,前端编译阶段就能看到哪些地方需要调整。文档不用维护了,因为类型定义本身就是文档。

我个人在实际操作中的体会是:T3 Stack 最大的价值不是某一个框架或工具,而是它把类型安全种进了开发习惯里。你在写 router 的 input 时就顺手定义了校验规则,在写 Prisma schema 时就决定了前端能拿到什么字段,这些决定都发生在写业务逻辑之前,而不是之后去补。如果你准备接一个前后端同仓的全栈项目,t3code 这种链路值得认真试一次。

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

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

立即咨询