ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

T3 Stack 全栈开发实战:Next.js + tRPC + Prisma 类型安全指南

T3 Stack 全栈开发实战:Next.js + tRPC + Prisma 类型安全指南 搞了几个月 T3 Stack 全家桶从踩坑到填坑总算把一套能用、能上线、能维护的完整代码库跑通了。趁热把这套东西沉淀下来从技术选型逻辑到每个环节的实际操作一步不落写清楚给想用 tRPC、Prisma、Next.js 这套现代全栈方案但又怕上手门槛高的朋友一份可以直接照着干的参考。1. 项目概述与技术选型逻辑先说结论t3code 这个名字听起来唬人本质就是围绕 T3 Stack 搭起来的一套全栈应用样板。T3 Stack 不是某种框架而是由 TypeScript、Tailwind CSS、tRPC、Next.js、Prisma 五个核心成员组成的技术组合。这套组合的核心诉求只有一句话在享受 Next.js 生态的同时把类型安全从数据库一路打通到浏览器 UI减少前后端联调的心智负担。1.1 为什么是 T3 Stack 而不是传统 REST API很多人第一反应是我已经会用 Express MongoDB 或者 Spring Boot 写接口为什么还要折腾 tRPC这里涉及一个根本的工作方式差异。传统模式是后端定义接口文档前端按照文档里的路径和参数去请求、解析、处理错误。接口一旦增多联调成本指数级上升。前端需要维护一份请求工具封装、一堆 DTO 类型定义后端还得保证文档和代码不脱节。tRPC 直接把这个过程反过来——后端函数就是前端可调用的本地方法类型直接从后端函数签名推导到前端调用方。打个比方传统 REST 像你去银行柜台办事填单子、递窗口、等叫号单子格式是标准化的但每次都要重填tRPC 像你有这家银行的 VIP 专属经理一个电话过去对方就知道你是谁、要办什么业务、需要准备什么材料省掉了大量重复沟通。我实际体验最深的一点是前端同事改代码的时候如果调用了一个不存在的 tRPC 方法或者参数类型不匹配类型系统当场就会报错压根不需要等运行时才发现接口 404 或者参数对不上。这在团队协作中能省掉大量低级 Bug 排查时间。1.2 核心成员各司其职既然叫 T3 Stack五个成员缺一不可但每个成员的职责边界很清晰Next.js应用框架负责页面渲染、路由、Server Components、API 路由虽然大部分场景用不到、部署适配TypeScript全栈类型语言没有它tRPC 和 Prisma 的推导能力就是空中楼阁Tailwind CSS原子化 CSS 框架解决样式问题不需要在组件文件和样式文件之间反复横跳tRPC前后端之间的无缝胶水替代 REST 接口定义和请求封装Prisma数据库 ORM提供数据库表结构到 TypeScript 类型的自动映射。这五个工具像一个团队里的不同岗位——Next.js 是项目经理统筹全局TypeScript 是质检员保证代码类型不出格Tailwind 是装修工负责把页面变得好看起来tRPC 是传令兵把任务从一端高效带到另一端Prisma 是仓库管理员管好数据进出。还有一个容易被忽视的点T3 Stack 官方脚手架create-t3-app把一整坨配置全部初始化好了包括 tsconfig、tailwind.config、prisma schema、tRPC router 目录结构。新人不用从零开始搭建工程设施直接进入业务开发的状态这也是为什么它能在开发者社区里火起来。1.3 这套方案适合谁、不适合谁先说适合的人中小型全栈项目、内部工具平台、快速验证产品的 MVP 阶段团队成员已经熟练 TypeScript业务逻辑复杂但不需要高性能的独立 API 服务层希望减少前后端沟通成本的团队。再说不适合的已经有成熟的后端团队并且对外提供多客户端 API 的场景。如果同时服务 Web、iOS、Android 多个端tRPC 就只能服务 Web 端其他端还得单独走 REST 或 GraphQL。另外复杂的高并发场景下tRPC 的 HTTP 层性能表现算不上最优直接裸奔的 Node.js HTTP 框架才是更合适的选择。我自己踩坑后的体会是T3 Stack 不适合做面向第三方开发者开放 API 的平台更适合做前后端在同一团队、同仓库、同类型系统下的业务。2. 全栈类型安全链路的核心细节这一章聊聊整个系统里最核心的类型安全链路也就是数据从数据库出来经过 API 层、组件层最后渲染到页面全程类型无损传递。这是 T3 Stack 与生俱来的能力但很多人搭完脚手架并没有真正用好它。2.1 Prisma 的 Schema 是类型链路的源头一切类型安全的起点都是 Prisma 的 schema.prisma 文件。这张表结构定义不但决定了数据库里长什么样子还决定了整个应用里所有相关数据的 TypeScript 类型是什么。比如我本地项目里的一个简单示例model Product { id String id default(cuid()) name String price Decimal db.Decimal(10, 2) stock Int default(0) createdAt DateTime default(now()) updatedAt DateTime updatedAt }定义完之后执行npx prisma generatePrisma Client 会立刻生成对应的 TypeScript 类型。然后前端调用product.list()方法时取到的products数组里的每项就会自动带上id: string、name: string、price: Decimal、stock: number这些类型信息。这里有个关键细节Prisma 的 Decimal 类型不会直接在 JSON 传输中出现它经过 tRPC 序列化以后可能是字符串或者 number取决于你的序列化配置。如果前端直接用price.toFixed(2)这种 Decimal 类型专属方法类型上过不去。我在实际项目中就是统一在服务端包一层映射输出为普通 number。2.2 tRPC Router 的输入输出如何进行数据验证tRPC 的核心概念是 Router 和 Procedure。Router 是路由集合Procedure 是一个具体的可调用端点。每个 Procedure 可以定义输入校验规则用 zod和实际处理逻辑。一个典型的商品列表查询接口服务端长这样import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const productRouter createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().int().default(1), pageSize: z.number().int().max(50).default(10), keyword: z.string().optional(), }) ) .query(async ({ ctx, input }) { const products await ctx.db.product.findMany({ where: { name: input.keyword ? { contains: input.keyword } : undefined, }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }); return { items: products.map((p) ({ ...p, price: Number(p.price), })), total: await ctx.db.product.count(), }; }), });这段代码里可以看到几个很重要的实践第一输入校验用的是 zod 的object模式。page和pageSize明确指定了类型和范围前端传的pageSize如果超过 50tRPC 会在请求进入处理函数之前直接拒绝并返回校验错误省得你自己写 if 判断。第二ctx.db是全局注入的 Prisma Client 实例这个依赖注入的方式让每个 Procedure 都能优雅访问数据库不需要每次手动实例化。第三返回的数据里我用Number(p.price)做了 Decimal 的转换。如果不做这一步Decimal 对象序列化为 JSON 时会被转成字符串前端拿到price: 19.99而不是数字很多计算逻辑会埋雷。2.3 前端调用方的类型体验前端代码最关键的是完全不需要任何请求封装层直接用 React Hook 风格的调用方式import { api } from ~/trpc/react; export default function ProductList() { const { data, isLoading } api.product.list.useQuery({ page: 1, pageSize: 10, keyword: 手机, }); if (isLoading) return div加载中.../div; return ( div {data?.items.map((p) ( div key{p.id} span{p.name}/span span¥{p.price}/span /div ))} /div ); }这里api.product.list.useQuery里的input参数类型、data返回类型都是从服务端自动推导的。如果我后端改了返回结构把price改成priceText字符串前端不修改代码的情况下编译会立刻报错。这一点在实际协作时价值巨大。我记得有一次我不会提交后端代码同事把接口返回的数据里删了一个字段结果他 push 代码还没到 review 环节自己本地在前端编译时就发现类型错误了赶紧补了回去。传统 REST 开发里这种问题通常要等前端跑到页面上才发现或者要等联调阶段才能暴露。类型安全链路把这类 Bug 提前到了编译阶段开发效率的差别是实打实的。2.4 鉴权上下文如何影响类型提示T3 Stack 的鉴权模型也很有意思。默认create-t3-app生成的是基于 NextAuth 的会话体系并且会给你留下一个getServerAuthSession函数。服务端 Router 可以用protectedProcedure来声明需要登录才能访问的接口import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const orderRouter createTRPCRouter({ create: protectedProcedure .input(z.object({ productId: z.string() })) .mutation(async ({ ctx }) { const userId ctx.session.user.id; // 此时 userId 一定存在类型层面就不允许为 null // 业务逻辑... }), });这个流程里类型系统帮了大忙ctx.session.user.id在protectedProcedure内部被推导为非空字符串不用手动判空。如果错用了publicProcedure这里类型就可能变成string | null | undefined编辑器会直接发出警告。这种通不过类型检查就不允许碰数据的设计把权限问题在编译期就拦了一道。不过这里有一个需要警惕的实战细节NextAuth 的 Session 类型默认偏保守里面user是Session[user]有时候不会包含你自定义的字段。我建议在项目的types/next-auth.d.ts里做一次模块增强把id、role这些需要在前端展示的字段补上类型否则后面频繁打补丁会让你抓狂。3. 一个完整的功能模块实操拆解商品推荐系统理论讲再多不如实际跑一个功能。我挑一个比较常见的业务场景——根据用户浏览记录推送相关商品——来完整演示 T3 Stack 各个环节的配合。这个功能有数据读取、有鉴权、有输入输出校验、有前端状态管理基本覆盖了每天都要干的那点事儿。3.1 Prisma Schema 扩展与迁移先增加一张浏览记录表用来记录用户看过哪些商品model ViewHistory { id String id default(cuid()) userId String productId String viewedAt DateTime default(now()) user User relation(fields: [userId], references: [id]) product Product relation(fields: [productId], references: [id]) index([userId, viewedAt]) } model User { id String id default(cuid()) name String? views ViewHistory[] // 其他字段... }修改完后执行npx prisma migrate dev --name add_view_history这步会生成一个迁移文件并且在本地数据库执行 DDL。这里我强烈建议养成一个习惯别把 migration 文件当成一次性产物它们要跟着代码仓库走方便以后回滚和团队成员拉取同步。我自己见过不止一个刚学 Prisma 的同学把 migrations 文件夹加进了.gitignore结果换台电脑数据库表全部对不上。3.2 tRPC Procedure 实现推荐逻辑接下来在商品 Router 里增加一个推荐接口。我不打算搞复杂算法就基于同一类目下的浏览频率来推荐简单但足够说明问题import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const productRouter createTRPCRouter({ recommended: protectedProcedure .input(z.object({ limit: z.number().int().min(1).max(20).default(5) })) .query(async ({ ctx, input }) { // 查用户最近浏览过的商品 const recentViews await ctx.db.viewHistory.findMany({ where: { userId: ctx.session.user.id }, orderBy: { viewedAt: desc }, take: 10, include: { product: { include: { category: true } } }, }); if (!recentViews.length) { return []; } // 汇总最近浏览商品所属类目 const categoryIds Array.from( new Set(recentViews.map((v) v.product.categoryId).filter(Boolean)) ); // 查询同类目下其他商品排除已经看过的 const viewedProductIds recentViews.map((v) v.productId); const recommendations await ctx.db.product.findMany({ where: { categoryId: { in: categoryIds }, id: { notIn: viewedProductIds }, status: ACTIVE, }, orderBy: { sales: desc }, take: input.limit, }); return recommendations.map((p) ({ ...p, price: Number(p.price), })); }), });代码逻辑本身不复杂。但这里有几个生产环境必须考虑的问题第一个是缓存。推荐结果不需要秒级实时如果每次请求都查一遍数据库热点商品的推荐接口压力会很大。可以在 tRPC 外层套一层简单的服务端缓存用 Redis 或者直接用内存缓存单机部署场景。我自己用的方案是用unstable_cache包一层设置 60 秒过期性价比很高。第二个是数据裁剪。如果用户浏览记录非常多每次都取最近 10 条就够了不需要拉全表。上面代码里take: 10就是为了控制查询成本。类目 ID 去重用Set也是个很容易被忽略的小优化否则同类目重复出现在 where 条件里不但浪费性能还容易产生语义错误。第三个是兜底策略。新用户没有任何浏览历史时直接返回空数组会让页面这块区域显得很寒酸。我在生产环境里的处理是取不到历史记录时回退返回全站热销榜保证推荐位永远有东西展示。这块逻辑在 tRPC 服务端处理最合适前端就不用关心边界情况了。3.3 前端组件的消费与状态管理前端消费这块老实说 T3 Stack 给的一个隐藏红利是 React Query 的集成。create-t3-app初始化时就已经配置好QueryClient和 tRPC React hooks这意味着useQuery 的缓存、重试、失效机制全部开箱即用。商品推荐组件大概长这样import { api } from ~/trpc/react; export function RecommendedProducts() { const { data, isLoading, refetch } api.product.recommended.useQuery( { limit: 5 }, { staleTime: 60 * 1000, // 一分钟内不重新请求 retry: 2, // 失败重试最多 2 次 } ); // 用户手动刷新按钮 const handleRefresh () { void refetch(); }; if (isLoading) return div classNamep-4 text-gray-500正在加载推荐.../div; return ( div classNamegrid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5 {data?.map((p) ( div key{p.id} classNamerounded-lg border p-4 shadow-sm h3 classNamefont-medium{p.name}/h3 p classNametext-red-500¥{p.price}/p /div ))} /div ); }大家注意staleTime和retry这两个参数。新手最容易踩的坑是页面切换后回来数据又重新加载转圈圈体感很差。设置staleTime: 60_000之后一分钟内组件挂载时直接走缓存一秒都不等。另外如果推荐接口偶尔因为网络原因失败retry默认是 3 次在 UI 上表现为反复闪 loading所以我设成 2 次成功率也够等待时间也不离谱。3.4 数据失效与乐观更新还有一个真实业务里高频遇到的操作用户浏览商品后推荐列表要立刻更新。如果每次都刷新整个页面用户体验很割裂。tRPC 结合 React Query 提供了一个非常顺滑的解法——在 mutation 成功之后主动失效当前查询import { api } from ~/trpc/react; function ProductDetail({ productId }: { productId: string }) { const utils api.useUtils(); const recordView api.view.record.useMutation({ onSuccess: () { void utils.product.recommended.invalidate(); }, }); // 用户在详情页停留 3 秒后记一次浏览 useEffect(() { const timer setTimeout(() { recordView.mutate({ productId }); }, 3000); return () clearTimeout(timer); }, [productId, recordView]); }核心是utils.product.recommended.invalidate()这一行。它会让当前页面上所有api.product.recommended.useQuery的缓存失效React Query 会自动重新拉取一次。用户视角是推荐位自己换了一批商品但其实背后就是一次静默的请求。我还试过更激进的做法在onSuccess回调里用setData手动更新缓存把新推荐结果直接塞进本地状态。但实测下来手动 setData 要精确匹配后端返回结构容易因为字段差一点点微调导致类型问题不如 invalidate 来得省心所以我现在统一走 invalidate 路线代码简单也不受返回结构变化影响。4. 项目初始化配置与应用部署实操前面聊了很多设计层面的东西这章开始进入动手环节。从执行create-t3-app那一刻起每一步都有可以优化的细节。4.1 脚手架选择与初始化推荐直接走官方脚手架npx create-t3-applatest my-t3-app初始化工具会让你回答问题选择模版。我推荐的选项组合是Next.jsApp Router tRPC Prisma Tailwind NextAuth。如果你是第一次上手Authentication 也可以先选 No等业务跑通再补鉴权避免一开始被 NextAuth 的配置细节带偏。初始化完成后的目录结构有两块值得提前摸清楚一是src/server/api/下的root.ts、trpc.ts、routers/这些文件。trpc.ts里面定义了createTRPCRouter、publicProcedure、protectedProcedure是所有 Router 的底座。二是src/trpc/下的server.ts和react.tsx是服务端和 React 客户端使用的 tRPC 实例不要随便改这些文件里的导出方式否则类型推导会全部失效。4.2 环境变量配置create-t3-app自带环境变量校验机制。项目根目录有个.env.example文件复制成.env以后至少需要填DATABASE_URL。如果是本地开发用的 PostgreSQL一行搞定DATABASE_URLpostgresql://postgres:passwordlocalhost:5432/t3demo顺便提一句环境变量文件会被.gitignore排除掉但是.env.example一定要提交到仓库。团队协作时新同事拉代码只需要复制示例文件再填自己的配置不用猜到底要设哪些变量。这也是官方脚手架设计好的规范好习惯要保持。4.3 本地数据库准备T3 Stack 官方推荐用 PostgreSQL 配 Prisma。如果你本机没装 PostgreSQL想快起点可以用 Dockerdocker run --name t3-postgres -e POSTGRES_PASSWORDpostgres -p 5432:5432 -d postgres:16数据库起来之后执行迁移npx prisma migrate dev npx prisma db seed这里提醒一个实测过的坑最新版 Next.js 15 对 eslint 的严格程度比旧版高不少很多模板代码只能通过 lint 检查但你一改动就可能报错。强烈建议首次启动前先跑一遍npm run lint把环境里的 lint 规则和编辑器配置对齐否则后面改代码时满屏的红色波浪线和提交时的 lint 阻断会让你瞬间头大。4.4 生产环境部署实战部署部分我个人最推荐的是 Vercel 平台。它和 Next.js 是同一家公司出品Server Components、Middleware、API 路由这些特性的兼容性最好。部署时唯一要注意的是数据库不能部署在 Vercel 上要用独立的托管数据库。数据库我尝试过两个方案NeonServerless PostgreSQL可以白嫖一个免费实例。它的连接方式是DATABASE_URL给一个 connection string唯一要注意的是免费套餐的连接需要开连接池默认配了 PgBouncer 风格的池化 URL否则冷启动阶段连接太多容易报错。Railway部署 Postgres 实例更传统适合当正式项目的可靠数据库。部署时环境变量里除了DATABASE_URL还需要填 NextAuth 相关的NEXTAUTH_SECRET和NEXTAUTH_URL。NEXTAUTH_SECRET可以用openssl rand -base64 32生成一个随机字符串。注意不要把本地开发用的 secret 直接复制到生产环境务必重新生成一个。Prisma 迁移在 Vercel 上不会自动执行。我的做法是在项目构建命令里加一个自定义步骤{ scripts: { build: prisma generate prisma db push next build } }但这里有个大坑要提醒prisma db push不适合生产环境的数据库结构变更因为它是把 schema 直接推上去不生成迁移记录后续回滚很麻烦。上线前应该在本地跑prisma migrate deploy生成迁移文件推送 commit 后再部署。这样数据库结构变更通过迁移文件管理比 db push 可控得多。4.5 服务器自托管方案如果你不想用 Vercel 这种 Serverless 平台想自己租一台服务器部署我建议选 Node.js 的长期支持版本当前是 20 LTS。构建命令和本地一致npm install npm run build npm start进程守护用 PM2pm2 start npm start --name my-t3-app服务器上还需要一个反向代理Nginx 配置示例server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } }这里proxy_set_header几个字段不是随便写的。Next.js 的 HMR 和某些实时特性依赖于 WebSocket 连接反向代理必须正确传递 Upgrade 头否则开发环境调试和部分生产功能会偶发异常。自托管方案有个天然的劣势自动扩容、区域部署、日志分析这些都不如 Vercel 省心。但换来的是完全可控的服务器成本预算敏感型项目可以这么干。5. 性能优化与常见问题排查实录项目运行过程中总会冒出各种稀奇古怪的问题。从热门社区里大家最常碰到的坑加上我自己亲身踩过的雷整理出一份可以直接按图索骥的排查手册。5.1 客户端常见问题速查表现象可能的根因解决方案前端调用 tRPC 方法时报缺失SessionProvider根组件没有包 next-auth 的SessionProvider在src/app/providers.tsx里加入并包裹{children}页面一直 loading 没有数据Router 里用了protectedProcedure但会话未建立或 token 过期检查登录态或者确认是否把公开接口误定义为 protectedtRPC 调用报 404前端调用的 Router 路径和后端 router 注册名不一致检查root.ts里的appRouter挂载名确认product.list形式正确客户端 bundle 体积过大数据请求在客户端执行导致引用了服务端库优先改用 Server Components 或api.server调用Decimal字段序列化后类型不对Prisma Decimal 没有转换为 number返回前统一Number()或自定义序列化器页面首次加载白屏很久没有开启 Streaming / Server Components 静态化尽量把数据获取放到服务端减短客户端等待链这个表格是实操过程中最能救命的工具。但光有排查表还不够有几类问题值得展开细聊。5.2 Server Components 场景下的 tRPC 调用方式很多人在 Next.js App Router 里习惯性把所有页面写成客户端组件然后用useQuery拉数据。这样虽然能用但有个隐患是客户端渲染意味着所有 tRPC 依赖和访问令牌逻辑都打包进了浏览器 bundle既是体积负担也可能暴露不该暴露的代码路径。正确的姿势是页面级的数据请求放到 Server Components 里执行。T3 Stack 提供了服务端调用方式import { api } from ~/trpc/server; export default async function ProductsPage() { const products await api.product.list({ page: 1, pageSize: 10 }); // 直接在服务端拿数据返回结果是完整的 return ProductList initialData{products} /; }客户端组件如果需要交互操作点击刷新、下拉分页再配合useQuery的initialData初始化数据。这样首屏数据不经过客户端请求拉直了加载链路。注意服务端调用时不需要考虑鉴权 cookie 传递的问题因为 Next.js 会把请求的 cookie 上下文直接带给服务端 tRPC 调用所以受保护的接口也一样能正常工作。这是我实际项目里最重要的一条优化经验首屏性能提升非常可观。5.3 热更新导致类型丢失的玄学问题有一阵子我改完 schema 跑npx prisma generate后前端 tRPC 类型偶尔会消失编辑器里满屏红色类型错误。排查了很久发现是Next.js 的 dev 进程缓存了旧的 TypeScript 类型声明。解决办法很粗暴但有效强制重启 dev server。后来我干脆把prisma generate之前的缓存清除也一起写进脚本db:gen: prisma generate rm -rf .next每次改完 schema 都跑一遍这个命令确保.next编译缓存里没有旧类型残留。这个问题在 5.x 版本里官方虽然优化过但偶尔还是会遇到尤其是在编辑器保持长时间打开的场景下。5.4 数据库连接数在 Serverless 环境下的局促部署到 Vercel 的 Serverless 函数之间天然是隔离的每个实例都会建立新的数据库连接。免费 PostgreSQL 套餐往往有连接数上限并发一高就报too many connections。我的解决方案分三层第一层是连接池。使用提供了 pgbouncer 或类似功能的托管数据库比如 Neon 的 pooled connection string或者 Prisma 官方推荐的连接池配置。第二层是限制并发。给DATABASE_URL加上连接池参数例如DATABASE_URLpostgresql://user:passhost:5432/db?pgbouncertrueconnection_limit5第三层是数据库查询的兜底缓存。冷门的、变化不频繁的查询直接放在 Next.js 的缓存层里减少数据库请求频次。这里也提醒一句本地开发时如果发现各种奇怪的数据库请求超时先检查自己的 Docker 容器是不是把 5432 端口写错了我就遇到过容器端口映射写反导致本地开发连到别人的库文件的荒诞场景。5.5 NextAuth 与 tRPC 的会话鉴权集成这两者结合时有个常见问题是前端已经登录了但 tRPC protectedProcedure 依然报未授权。我排查过几次基本是 Cookie 属性问题。生产环境里 NextAuth 默认会把 Cookie 设置为Secure要求 HTTPS 才会携带。如果本地用 HTTP 调试填了NEXTAUTH_URLhttp://localhost:3000也正常但一旦部署到自定义域名并且没有配置好 HTTPSCookie 直接请求就带不上。还有一个容易忽略的坑NextAuth 的NEXTAUTH_SECRET一旦变更所有已登录 Session 全部失效。之前实施过一次 secret 轮换结果线上用户集体掉登录。建议这类操作放在凌晨低峰期并且提前在公告里说明。5.6 部署后页面样式闪烁Tailwind CSS 在 Server Components 和 Client Components 混用下偶尔出现样式闪烁就是首屏 HTML 里没有完整样式表。大多数情况下是因为 Tailwind content 配置里没有包含实际会用到的文件类型。例如只在src/**/*.{ts,tsx}里配置了但某个第三方组件的类名是从.js文件里生成的就会丢样式。我在tailwind.config.ts里统一配置成content: [./src/**/*.{js,ts,jsx,tsx,mdx}],保证所有常见前端文件都覆盖到。顺便提一句如果接入了自定义设计系统里的复杂类名建议启用 Tailwind v4 的source指令搜索范围否则某些动态拼接的类名会被错误清理。6. 开发流程团队协作规范与进阶方向如果要把 T3 Stack 真正用在团队项目里光靠个人技术还远远不够。代码组织规范、分支策略、数据迁移节奏这些软性规则往往决定了项目能不能长期维护下去。6.1 目录组织与 Router 拆分原则tRPC 的 Router 拆分如果做不好文件会膨胀到没法维护。我建议两类拆分维度一是按业务域拆分productRouter、orderRouter、userRouter、cartRouter各占一个文件在root.ts里统一挂载export const appRouter createTRPCRouter({ product: productRouter, order: orderRouter, user: userRouter, cart: cartRouter, });二是按数据访问复杂度分层如果某个 Router 的 handler 特别庞大可以拆成 service 层Router 只做编排和校验。我自己踩过一次坑是把一个订单流程里所有操作都塞进一个order.ts文件结果写了 800 多行后来整整花了半天时间才拆干净。Router 越薄越好真正的业务逻辑放 service 层Router 做纯传话筒。6.2 数据迁移的节奏把控Prisma 的迁移文件同样要在 Code Review 中认真审视。团队协作时我保留一个原则谁改 schema 谁负责生成迁移文件和通知前端影响。因为migrate dev生成的文件不仅仅是 DDL它意味着数据库结构变更了所有依赖旧字段的代码都得跟着更新。在 CI 流程里我会加一步npx prisma migrate deploy确保合并到主分支之前迁移文件能在干净的数据库上执行一遍。这样有效避免了本地跑得好好的生产一执行 migrate 就报字段重复的尴尬。6.3 单元测试与端到端测试策略T3 Stack 本身没有强制指定测试框架但这不代表可以不做测试。我目前在项目里采用的是Vitest Testing Library MSW组合。Router 层面的测试重点是输入校验逻辑和鉴权控制。比如一个订单创建接口传负数数量必须被拦截未登录用户必须拿不到数据。这些点写测试用例并不复杂但收益非常大尤其是字段类型改动时能立刻发现破坏点。端到端测试我用 Playwright 跑用户主链路——登录、浏览商品、加购、下单。因为 tRPC 是类型安全的E2E 流程可以少考虑一层参数格式错误的问题更多聚焦在 UI 交互和权限访问控制上。6.4 从 T3 Stack 继续演进的方向项目到一定体量后T3 Stack 的边界会逐渐显现。当你需要处理长列表虚拟滚动、复杂的实时协作、或者大数据量图表分析时可能要考虑这些演进方向一是加入 Server Actions这是 Next.js 自带的不用单独建 API 路径的前后端交互方式在某些表单提交类场景比 tRPC 更轻便。二是引入事件驱动的后台任务比如订单超时关单、邮件发送可以引入 Redis 队列或者数据库定时任务避免在 Router 里用await长连接阻塞请求。三是如果多客户端需求出现tRPC 无法直接满足移动端消费可以考虑把某些核心能力同时暴露成 REST或者加一层 GraphQL BFF。不过这里有明显的架构成本上升除非确实有硬需求否则慎动。这几种演进都有各自的代价不是升级就一定好。很多项目用 T3 Stack 核心能力已经足够覆盖业务场景贸然引入微服务或者复杂后端的模式反而会破坏现有效率。写在最后讲完这么多我最后想强调的还是那句话T3 Stack 的价值不只是把一堆好用的工具攒在一块儿它真正解决的是全栈项目里类型脱节、接口沟通成本高、工程搭建繁琐这三个痛点。用熟了以后你会明显感觉到前后端协作的边界变得模糊了——改接口和改函数是同一件事改数据和改类型也是同一件事——这种顺畅感一旦体验过就不太想回到传统 REST API 那种来回对着文档调试的日子。当然技术选型从来没有银弹。T3 Stack 适合的场景我在开头也划清楚了如果你的项目要长期做开放平台、要给多端服务、或者后端有独立团队和独立部署需求那这套方案的收益就要打折扣。但如果你正在启动一个 TypeScript 技术栈为主的全栈项目并且团队规模不大那么花一个周末把这套链路跑通是非常值得的投资。最后再分享一个个人习惯无论用 T3 Stack 还是其他技术栈每周花一点时间跑一遍依赖更新和安全补丁。Node.js 生态更新快长期不升级的依赖终有一天会成为定时炸弹。T3 Stack 的上游项目Next.js、Prisma、tRPC每个月都有不少改进紧跟版本节奏你的项目才会一直处于省心的状态。
返回列表