首页 / 资讯中心 / 文章详情

t3code全栈开发实战:用TypeScript穿透前后端边界

t3code全栈开发实战:用TypeScript穿透前后端边界 ★ FEATURED ARTICLE
1. 从一个反直觉的体验说起为什么我放弃了“前后端分离”的标配几个月前接了个内部工具的项目需求不复杂就是给运营团队做个数据录入和查询的后台。按我过去几年的习惯这种项目肯定是 React Ant Design 一套前端Node.js Express 或者 Python FastAPI 一套后端再花半天时间对接口文档、处理跨域、封装 axios 请求。结果这次我用了一套叫 t3code 的组合拳整个项目从立项到上线只用了三天而且前端代码量少得让我自己都有点不适应。t3code 这个说法最早是我在技术社区里看到有人讨论的热词一开始我以为是什么新的编码规范或者工具链点进去才发现它其实指代的是一种技术选型的组合模式——以 TypeScript 为底座把 Next.js、Tailwind CSS、tRPC、TanStack Query 这些工具串成一条完整的前后端开发流水线。它解决的核心问题是当一个全栈项目业务逻辑并不复杂时我们真的有必要维护两份类型定义、写一堆重复的请求封装、再为接口文档的同步操碎心吗这篇文章我就拿那个运营后台当例子把 t3code 从选型逻辑、目录设计到实际开发中遇到的坑完整拆开讲一遍。适合谁看如果你受够了“前端写 interface、后端写 model两边经常对不上”的糟心事如果你正在做一个以 CRUD 为主的内部系统或中小型应用又不想上一堆重型微服务框架这篇文章应该能给你一些非常落地的参考。先说结论t3code 的核心思路不是“不用后端”而是把“前后端之间的那层胶水”尽量压缩到零。它用 tRPC 替代 REST API用 Next.js 的 API 路由承载服务端逻辑让前端代码可以像调用本地函数一样直接调用后端方法而类型安全在这个过程中是自动保证的。听起来有点玄乎但它背后的原理其实不复杂下面我从选型逻辑开始拆。2. 为什么是“T3”而不是“MERN”或“JAMstack”选型逻辑拆解2.1 全栈项目的痛点类型不同步和接口文档焦虑先说个我自己的真实体会。以前用 MERN 那套MongoDB Express React Node做项目最折磨人的不是写业务逻辑而是维护前后端之间的接口契约。前端要等后端把接口写完才知道返回结构长什么样后端改了字段名忘了通知前端结果页面渲染出一片空白查半天发现是userName变成了user_name。这种问题在传统 REST 架构里几乎是无法根治的因为前端和后端是两个独立的类型系统唯一能对齐的方式就是靠人沟通、靠文档约束、靠联调阶段反复修修补补。而 t3code 这套组合天然就是为解决这个问题设计的——它的核心是让 TypeScript 的类型定义穿透前后端边界前端拿到的数据类型就是后端定义的数据类型没有二次翻译的过程。2.2 T3 Stack 到底是什么t3code 和它什么关系你可能会问t3code 和网上常说的 T3 StackThe T3 Stack是不是一回事确实有关系。T3 Stack 最早是 tRPC 作者 Theo 提出的一个概念推荐的组合是 Next.js TypeScript tRPC Prisma Tailwind CSS NextAuth.js。t3code 可以理解为在这个思路上的一种工程化实践变体它在保留 tRPC 核心优势的前提下更强调“用代码即契约”的开发方式——你不再需要单独维护一份 OpenAPI 或 Swagger 文档后端路由的方法签名本身就是前端调用时的类型依据。我个人的理解是t3code 不是某个官方框架它更像是一种编码模式和工具链组合的代称核心精神有三条类型安全贯穿全栈从数据库查询到 UI 组件的数据消费TypeScript 类型不丢失开发效率优先减少样板代码减少前后端联调的沟通成本以 Web 标准为基础构建在 Next.js 和 React 生态之上而不是另起炉灶。2.3 什么时候适合用 t3code什么时候别硬上不是说所有项目都适合这套组合。我自己总结的适配条件有三个项目特征是否适合 t3code说明内部工具、管理后台、中小型应用非常适合业务以 CRUD 为主类型安全收益最大前后端团队完全分离需要独立部署不太适合如果后端要单独扩容或多端共用传统 REST 或 GraphQL 更合适有大量第三方客户端接入不太适合提供给外部开发者的 API 走 REST 更通用高度实时、复杂状态同步的应用部分适合配合 WebSocket 需要额外处理TanStack Query 的模式不一定最优所以如果你打算用 t3code先别急着搭环境想清楚你的项目是“一个人或一个小队同时写前后端”还是“大团队前后端分离协作”。前者用 t3code 会非常舒服后者可能还是走传统分层架构更稳。3. 从零搭一个 t3code 项目基于 create-t3-app 的完整实操3.1 环境准备和初始化命令这部分实际操作没有太多悬念官方脚手架 create-t3-app 已经把大部分工作都做好了。我当时的操作环境是 Node.js 20 LTS包管理器用的 pnpm因为多包场景下 pnpm 的依赖隔离做得更干净安装速度也明显比 npm 快。# 创建项目项目名自取 pnpm create t3-applatest my-admin # 按照交互提示选择需要开启的模块 # 我这次勾选了Next.js、TypeScript、tRPC、Prisma、Tailwind CSS # NextAuth.js 这次没用因为内部系统走的是公司 SSO后面自己接脚手架跑完之后你会得到一个结构非常清晰的项目模板。这里我强烈建议不要跳过阅读生成代码的环节尤其是prisma/schema.prisma、src/server/api/和src/trpc/这几个目录它们正是 t3code 模式的精华所在。3.2 目录结构的核心逻辑server 与 app 的分界第一次看 T3 项目的目录很多人会懵因为它的组织方式跟传统的“前端目录 后端目录”完全不一样。拿我这次的项目为例关键结构是这样src/ ├── app/ # Next.js App Router 页面与路由 │ ├── api/ # Next.js API 路由tRPC 的 HTTP 出口 │ ├── _components/ # 前端 UI 组件 │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── routers/ # 业务路由聚合 │ │ └── root.ts # tRPC 根路由 │ ├── db.ts # Prisma Client 实例 │ └── auth.ts ├── trpc/ │ ├── server.ts # 服务端 tRPC 初始化上下文、中间件 │ ├── client.ts # 前端 tRPC 客户端 │ └── react.tsx # React Hooks 封装 └── styles/这个结构最大的特点是后端代码和前端代码在同一个代码库里但通过server/和trpc/做了清晰的逻辑边界。你不需要在两个仓库之间切换类型可以直接通过 import 引用这就是“代码即契约”的现实落地。3.3 Prisma 数据建模先定义 schema再生成类型数据层我用的 Prisma连接的是项目已有的 PostgreSQL 实例。这一步的关键在于schema.prisma里的 model 定义会自动生成对应的 TypeScript 类型这些类型会被 tRPC 直接使用最终一路穿透到前端组件。所以一旦 model 字段有调整前端所有引用到该类型的地方都会在编译期报错——这比任何文档都靠谱。拿运营后台的核心表举例model Article { id String id default(cuid()) title String content String db.Text status Status default(DRAFT) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt } enum Status { DRAFT PUBLISHED ARCHIVED }定义好之后执行pnpm prisma migrate devPrisma Client 的类型就自动生成了接下来就是写 tRPC 路由。3.4 写第一个 tRPC 路由从数据库查询到前端渲染一气呵成tRPC 的写法非常直观。以文章列表为例后端路由大概是这样的// src/server/api/routers/article.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const articleRouter createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().default(1), pageSize: z.number().default(20), }) ) .query(async ({ ctx }) { const articles await ctx.db.article.findMany({ skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: desc }, }); return articles; }), });注意看这里的input用 zod 做了运行时校验返回值的类型由 Prisma 自动推断。前端调用时就非常简单了use client; import { api } from ~/trpc/react; export function ArticleList() { const { data, isLoading } api.article.list.useQuery({ page: 1, pageSize: 20, }); if (isLoading) return div加载中.../div; return ( ul {data?.map((article) ( li key{article.id}{article.title}/li ))} /ul ); }第一次跑通这个完整链路时我的感受是好像没写什么东西功能就出来了。没有手写 fetch、没有 axios 实例、没有接口地址常量、没有类型断言一切都顺理成章。这就是 t3code 模式的日常开发状态——你的注意力完全放在业务本身而不是技术胶水。4. 前后端类型穿透的秘密tRPC 的工作原理与中间件设计4.1 tRPC 到底是什么它和 REST 的本质区别这个点值得单独拉出来讲透。REST 的核心思想是“资源 HTTP 动词”客户端通过 URL 和 Method 表达意图服务端通过状态码和响应体返回结果。这种方式在跨系统集成时非常稳定但代价是消息格式需要双方约定类型检查只能靠运行时开发期的重构非常痛苦。而 tRPC 换了一种思路——它不追求通用性只追求 TS 全栈项目内的“零摩擦”。打个比方REST 是两个人各拿着一本翻译词典互相对话tRPC 是两个人直接用同一种母语聊天谁也不用翻译。tRPC 利用 TypeScript 的类型推断能力把服务端 router 的输入输出类型直接映射到前端 client 的方法签名上而运行时只通过一个简单的 JSON 协议默认走 HTTP POST传输数据。4.2 订阅、变更和查询tRPC 的方法类型如何适配 React 状态需求在 t3code 实践中我几乎只会用到三种 procedurequery读数据对应 TanStack Query 的useQuerymutation写数据对应useMutationsubscription实时推送对应useSubscription这次项目没用到。这里建议一个习惯查询和变更尽量分开写变更函数的返回值不要依赖查出来的数据而是变更完成后触发一次失效重取。这样能最大限度减少缓存不一致的问题。TanStack Query 在 T3 模板里已经被默认集成了它的作用就是管理服务端状态缓存。比如文章发布之后const utils api.useUtils(); const publishMutation api.article.publish.useMutation({ onSuccess: () { // 让列表查询自动重新拉取 utils.article.list.invalidate(); }, });这种模式的体验非常顺滑写起来不可感知但效果上相当于把“手动更新前端缓存”这件事从开发清单里彻底删掉了。4.3 中间件和上下文如何优雅地做鉴权和日志tRPC 的中间件机制可以类比 Express 中间件但类型约束更强。其实做内部系统时我写了一个简单的鉴权中间件核心逻辑是从请求头里解析 SSO Token校验后把用户信息塞进ctx。// src/trpc/server.ts import { initTRPC, TRPCError } from trpc/server; export const t initTRPC.contextContext().create(); const isAuthed t.middleware(({ ctx, next }) { if (!ctx.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { user: ctx.user } }); }); export const protectedProcedure t.procedure.use(isAuthed);之后业务路由只要用protectedProcedure替换publicProcedure整个路由就自动具备鉴权能力。相比传统后端里每个接口都要写一遍鉴权逻辑这种方式省下来的代码量非常可观。4.4 一个需要留意的细节Context 的类型设计tRPC 的 Context 是每次请求都会重新创建的常见做法是拿 Node 的IncomingMessage里解析出必要信息。但有个容易踩的坑如果你在 Context 里放了数据库连接或 Session 实例一定要保证它是可序列化的、不会因为并发请求导致资源竞争。我当时在 Context 里放了 Prisma Client查了一下发现 Prisma 官方是推荐这样做的因为它是连接池管理的没有资源泄漏问题。如果你放的是自己管理的数据库连接就要小心连接数上限被打满。5. 数据流通了UI 层怎么处理效率最高Tailwind 与组件化实践5.1 为什么选择 Tailwind CSS而不是传统 CSS 方案T3 模板默认集成 Tailwind CSS用下来确实顺手。对于一个没有专职设计师的团队来说Tailwind 最大的价值是样式直接写在类名里不需要来回切换 CSS 文件而且原子类名的组合方式天然逼着你做组件化拆分。我承认一开始对 Tailwind 是有点抗拒的觉得 class 名太长、可读性差。但用了一个星期之后看法完全变了——在 t3code 的工作流里Tailwind 的原子类名和组件化的 TSX 结构一起构成了一个非常稳定的“视觉开发语言”。你不需要为按钮写什么.btn-primary的样式类因为bg-blue-500 hover:bg-blue-600就足够表达意图了。5.2 常见的布局组件示例一个多条件筛选表单运营后台最常见的场景就是条件筛选 表格展示 分页。我用 Tailwind 写了一个筛选栏完整代码大概长这样div classNameflex flex-wrap gap-3 rounded-lg border border-gray-200 p-4 input typetext placeholder搜索关键词 classNameh-10 rounded-md border border-gray-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 / select classNameh-10 rounded-md border border-gray-300 px-3 text-sm option全部状态/option option已发布/option option草稿/option option已归档/option /select button classNameh-10 rounded-md bg-blue-600 px-4 text-sm font-medium text-white hover:bg-blue-700 查询 /button /div这种写法配合 TanStack Query 的筛选参数体验上是无缝的。你需要做的只是把表单状态提上来作为useQuery的 input 参数传入即可。5.3 组件划分的几个建议t3code 项目里我个人推荐按“数据边界”而不是“视觉边界”来拆分组件。什么意思一个页面里凡是需要api.xxx.useQuery的组件都尽量保持独立并且让数据获取尽量发生在页面顶层子组件只负责展示和回调。这样做的原因是TanStack Query 的数据缓存是全局共享的如果子组件各自拉一份数据很容易出现同一个板块显示的数据和时间戳不一致排查起来非常头疼。6. 从开发到部署几个必须要处理的工程化问题6.1 环境变量管理不只是数据库连接串T3 项目的环境变量有模板化的.env.example文件这个我强烈建议所有团队都保留。实际项目中除了DATABASE_URL我还会配置NEXTAUTH_SECRET或自定义的SSO_SECRET根据实际鉴权方案NODE_ENV和NEXT_PUBLIC_APP_ENV第三方服务 API Key比如对象存储的 AccessKey经验是所有非公开的变量一律只放在服务端代码里读取只有以NEXT_PUBLIC_开头的变量才会暴露给浏览器端这是 Next.js 的铁律一定不要玩花样。6.2 部署策略一份 Dockerfile 就够了t3code 项目最大的部署优势就是它本质上是一个 Next.js 应用所以你只需要构建并运行一个 Node 服务即可。我的 Dockerfile 大致思路FROM node:20-alpine AS base RUN corepack enable FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN pnpm prisma generate RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENVproduction COPY --frombuilder /app/.next/standalone ./ COPY --frombuilder /app/.next/static ./.next/static COPY --frombuilder /app/public ./public EXPOSE 3000 CMD [node, server.js]这里依赖了 Next.js 的 standalone 输出模式它会把运行所需的最小文件集打包出来镜像体积能比传统方式小很多。这个优化对于小团队自己维护服务器来说非常实用。6.3 构建时容易忽略的 Prisma 步骤我在第一次 CI 部署时因为少了prisma generate这个步骤构建直接报了一堆类型错误。原因是 tRPC 的类型是从 Prisma Client 的类型里推导的而 Prisma Client 的生成代码默认被.gitignore排除所以必须在构建流程里显式生成一次。这里顺带提一句prisma generate会读取schema.prisma中的generator client配置命令本身很快但在 CI 里最好固定在依赖安装之后、类型检查之前执行避免顺序不对导致奇怪的缓存问题。7. 我踩过的三个坑给准备上手的人提个醒7.1 坑一在 Server Component 里直接调用 tRPC clientApp Router 下服务端组件和客户端组件是有边界的。我第一次写页面时图省事在服务端组件里直接import { api } from ~/trpc/react然后调用useQuery结果编译直接报错——因为api的 React 绑定依赖 hooks只能在客户端组件里用。后来我改成了在服务端组件里调用 tRPC 的 server-side proxy或者干脆把需要数据获取的组件标注为use client。这个问题说穿了不复杂但第一次撞上的时候确实容易懵。7.2 坑二zod schema 和 Prisma 枚举类型不一致导致的隐性问题zod 校验的 input 类型和 Prisma 的 enum 类型有时候会不一致尤其是枚举值里有下划线或连字符的情况。比如我定义了一个OrderStatus枚举里面有IN_PROGRESSzod schema 里如果写成了小写开头的in_progress前端不会报错但调用 mutation 时会被 tRPC 的校验拦住。这种错误提示一般比较清晰定位起来不难但最好从一开始就用枚举值常量来统一维护避免字符串散落。7.3 坑三useMutation 里直接操作返回数据结果缓存不一致TanStack Query 的缓存机制确实强大但它不是魔法。我之前写“创建文章”的 mutation 时在onSuccess里直接utils.article.list.setData手动塞了一条假数据进去过了几秒发现页面显示的内容和数据库里实际的不一致——因为列表排序、分页参数、字段过滤都可能影响数据形态。后来我改成invalidate强制重取虽然多了一次请求但永远不会有脏数据。这个取舍值得记住手动更新缓存只适合确定性的、简单集合的场景其他情况一律用失效重取。8. 一次真实的排查过程t3code 项目里数据库连接池被打满这个坑写在最后是因为它的排查链路非常典型值得记录完整过程。上线第二天运营反馈系统偶尔会卡住报错信息是PrismaClientInitializationError提示连接池达到最大连接数。我第一反应是 Prisma 的连接数配置太小于是去schema.prisma里把connection_limit调大从默认的 5 改成 10。但改完之后问题依旧而且报错频率反而更高了。后来逐步排查定位到了两个问题第一个是 Next.js 开发环境下热更新会反复初始化 Prisma Client每次初始化可能都尝试创建新的连接。这个在开发环境无所谓但如果在生产环境用next dev跑很多人图省事这么干问题就会放大。第二个问题才是真正的根因——我在 tRPC 的 Context 里没有做连接复用每次请求都直接new PrismaClient()。这等于把连接池的池化能力完全丢掉了。正确的做法是用模块级单例// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: [query, error] }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个写法在 Next.js 官方文档里也有推荐核心是为了在开发模式的热更新过程中避免反复实例化占用数据库连接。把 Context 改成引用这个单例之后连接池打满的问题再也没出现过。排查整个过程花了我大约两个小时最耗时的部分不是代码本身而是把“连接数不足”这个表面症状背后的调用链彻底捋清楚。如果你自己也用 t3code 组合我建议一开始就把连接池的监控接入进来而不是等报错再查。这个过程中的另一个收获是tRPC Context 里的“昂贵对象”一定要保持单例不只是数据库客户端Redis 连接、外部 SDK 客户端同理。把这些经验沉淀下来后续做别的全栈项目时也能少走很多弯路。
阅读完成 · 觉得有帮助?
咨询建站