1. 从"t3code"这个标题说起:它到底是什么
第一次看到"t3code"这个词,我脑子里蹦出来的第一反应是——这大概率是个技术圈的自造词,而不是某个现成的产品名。事实也确实如此。在开发者社区里,类似"t3code"这种命名方式非常常见:一个字母加数字的前缀,后面跟一个功能性的词,用来指代某个特定技术栈下的代码实践、脚手架或者项目模板。t3这个前缀在技术圈里最广为人知的出处,是那个把TypeScript、Tailwind CSS和tRPC打包在一起的全栈开发范式,圈内人习惯叫它"T3 Stack"。所以"t3code"最合理的解读,就是围绕这套技术栈展开的代码组织方式、项目结构约定,或者干脆就是一套可以直接拿来用的代码模板。
那它到底能做什么?简单说,如果你正在用或者打算用TypeScript做全栈项目,t3code代表的这套东西能帮你解决一个非常实际的问题:从零搭建一个类型安全、前后端打通、样式方案统一的项目骨架,并且不用在配置上浪费三天时间。它适合的人群也很明确——有一定前端基础、想往全栈方向走、但又不想被各种配置文件折磨的开发者。如果你已经写过几个React项目,对TypeScript不陌生,但每次新建项目都要重新折腾一遍ESLint、Prettier、路径别名、API层封装这些东西,那t3code这套思路就是冲着你来的。
我之所以对这个话题有话说,是因为我自己在过去两年里,前前后后搭过不下十个基于这套技术栈的项目,踩过的坑、绕过的弯路、最后沉淀下来的那套"抄了就能用"的配置,基本都在这了。下面我会从整体设计思路开始,一层一层拆开讲,把每个关键决策背后的"为什么"说清楚,再给到可以直接复现的操作步骤和参数。不管你是刚接触这套技术栈的新手,还是已经用过一段时间但总觉得哪里不顺的老手,应该都能从里面捞到点有用的东西。
2. 整体设计与思路拆解:为什么是这套组合
2.1 核心思路:用类型系统把前后端"焊"在一起
t3code这套东西最核心的设计哲学,其实就一句话:让TypeScript的类型系统贯穿整个应用,从数据库查询一直管到前端组件的props。传统的前后端分离开发里,后端定义了一套接口,前端照着文档再定义一遍类型,两边一旦不同步,就是运行时的报错和调试的噩梦。t3code的思路是,后端用tRPC定义好过程(procedure),前端直接通过类型推导拿到返回值类型,中间不需要手写任何接口类型声明。你改后端的一个字段名,前端对应的调用处立刻飘红,编译期就给你拦下来。
这个思路的好处是显而易见的,但代价也得说清楚。它要求你的前后端在同一个代码仓库里,也就是所谓的monorepo或者单仓库结构。如果你的团队是前后端完全分离、各自独立部署、通过HTTP接口通信的,那这套东西的收益会打折扣。所以选型之前先想清楚:你的项目是不是一个团队维护的全栈应用?如果是,t3code这套组合的收益非常大;如果不是,硬套反而会增加沟通成本。
2.2 技术选型的取舍逻辑
为什么是TypeScript而不是JavaScript?这个问题现在问出来可能有点多余,但在实际项目里,TypeScript的价值不在于"类型安全"这个口号,而在于它让重构变得可执行。一个几百个文件的项目,你要改一个核心数据结构的字段名,没有类型系统的话,你只能靠全局搜索加肉眼判断,漏掉一处就是线上bug。有了TypeScript,编译器会把你所有需要改的地方列出来,你一个个改完,编译通过,基本就稳了。这就是为什么t3code把TypeScript作为整个体系的基石。
为什么是Tailwind CSS而不是CSS-in-JS或者传统CSS?我自己的体会是,Tailwind解决的不是"怎么写样式"的问题,而是"样式写在哪里"的问题。传统CSS你要想类名、要维护样式文件、要担心全局污染;CSS-in-JS虽然把样式收进了组件,但运行时开销和心智负担都不小。Tailwind把样式变成了一组原子类,直接写在JSX里,改样式就是改类名,不用在文件和文件之间跳来跳去。当然,Tailwind的类名一长串确实不好看,但配合编辑器的排序插件和组件抽取,实际写起来效率是明显更高的。
为什么是tRPC而不是REST或者GraphQL?REST的问题是接口契约靠文档维护,容易脱节;GraphQL虽然类型强,但引入了一套新的schema定义语言和运行时,学习成本和配置成本都不低。tRPC的定位很精准:在TypeScript全栈项目里,用最少的代码实现端到端的类型安全。你不需要写schema,不需要生成代码,后端定义一个函数,前端直接调用,类型自动推导。这个体验一旦用过,就很难回去了。
2.3 项目结构的约定
t3code在项目结构上有一套比较固定的约定,这套约定不是随便定的,每一层都有它的道理。典型的目录结构大概是这样:
src/ server/ api/ routers/ trpc.ts db/ schema.ts index.ts pages/ 或 app/ components/ styles/ utils/ types/server目录放所有后端相关的东西,api/routers里按业务模块拆分路由,db里放数据库schema和连接实例。前端部分,components放可复用组件,pages或app放路由页面。这个结构的核心思想是按职责分层,而不是按文件类型分层。很多项目喜欢把所有组件放一个文件夹、所有工具函数放一个文件夹,项目一大就乱。按职责分层的好处是,你改一个功能,相关的文件基本都在一个目录下,找起来快,删起来也干净。
3. 核心细节解析与实操要点
3.1 环境准备与依赖安装
动手之前,先把环境理清楚。Node.js版本建议用18以上的LTS版本,太老的版本对ESM和新的TypeScript特性支持不好。包管理器我推荐pnpm,原因是它对monorepo的支持最好,安装速度快,磁盘占用也小。如果你习惯npm或者yarn也不是不行,但pnpm在依赖提升和幽灵依赖处理上确实更省心。
初始化项目的时候,可以直接用现成的脚手架,也可以手动搭。手动搭的好处是你清楚每一个依赖是干什么的,出了问题知道从哪查。核心依赖大概分几类:框架本身(Next.js或Vite+React)、TypeScript、Tailwind CSS及其配套的PostCSS和autoprefixer、tRPC相关的包(@trpc/server、@trpc/client、@trpc/react-query)、数据校验库Zod、以及数据库相关的ORM(Prisma或Drizzle)。这些包之间的版本兼容性需要留意,尤其是tRPC和React Query的版本要匹配,不然会出现类型推导失败的问题。
提示:安装依赖时建议锁定大版本号,比如tRPC用
^10而不是latest。这套生态更新很快,小版本之间偶尔会有破坏性变更,锁版本能避免半夜被一个自动更新搞崩。
3.2 类型安全的API层怎么搭
tRPC的核心概念是router和procedure。router就是一组相关procedure的集合,procedure相当于一个API端点,但它是用函数定义的,不是用路径字符串。定义一个procedure大概长这样:
import { z } from "zod"; import { publicProcedure, router } from "../trpc"; export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { return ctx.db.user.findUnique({ where: { id: input.id } }); }), create: publicProcedure .input(z.object({ name: z.string().min(1), email: z.string().email() })) .mutation(async ({ input, ctx }) => { return ctx.db.user.create({ data: input }); }), });这里有几个关键点值得展开说。第一,input用Zod定义,这不仅是类型声明,还是运行时校验。前端传过来的数据会先过一遍Zod,不合法直接报错,不会进到你的业务逻辑里。第二,query和mutation的区分对应了读和写,这个区分在React Query的缓存策略里很重要,query会自动缓存和重新验证,mutation则用来触发副作用。第三,ctx是上下文对象,数据库连接、当前用户信息这些跨procedure共享的东西都挂在这里,通过一个统一的createContext函数注入。
前端调用的时候,体验是这样的:
const { data, isLoading } = api.user.getById.useQuery({ id: "123" });注意这里没有任何手写的类型声明,data的类型是自动推导出来的,跟后端返回的类型完全一致。后端改了返回结构,前端这里立刻会有类型错误提示。这就是t3code这套东西最值钱的地方。
3.3 数据库层的schema设计
数据库这块,Prisma和Drizzle是两个主流选择。Prisma的优势是schema文件直观、迁移工具成熟、生成的客户端类型好用;Drizzle的优势是更贴近SQL、运行时更轻、对边缘环境更友好。我自己的选择是,如果项目跑在传统的Node.js服务器上,用Prisma;如果要部署到边缘运行时,用Drizzle。
不管用哪个,schema设计有几个原则是通用的。第一,主键用cuid或uuid,不要用自增整数,自增整数在分布式和迁移场景下容易出问题。第二,时间戳字段统一用UTC存储,展示的时候再转本地时区,不然跨时区用户会看到混乱的时间。第三,软删除用deletedAt字段而不是isDeleted布尔值,前者能记录删除时间,后者只能记录删没删,信息量差很多。
model User { id String @id @default(cuid()) name String email String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? }这个schema看起来简单,但每个字段的设计都有考量。email加unique约束,防止重复注册;createdAt和updatedAt分开,方便审计;deletedAt可空,为空表示未删除。这些细节在项目初期定好,后期能省很多事。
3.4 样式方案的组织方式
Tailwind用起来简单,但项目一大,类名管理就成了问题。我的经验是,超过五个类名的组合就抽成组件或者用@apply封装成语义类。比如一个按钮,与其在每个地方都写一长串类名,不如定义一个Button组件,把样式收进去,外部只暴露variant和size两个prop。
另外,Tailwind的配置文件tailwind.config.js里,建议把设计系统的颜色、间距、字体都定义成token,而不是在代码里写死具体值。这样做的好处是,以后要调整主题色,改一个地方就行,不用全局搜索替换。比如:
module.exports = { theme: { extend: { colors: { primary: { 500: "#3b82f6", 600: "#2563eb", }, }, }, }, };用的时候写bg-primary-500,而不是bg-[#3b82f6]。前者有语义,后者是魔法数字,维护成本差很多。
4. 实操过程与核心环节实现
4.1 从零搭建的完整步骤
假设你现在要从零搭一个t3code风格的项目,下面是我实测下来最顺的流程。第一步,创建项目目录并初始化package.json,设置"type": "module",这样可以用ESM语法。第二步,安装核心依赖,我一般会一次性装完,避免来回折腾:
pnpm add next react react-dom @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson pnpm add -D typescript @types/react @types/node tailwindcss postcss autoprefixer prisma @typescript-eslint/parser @typescript-eslint/eslint-plugin第三步,初始化TypeScript配置。tsconfig.json里最关键的是strict: true和paths别名配置。strict打开会带来一些额外的类型检查,初期可能觉得烦,但长期看是值得的。paths别名让你可以用@/components/Button这样的路径导入,而不是../../../components/Button,后者在深层嵌套的目录里简直是灾难。
{ "compilerOptions": { "strict": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }第四步,配置Tailwind。初始化之后,在tailwind.config.js的content字段里指定要扫描的文件路径,确保所有用到类名的地方都被覆盖到,不然生产构建时Tailwind会把没扫描到的类名当成未使用而删掉,导致样式丢失。第五步,搭建tRPC的服务端和客户端。服务端定义router和context,客户端创建tRPC hooks。这一步的代码量不大,但配置项比较多,建议对照官方文档一步步来,不要跳步。
4.2 关键配置的参数计算与选择
这里重点说几个容易出错的参数。第一个是React Query的staleTime。这个参数决定了数据多久之后被认为是"过期的",过期后重新请求。默认值是0,意味着每次组件挂载都会重新请求。对于不常变的数据,比如用户资料,可以设成5分钟甚至更长;对于实时性要求高的数据,比如消息列表,保持默认或者设短一点。这个值设得太短,请求频繁,服务器压力大;设得太长,用户看到的是旧数据。我的经验是,列表类数据设30秒到1分钟,详情类数据设5分钟,配置类数据设30分钟。
第二个是tRPC的transformer。默认情况下,tRPC用JSON序列化数据,但JSON不支持Date、Map、Set这些类型。如果你后端返回一个Date对象,前端拿到的是字符串,类型对不上。解决办法是配置superjson作为transformer,它能正确处理这些类型。配置方式是在服务端和客户端都加上:
import superjson from "superjson"; // 服务端 export const t = initTRPC.context<Context>().create({ transformer: superjson, }); // 客户端 export const trpc = createTRPCReact<AppRouter>({ transformer: superjson, });第三个是数据库连接池的大小。Prisma默认的连接池大小是num_physical_cpus * 2 + 1,在开发环境够用,但生产环境如果并发高,可能需要调大。不过要注意,连接池不是越大越好,数据库本身的连接数上限是有限的,连接池太大反而会导致连接等待。一般来说,单个应用实例的连接池设在10到20之间比较稳妥,具体要看数据库的配置和应用的并发量。
4.3 一个完整功能的实现记录
拿一个"用户列表页"来举例,走一遍完整流程。后端这边,先在server/api/routers/user.ts里定义一个listprocedure:
list: publicProcedure .input(z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(20), })) .query(async ({ input, ctx }) => { const { page, pageSize } = input; const [items, total] = await Promise.all([ ctx.db.user.findMany({ skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: "desc" }, }), ctx.db.user.count(), ]); return { items, total, page, pageSize }; }),这里用了分页,skip和take是Prisma的分页参数,Promise.all并行执行查询和计数,比串行快。注意pageSize设了上限100,防止前端传一个巨大的值把数据库拖垮。
前端这边,在页面组件里调用:
const [page, setPage] = useState(1); const { data, isLoading } = api.user.list.useQuery({ page, pageSize: 20 }); if (isLoading) return <Spinner />; return ( <div> {data.items.map((user) => ( <UserCard key={user.id} user={user} /> ))} <Pagination current={page} total={data.total} onChange={setPage} /> </div> );整个流程里,前端没有写任何接口类型,data的类型完全来自后端推导。data.items里每个元素的类型就是Prisma的User类型,编辑器里点进去能看到所有字段。这种体验是传统REST开发给不了的。
4.4 构建与部署的注意事项
构建的时候,Next.js项目要注意output配置。如果部署到支持standalone模式的平台,设成output: "standalone"能显著减小产物体积。环境变量方面,数据库连接字符串、密钥这些敏感信息绝对不能提交到代码仓库,用.env文件管理,并且把.env加到.gitignore里。生产环境的环境变量通过部署平台的配置界面注入,不要硬编码在代码里。
部署之后,第一件事是验证数据库迁移有没有跑成功。Prisma的迁移命令是prisma migrate deploy,这个命令在生产环境是幂等的,重复执行不会出问题。但要注意,迁移文件一旦提交就不能改,改了会导致迁移历史不一致。如果迁移写错了,正确的做法是新建一个迁移来修正,而不是改旧的。
5. 常见问题与排查技巧实录
5.1 类型推导失败的排查思路
用tRPC最常见的坑就是类型推导失败,编辑器里api.xxx下面全是红线。这种情况九成以上是AppRouter类型没有正确导出或导入。检查两个地方:服务端有没有export type AppRouter = typeof appRouter,客户端导入的是不是这个类型。另外,如果服务端和客户端在不同的包或者目录里,要确保TypeScript的路径映射配置正确,不然客户端找不到服务端的类型定义。
还有一种情况是版本不匹配。tRPC的客户端和服务端版本必须一致,React Query的版本也要和@trpc/react-query要求的版本匹配。排查方法是看package.json里的版本号,或者直接pnpm why @trpc/server看实际安装的版本。版本冲突在monorepo里尤其常见,因为不同的包可能依赖了不同的版本。
5.2 数据库连接问题的速查表
数据库相关的问题,我整理了一个速查表,基本覆盖了日常遇到的大部分情况:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 连接超时 | 数据库地址错误或网络不通 | 用telnet或nc测试端口连通性 | 检查连接字符串和防火墙规则 |
| 连接数耗尽 | 连接池配置过大或连接未释放 | 查看数据库的当前连接数 | 调小连接池,检查是否有未关闭的事务 |
| 迁移失败 | 迁移文件冲突或数据库状态不一致 | 查看迁移日志和数据库的迁移记录表 | 回滚到一致状态后重新迁移 |
| 查询慢 | 缺少索引或查询条件不合理 | 用EXPLAIN分析查询计划 | 加索引,优化查询条件 |
这个表里的每一条我都在实际项目里遇到过。印象最深的一次是连接数耗尽,原因是某个定时任务里开了事务但异常路径没有回滚,连接一直挂着不释放。后来加了try/finally确保事务一定结束,问题就解决了。这种问题在开发环境很难发现,因为并发低,到了生产环境才暴露出来。
5.3 样式不生效的几种情况
Tailwind样式不生效,最常见的原因是content配置没覆盖到文件。比如你新加了一个目录放组件,但tailwind.config.js里的content数组没包含这个目录,Tailwind就不会扫描里面的类名,生产构建时这些类名会被当成未使用而删除。开发环境因为是即时编译,可能看起来正常,一到生产就出问题。所以每次新增目录,记得同步更新content配置。
第二种情况是类名动态拼接。比如className={`text-${color}-500`},这种写法Tailwind识别不了,因为它是在运行时拼接的,Tailwind在构建时扫描不到完整的类名。解决办法是用完整的类名做映射,比如定义一个对象{ red: "text-red-500", blue: "text-blue-500" },用的时候取对应的值。这样类名在代码里是完整的字符串,Tailwind能扫描到。
第三种情况是CSS优先级问题。Tailwind的原子类优先级比较低,如果你同时用了自定义的CSS类,可能会被覆盖。解决办法是尽量避免混用,或者用!important修饰符(在类名后面加!),但后者要慎用,用多了会让样式难以维护。
5.4 性能优化的几个实操技巧
性能这块,我踩过的坑主要集中在数据请求上。第一个技巧是合理使用React Query的缓存。默认情况下,同一个query key的数据在多个组件间是共享的,不会重复请求。但如果你在query key里放了每次都变的值,比如Date.now(),那缓存就失效了,每次都会重新请求。query key要设计成稳定的、能唯一标识数据的值。
第二个技巧是用prefetch预加载。比如用户列表页,用户点击某个用户会跳转到详情页,你可以在列表项hover或者进入视口时预加载详情数据,这样点击进去的时候数据已经在了,体验会流畅很多。tRPC提供了useUtils钩子,可以拿到prefetch方法:
const utils = api.useUtils(); const prefetchUser = (id: string) => { utils.user.getById.prefetch({ id }); };第三个技巧是避免在渲染函数里创建新对象。比如useQuery({ page, pageSize: 20 }),如果这个对象每次渲染都新建,React Query会认为输入变了,可能触发不必要的重新请求。解决办法是把输入对象用useMemo包起来,或者把稳定的值直接传进去。
6. 我在这套技术栈上的一些个人体会
写了这么多,最后说点掏心窝子的话。t3code这套东西,本质上不是某个具体的工具或者框架,而是一种用类型系统约束整个开发流程的思路。它的价值不在于让你少写几行代码,而在于让你在改代码的时候心里有底。你知道编译器会帮你兜底,不会因为漏改一个地方就出线上事故。这种安全感,在项目规模上去之后,是任何开发效率工具都换不来的。
但它也不是银弹。如果你的项目很小,就几个页面,那引入这一整套东西的配置成本可能比收益还大。如果你的团队里有人对TypeScript不熟,strict模式下的类型报错可能会让他们很痛苦。所以我的建议是,先在一个中等规模的项目上试水,跑通了再往大项目上推。不要一上来就在核心业务上全套照搬,那样风险太大。
另外,这套生态更新确实快,今天能用的配置,半年后可能就有更好的替代方案。我的做法是,核心的架构决策(比如用tRPC做API层)保持稳定,周边的工具(比如ORM、UI库)保持关注,有更好的选择时小范围试点,验证没问题再逐步替换。这样既能享受新工具的红利,又不会因为频繁重构而影响业务。
最后一个实用建议:把项目里那些"约定"写成文档或者注释。比如为什么用cuid不用自增id,为什么query key要这么设计,为什么某个配置项设成这个值。这些决策背后的原因,如果不写下来,三个月后你自己都忘了,更别说新加入的同事。文档不用多正式,一个DECISIONS.md文件,按时间倒序记录关键决策和原因,就足够了。这个习惯我坚持了两年,回头看,它帮我省下的沟通成本,比任何工具都多。