ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

t3code实践:基于T3 Stack的TypeScript全栈类型安全工程化指南

t3code实践:基于T3 Stack的TypeScript全栈类型安全工程化指南 t3code 这个词最近在技术群里出现的频率越来越高。我第一次看到也是一愣这到底是个项目名、库名还是某种框架的代号后来顺着社区里的讨论翻了一圈才大概弄明白它基本可以理解为一套 TypeScript 全栈工程化实践的代称而它的核心就是那套被反复讨论过的 T3 Stack 组合——Next.js TypeScript tRPC Prisma Tailwind。再往下说t3code 想解决的事情其实很朴素用一种语言、一份类型定义把浏览器端、服务端、数据库这条完整链路串起来写代码让前后端之间的“接口”不再靠文档和自觉维持。这套东西能干什么最直观的好处是前端调用后端逻辑时不再需要对着一个随意返回的 JSON 手写 interface后端一旦改了字段结构前端在编译期就会立刻报错。同时它把传统 REST/GraphQL 常见的接口文档、双端类型同步、联调成本砍掉一大截。适合谁想认真做一个全栈项目、又不想把大量时间消耗在类型对齐上的开发者尤其是刚接触 Next.js 工程化的朋友。下面的内容我不打算写成工具书式的罗列而是基于我实际动手搭 t3code 项目的经验把底层选型逻辑、关键实现细节和踩过的坑一并说清楚。1. 先弄明白 t3code 到底是什么再决定要不要入坑1.1 从 T3 Stack 到 t3code一个代号的背后是什么T3 Stack 这个名字最早来自社区的create-t3-app脚手架T3 的由来和开发者 Theo 的个人项目代号有关但社区更愿意把它解读为“TypeScript、Tailwind、tRPC”这三个以 T 开头的核心成员。后来大家越用越顺渐渐就把这套组合形成的一个完整工程实践简称 t3code。我个人的理解是t3code 并不单指某个开源仓库而是“用 TypeScript 统一前后端边界”的整套工程范式。很多新人刚看到这套组合时会觉得有点绕已经有 REST 和 GraphQL 了为什么还要一个 tRPC这里的关键在于tRPC 把“写接口”这件事变成“写函数”。你不需要定义 URL、不需要写 OpenAPI 文档、不需要手动维护类型后端写一个 procedure前端就能直接拿到推导完整的类型和方法名。这种体验很像在同一个代码库里调用本地函数只是内部帮你封装了网络传输和序列化。当然t3code 得以流行的前提是 Next.js 这类全栈框架把页面渲染和服务端逻辑拉到同一个进程里。没有 SSR 这类能力tRPC 依然只剩“半条腿”——它擅长服务端调用但浏览器侧的接口调用还需要额外封装。所以 t3code 实际的配方是Next.js 提供应用骨架TypeScript 做类型粘合剂tRPC 做远程调用Prisma 做数据模型Tailwind 处理 UI 样式。五个角色各管一段但共享一套类型系统。1.2 为什么是这五件套而不是其它方案先说我踩过的弯路。早年我做全栈项目时用过 Express REST 手写 types前端维护一份api.d.ts后端维护一份routes.ts两边稍微不一致跑起来就是各种undefined is not a function。后来换过 GraphQL类型安全确实好很多但为了一个小项目去学 schema、resolver、codegen启动成本不低而且很多团队根本用不上 GraphQL 那种按需聚合的能力。t3code 能成立本质上是因为它把“全栈类型安全”这件事收敛到了一条清晰的链路Prisma Schema 定义数据库模型prisma generate生成 TypeScript 类型tRPC procedure 用 zod 做输入校验自动推导输入输出类型前端 hooks 再把这两者的类型直接暴露给组件。整条链中数据库模型是源头zod 是运行时闸门TypeScript 是编译期防线。用生活化类比来说传统前后端联调像“两个国家之间的边境通关”JSON 是货物两边各自要对照申报单填一堆字段t3code 则更像同一家公司里的两个部门直接打电话所有信息格式从公司人事档案系统里自动同步不用手动互发模板。这个比喻有点粗糙但核心意思是对的减少约定减少对齐成本。Tailwind 在这套体系里看起来和“类型安全”无关但它解决了另一个痛点传统 CSS 的类名作用域和样式覆盖问题。Tailwind 的原子类思路让样式直接写在组件里配合 TypeScript 的模板字符串类型可以在编译期发现很多无效类名写法。从工程效率上说这类“约束式”工具和 t3code 的理念非常匹配。1.3 边界感什么项目不适合套 t3code我不是来劝所有人都把项目改成 t3code 的。它也有很清晰的适用边界。如果你要做一个面向第三方开放 API 的公共服务那 REST OpenAPI 或 GraphQL 还是更标准的方案因为外部调用方需要完善的契约文档而不是“自己连进来调用函数”。tRPC 的客户端是 TypeScript 强绑定的非 TS 生态的调用方支持起来比较别扭。另外如果团队主要技术栈是 Java 或 Go只是某个小前端模块需要数据t3code 的全栈一体化反而会成为集成负担。这种场景更适合保持后端语言独立用 OpenAPI 生成前端类型。还有一类是纯展示类官网不涉及复杂业务逻辑和数据库交互套 t3code 属于杀鸡用牛刀甚至 Next.js 静态导出就够了。我自己的判断标准很简单只要这个项目是“前端 服务端 数据库”三条腿都齐的内部应用或中小体量产品t3code 就能发挥很好的作用如果它根本不涉及数据库或者要开放给大量外部系统那就别硬套。工具这东西顺手才是第一位。2. 全栈类型安全是怎么落地的tRPC、Prisma、Zod 三条链路拆解2.1 tRPC 路由结构与 Zod 校验的配合tRPC 的核心概念有三个router、procedure和context。router就像一个命名空间用来组织一组 procedureprocedure 是真正可被客户端调用的函数它支持query查和mutation写context 是每次请求都会伴随的公共数据比如用户身份、数据库连接实例。我在项目里最常用的一段根路由代码如下import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.create(); export const appRouter t.router({ getTodo: t.procedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) { return ctx.prisma.todo.findUnique({ where: { id: input.id } }); }), createTodo: t.procedure .input( z.object({ title: z.string().min(1).max(100), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title, tags: input.tags ? { connect: input.tags.map((id) ({ id })) } : undefined, }, }); }), }); export type AppRouter typeof appRouter;这里有几个细节值得展开。第一.input()如果你不写 zod schema客户端的参数类型就会是unknown我见过不少人掉在这个坑里服务端跑得好好的前端完全没有提示。第二query和mutation之后返回的值会被自动推导到客户端 hooks所以你在写返回逻辑时尽量不要用any否则类型安全会断裂。第三context一般放在初始化 tRPC 时注入比如把prisma和session挂上去方便每个 procedure 直接使用。很多人会问zod 到底是干嘛的它在这里承担双重角色运行时校验输入数据防止脏数据进入业务逻辑同时把校验规则变成静态类型供 TypeScript 编译器使用。这比单纯依赖 TypeScript 的编译期类型要稳得多因为编译期不会拦你在接口里塞字符串。2.2 Prisma Schema 设计时容易忽略的细节Prisma 在整个 t3code 链路里是“源头”数据库模型直接决定 ORM 能生成什么类型。我见过不少项目写 schema 时很随意后来前端查询时一直拿不到关系字段问题就出在模型关系没定义好。举个简单例子待办清单和标签是多对多关系model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt tags Tag[] relation(TodoTags) } model Tag { id String id default(cuid()) name String unique todos Todo[] relation(TodoTags) }这里有三个容易忽略的点。第一default(cuid())和default(uuid())要按场景选cuid 适合公开场合不容易被遍历猜测uuid 更通用但在冷读场景下索引稍大。第二多对多隐式关系表面上看只需要两边互相写数组字段但如果你后续想给关联本身加属性比如给“TodoTag”记录加上“创建时间”就必须改成显式中间表。否则后面想加字段就要做一次不小的迁移。第三updatedAt这个字段 Prisma 会在更新时自动维护不需要你手动赋值但如果你用原生 SQL 直接 update 表它是不会自动变的这点在排查数据问题时可能会绕一下。另外prisma generate生成的类型在node_modules/.prisma/client下每次改完 schema 并迁移后如果发现前端类型没反应我基本都会重启一遍 dev server。编辑器里的 TS Server 偶尔会缓存旧的类型定义这属于日常高频问题。2.3 Tailwind 与组件库样式层怎么选型不后悔Tailwind 看上去是“样式层”的小事但在 t3code 工程里选型选错了后期会非常痛苦。我的建议是新项目先纯 Tailwind 起步不加任何重型 CSS 框架等 UI 组件重复度上来了再考虑shadcn/ui这类基于 Radix Tailwind 的 headless 组件库。为什么因为 Tailwind 的 JIT 编译模式只生成你实际用到的类这保证了样式包体积可控。而如果你一开始就引入一套完整组件库很多组件和样式你根本用不到还会带来样式覆盖优先级的问题。Tailwind 的设计哲学是“约束大于自由”它帮你减少类名命名的决策负担这刚好和 t3code 想减少工程决策负担的理念一致。实操方面我建议把主题色、间距、圆角这类 token 定义在tailwind.config.ts的theme.extend里而不是直接在业务组件里写死色值。这样后面需要换主题时只需要改配置不用全局搜索替换。另外不要在className里做字符串拼接来动态生成类名比如w-${size}Tailwind 的 JIT 编译器无法扫描到这种动态类名样式会莫名丢失。正确做法是写全类名或者用safelist。3. 手把手搭一个 t3code 项目初始化、业务路由到部署3.1 create-t3-app 初始化时每个选项该如何选现在初始化 t3code 项目最省事的途径还是官方脚手架。我习惯用 pnpm创建命令pnpm dlx create-t3-applatest t3code-demo交互过程会问你想用 App Router 还是 Vite以及其他一堆功能模块。先说我的推荐选择App Router 勾选TypeScript 勾选Tailwind 勾选tRPC 勾选Prisma 勾选ESLint 勾选NextAuth 看项目是否需要登录不需要就别强行勾它会引入额外的 session 配置复杂度。App Router 和 Pages Router 的差异很多人刚开始不敏感但实际写起来影响很大。App Router 引入了 React Server ComponentsRSC页面的数据获取可以直接在服务端完成配合 tRPC 的serverClient调用方式非常高效。当然RSC 有学习曲线比如“use client” 和 “use server” 的边界刚开始确实容易绕晕所以我建议新项目直接适应它而不是继续抱着 Pages Router因为生态里越来越多的库已经在朝 App Router 对齐了。初始化完成后建议先跑一遍pnpm prisma migrate dev --name init pnpm prisma generate pnpm dev确认页面正常打开后再开始加业务代码。很多人喜欢一上来就堆代码结果环境变量、数据库连接都没准备调试起来特别痛苦。3.2 一个能跑的示例带标签的待办清单接下来我用一个具体例子走一遍流程。项目就叫 t3code-demo核心功能是创建待办、给待办打标签、展示列表。先定义数据模型对接数据库。假设你已经配置好 PostgreSQL 的DATABASE_URL写好 Schema 后执行迁移命令。seed 脚本我通常放在prisma/seed.ts用tsx运行import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { await prisma.tag.createMany({ data: [{ name: 工作 }, { name: 生活 }], skipDuplicates: true, }); } main() .catch((e) { console.error(e); process.exit(1); }) .finally(async () { await prisma.$disconnect(); });别忘了在package.json里配置prisma: { seed: tsx prisma/seed.ts }然后业务 router 可以加一个 list 查询和 create mutationimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ list: publicProcedure .input(z.object({ tagId: z.string().optional() }).optional()) .query(async ({ ctx }) { return ctx.prisma.todo.findMany({ include: { tags: true }, orderBy: { createdAt: desc }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title, tags: { connect: input.tagIds.map((id) ({ id })), }, }, include: { tags: true }, }); }), });在前端页面里调用时注意 App Router 的写法。对于客户端组件先要用createTRPCReact生成 hooksimport { createTRPCReact } from trpc/react-query; import type { AppRouter } from ~/server/api/root; export const trpc createTRPCReactAppRouter();然后在_app.tsx或 layout 的 Provider 里包装。组件里这样用use client; import { trpc } from ~/trpc/react; export function TodoList() { const { data: todos, isLoading } trpc.todo.list.useQuery(); const utils trpc.useUtils(); const createTodo trpc.todo.create.useMutation({ onSuccess: () utils.todo.list.invalidate(), }); if (isLoading) return div加载中.../div; return ( ul {todos?.map((todo) ( li key{todo.id}{todo.title}/li ))} /ul ); }这段代码里invalidate是重点mutation 成功后让 list 重新拉取保证页面数据新鲜。实际项目里有人会手动setData更新但在大多数中小场景里invalidate 是最省心且不容易出错的方案。3.3 部署前必须处理的三个环节很多项目开发期跑得好好的一到生产环境就各种莫名问题。根据我的经验t3code 项目部署前一定要处理这三件事。第一DATABASE_URL要区分环境且生产环境最好单独配置连接池相关的参数比如 PostgreSQL 需要pgbouncertrue的场合。不要直接在代码里写死任意库的地址也别把配置提交到仓库。Prisma Client 支持在连接串中带sslmoderequire远程数据库一般都要加上。第二迁移的时机。开发环境你可以用prisma migrate dev它会自动重置数据库、应用迁移并重新生成客户端但生产环境绝对不能这么干。生产环境要用prisma migrate deploy它只会应用还没有执行过的迁移文件不会触发重置或 seed。常见的 CI 步骤是prisma generate-prisma migrate deploy-next build-next start。第三Next.js 的部署产物。在next.config.js里设置output: standalone这样构建产物里只包含运行所需的最小文件在容器平台部署时镜像体积会小很多。同时注意 Node 版本要和本地一致最好在package.json的engines字段里锁一个避免不同环境下 Node 行为差异。4. 实战中的高频报错与排查经验4.1 类型推断失灵先按这个顺序查用 t3code 最爽的是类型安全但类型失灵时也是最让人头疼的。我总结了一个排查顺序基本能覆盖 90% 的情况。首先看客户端 hooks 有没有正确拿到AppRouter类型。如果你在createTRPCReactAppRouter()里传错了泛型或者appRouter没有绑定到createNextApiHandler前端就会出现“无法找到属性 xxx”的错误。特别是复制粘贴模板代码时很容易漏掉root.ts的 export。其次检查 procedure 的.input()是否写了 zod。如果没写客户端的入参类型就是unknown你会在useMutation里看到一个没有类型的变量这不仅失去提示还会导致运行期数据校验完全缺失。最后如果 Prisma 模型的字段类型和你预期不一致多半是prisma generate之后没有重新加载 TypeScript Server 的诊断信息。我会先看一下生成的.prisma/client文件是否包含最新的模型再重启一次编辑器。很多人不知道tsc --noEmit可以快速暴露这一类编译问题建议有报错时先跑一遍。现象根因解决方案客户端方法完全没提示AppRouter 泛型没接好检查createTRPCReact的泛型定义入参类型是 unknown缺少 zod input 校验为 procedure 补.input(z.object({ ... }))关系字段报类型错误查询没include或select在 route 内正确声明 include字段类型和 Schema 不一致未重新生成 Prisma Client执行prisma generate并重启 dev4.2 页面水合不一致与 tRPC 请求重复Next.js 的 SSR 在服务端生成 HTML然后在客户端做 hydration水合。如果两边渲染的结果不一致就会报水合错误。最常见的来源是组件里直接读取localStorage、sessionStorage或者用new Date()渲染时间。服务端看到的日期和客户端不同HTML 结构就对不上。解决方式相对固定把依赖浏览器环境的逻辑放进useEffect里或者用 Next.js 的动态导入关闭 SSR例如dynamic(SomeComponent, { ssr: false })。我用纯前端状态展示用户登录信息时通常先渲染一个骨架等到 useEffect 里拿到浏览器数据再更新这样水合永远一致。再说 tRPC 的请求重复问题。默认情况下如果客户端组件里的useQuery同时又在服务端预取数据很容易出现“同一接口打两次”的现象。App Router 里的原则是RSC 直接调 serverClient 取数据不要再用客户端useQuery重复拉客户端交互较多、实时性要求高的组件则把ssr: false配合客户端 hooks 使用。这样分层既避免多余请求又能保持数据流清晰。4.3 服务端组件和 tRPC 的配合误区App Router 的 RSC 不能直接使用useQuery这类 hooks这是很多初学者的第一个大坑。正确的做法是使用 tRPC 的 server caller。在~/trpc/server.ts里创建一个serverClientimport { createCallerFactory, createTRPCContext } from ~/server/api/trpc; import { appRouter } from ~/server/api/root; const createCaller createCallerFactory(appRouter); export const serverClient createCaller(createTRPCContext);然后在 RSC 页面里直接调用export default async function Page() { const todos await serverClient.todo.list(); return TodoList initialTodos{todos} /; }注意这个组件本身不需要 “use client”它默认就是服务端组件可以被异步函数直接调用。把拿到的数据作为 props 传给客户端组件再由客户端组件接管交互逻辑。这个模式把“数据获取”和“用户交互”分得很清楚RSC 负责读客户端组件负责写和状态更新。另一个误区是“把所有页面都标记成 use client”。很多人觉得 tRPC hooks 用起来方便就直接把整个页面组件标成客户端组件结果服务端渲染的优势全没了首屏 JS 体积也变大。实际上大部分数据获取都可以先通过 serverClient 在服务端完成只有真正需要事件处理和实时状态的部分才需要客户端化。4.4 几组容易被忽略的配置小坑最后列几个我踩过的小配置问题都属于“百度半天找不到其实就一行配置”的范畴。第一个是 zod 版本升级带来的类型推断差异。tRPC 官方对 zod 版本有兼容性要求如果你在项目里强行升级到不兼容的大版本可能会出现input类型无法推断的诡异现象。遇到这种问题先对齐一下依赖版本tRPC 文档里有明确的 peerDependencies 表。第二个是 Prisma 在连接远程数据库时的 SSL 配置。本地开发可能不需要但云端 PostgreSQL 基本都要带?sslmoderequire。少了这个参数prisma migrate deploy会报连接被拒。第三个是 Tailwind 的content配置。如果你在tailwind.config.ts的content数组里没有包含实际用到类名的文件路径部分样式会莫名不生效。特别是新增了某个模块却没把它加进扫描范围排查起来相当闹心。我现在的习惯是内容路径写得宽一点哪怕是./src/**/*.{ts,tsx}这样也不会明显拖慢构建。还有一个不算配置但很影响体验的问题不要图省事在server/api/routers底下把所有代码写到同一个文件里。刚开始项目小确实没问题但随着业务增长这个文件会膨胀得非常快。按领域拆分 router再在 root router 里聚合才是 t3code 工程能长期维护的关键。个人实践里的一个体会是t3code 这套组合最大的价值不是“少写代码”而是“减少猜测”。以前前后端联调我经常对着接口文档猜某个字段是不是可选现在所有数据结构在编译器和运行时都被卡得死死的我可以把精力放到业务本身。最后再分享一个小习惯每改一次 Prisma Schema 或者 router 的输入输出先跑一遍tsc --noEmit再启动开发服务器。这点额外花的时间基本能在后续调试里几倍地赚回来。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表