从零构建 t3code:T3 Stack 全栈开发实战全记录
最近一直在琢磨一个事:代码片段到底怎么管才最省心。浏览器收藏夹里的 Stack Overflow 链接越存越多,本地笔记软件里散落着临时复制的配置命令,GitHub 上的 Gist 又只能靠账号体系勉强支撑。于是我就用周末时间搭了一个小项目,代号叫 t3code。名字很直白,就是 T3 Stack 三件套——TypeScript、TailwindCSS、tRPC 的合称。项目功能不花哨:注册账号、登录、写代码片段、打标签、全文检索、一键复制,数据在云端,换台电脑也能拿到。看起来只是个小工具,但从架构选型、数据建模、鉴权,到部署上线,整个全栈链路全都覆盖了一遍。这篇文章就把我对 t3code 的完整思考和实操过程整理出来,给想上手现代全栈开发、又对 tRPC 这类新东西感兴趣的朋友做个参考。
1. 项目定位与整体设计思路
1.1 为什么叫 t3code
先解释一下名字。T3 在技术圈通常指下一代全栈开发组合:TypeScript 提供类型安全,TailwindCSS 负责样式,tRPC 让前后端调用像本地函数一样直接。t3code 这个名字就是简单粗暴地把 T3 和 code 拼在一起,既是技术栈的标记,也暗示这个项目本身跟编码强相关。
命名看起来是小事,但对我来说挺重要。一个清楚的代号能帮助你在开发时快速聚焦。比如我本地起了很多服务,t3code 的目录、Docker 容器名、代码里的命名空间,全都带这个前缀,不容易混淆。如果你也要建类似项目,建议一开始就把项目代号定下来,别用什么 my-app、test-demo,后期会乱到你想哭。
1.2 要解决的真实痛点是什么
做工具之前,我先理了理自己每天遇到的场景。写技术文章时经常要贴一段 docker-compose.yml;写脚本前想找以前用过的日期正则;部署新服务时要复制一份 Nginx 配置再改改端口。这些东西有个共同点:不是完整项目,不值得单独建仓库,但丢弃了又可惜,下次用到还得重新搜。
t3code 的核心逻辑就是把“临时代码”做成一个可检索的个人沉淀库。最基础的需求其实有这么几条:
- 保存代码时能填标题、语言、标签和正文。
- 列表页快速浏览,搜索能按标题或内容命中。
- 打开详情页能看高亮代码、复制代码块。
- 必须登录才能用,免得数据裸奔。
做需求拆解时,我特意没加太多复杂功能,比如协作编辑、版本历史、公开分享,这些在 MVP 阶段都是负担。工具类产品最容易犯的错就是功能铺太开,结果每个点都不好用。t3code 的第一版只要能完成“存进去—搜得到—抄出来”这个闭环,就已经能解决我 80% 的痛点。
1.3 模块边界怎么划分
按全栈习惯,我把它分成三层。前端负责页面渲染和交互,服务端负责业务逻辑和数据读写,数据库只存结构化数据。但因为用了 Next.js,前端和服务端住在同一个项目里,tRPC 又把接口调用做成了像函数调用一样,所以模块边界更多是靠目录来约束,而不是靠独立服务。
具体来说,我把项目划分成六个模块:
- 认证模块:负责注册、登录、会话管理。
- 代码片段模块:负责增删改查和搜索。
- 标签模块:负责标签的维护和过滤。
- 用户设置模块:负责个人偏好,比如默认复制格式。
- 对外开放接口模块:预留 API 给未来的浏览器插件调用。
- 部署配置模块:包括环境变量、数据库迁移脚本、静态资源策略。
这么一分,开发时就不容易迷失。哪怕是一个小项目,模块边界清晰了,后续扩展才不会被自己绕晕。
2. 核心架构选型:T3 技术栈怎么选、为什么这么选
2.1 tRPC 凭什么替代传统 Restful API
先聊 tRPC。这是 T3 Stack 里最让人纠结的一个选择。传统全栈开发通常是 Node 后端写一堆 REST 接口,前端再用 axios 按 URL 请求。问题在于:URL 写错了、参数类型对不上、接口返回结构和预期不一致,这些坑经常会拖慢开发速度。类型定义要在前后端各维护一份,时间一长就不同步。
tRPC 的思路完全不一样。它让你在服务端定义一个 router 和对应的 procedure,然后服务端的函数会被自动生成一个类型安全的客户端对象。前端直接调用这个对象的方法,输入参数和返回值的类型都从服务端推断出来。如果服务端改了返回值结构,前端编译直接报错,不需要靠文档和联调来兜底。
给没接触过的朋友打个比方。REST 接口就像打电话叫外卖,你得报地址、报菜名、等接线员确认,中间任何一个环节听错了都可能出问题。tRPC 则是走进一家餐厅,坐在服务员旁边指着菜单说“我要这个”,什么能吃、什么不能点,菜单上写得清清楚楚。
我在 t3code 里写了一个 getRandomSubs 的流程,等会实操部分会展开。这里想强调的是,tRPC 并不适合所有场景。如果你的系统需要开放接口给第三方开发者、需要严格的按版本发布、需要跨语言调用,那还是老老实实上 REST 或 GraphQL。但如果是 Next.js 全家桶这种前后端同仓库的形态,tRPC 的体验确实香。
2.2 其他技术选型的理由
数据库我选了 SQLite 加 Prisma,生产环境切到了 PostgreSQL。没选 MongoDB,是因为我的数据模型是典型的关联结构:用户—片段—标签,用关系数据库建索引和做关联查询更顺手。Prisma 的好处是 schema 文件写清楚后,可以自动生成类型和迁移脚本,省去手写大量 SQL 的麻烦。
认证方案选了 NextAuth.js,它和 Next.js 的集成度很高,支持 OAuth、邮箱、凭证等多种方式。t3code 第一版只做了邮箱密码登录,后续可以很轻松地接入 GitHub 或者 Google 登录,不用动太多代码。
样式层用 TailwindCSS 是考虑到开发效率。写自定义 CSS 也能实现界面,但 Tailwind 的原子类让我在没离开 JSX 的情况下就能完成排版,配合夜间模式和响应式断点,省了不少心。唯一要克制的是别什么效果都硬塞进去,类名堆太多之后同样难维护。
编辑器这块我选了 CodeMirror 6。它比 Monaco 轻量,加载速度快,而且语言高亮、代码折叠、自动补全都做得不错。t3code 只是用来保存和展示代码,不需要跑到一个完整 IDE 的水准,CodeMirror 刚好卡在轻量和实用的平衡点上。
2.3 不使用 T3 之前的反面教训
其实两年前我做过一个类似的小工具,当时用的是传统三层架构:React 前端、Express 后端、MongoDB 数据库。基础功能确实也能跑,但迭代到后面就有几个很明显的痛点。
第一是接口联调成本高。后端每个接口都要写路由、校验、文档,前端又得维护一堆 request 函数,很多字段其实就是从一个函数传给另一个函数,中间却隔了一层 JSON。第二是类型断层严重。ESLint 能保证前端代码内部不出错,但前后端交接的类型只能靠手写 interface,改一个字段就要同步两处。第三是错误处理不统一,后端抛错的格式和后端校验提示各说各话,前端处理异常时得猜。
换成 t3code 的这套架构后,最直观的感受是:后背跟前端可以共用一套类型,数据流从数据库一路传到组件里,中间没有断点。很多本来要靠口头沟通保证一致性的东西,编译期就帮你检查完了。
3. 从 0 到 1 实现 t3code 的实操记录
3.1 初始化和项目骨架
第一步当然是初始化项目。我用的是 create-t3-app 的官方脚手架,它会自动装好 Next.js、TypeScript、tRPC、Prisma、NextAuth、TailwindCSS,省的自己逐个配置。
执行命令:
npx create-t3-app@latest t3code按提示选择你要用到的模块。我勾选了 NextAuth、Prisma、Tailwind、tRPC,其他选项比如 Drizzle、Cloudflare 等先不碰。
装完之后目录大概长这样:
t3code/ ├─ prisma/ │ └─ schema.prisma ├─ src/ │ ├─ pages/ │ ├─ server/ │ │ ├─ api/ │ │ ├─ auth.ts │ │ └─ db.ts │ ├─ styles/ │ └─ utils/ ├─ .env └─ package.json刚初始化完的项目默认带了一个健康检查接口,可以先跑起来确认环境没问题。
3.2 数据模型设计
接下来是比较关键的 database schema。t3code 的表不多,但我还是花了不少时间考虑索引和关联关系。Prisma schema 大概这样:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] tags Tag[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String content String language String @default("text") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tags SnippetTag[] @@index([userId, updatedAt]) } model Tag { id String @id @default(cuid()) name String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) snippets SnippetTag[] @@unique([userId, name]) } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) snippetId String tagId String @@id([snippetId, tagId]) }这里有几个设计细节值得说。
用户和代码片段之间是典型的一对多关系,删用户时级联删除他的所有片段,这在 onDelete: Cascade 里体现。标签我用的是多对多关联,因为一个片段可以同时挂多个标签,一个标签下面也可能有很多片段,中间表 SnippetTag 就是干这个的。
索引方面,我给自己加了一个复合索引@@index([userId, updatedAt]),为什么?因为列表页最常见的查询是“某个用户的片段按更新时间排序”,这个索引能直接命中,避免全表扫描。数据量小的时候感受不到差别,但积累到几千条之后,有没有索引的差别会非常明显。
3.3 写第一个 tRPC 查询流程
先登录获取会话,然后查数据库,流程在 tRPC 里可以写得很直观。我在src/server/api/routers/snippet.ts里定义路由:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ search: z.string().optional(), tag: z.string().optional(), language: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const snippets = await ctx.db.snippet.findMany({ where: { userId: ctx.session.user.id, ...(input.search ? { OR: [ { title: { contains: input.search, mode: "insensitive" } }, { content: { contains: input.search, mode: "insensitive" } }, ], } : {}), ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.language ? { language: input.language } : {}), }, orderBy: { updatedAt: "desc" }, include: { tags: { include: { tag: true } } }, take: 50, }); return snippets; }), });这里最关键的是protectedProcedure。它会在执行查询之前先检查当前请求里有没有合法会话,没有就直接抛出未认证错误。这套校验逻辑是框架层帮你做掉的,不需要在函数里手写 if。
前端调用时,我在 React 组件里这样拉数据:
const { data, isLoading } = api.snippet.list.useQuery({ search: keyword || undefined, tag: activeTag || undefined, });一改完,编辑器的补全立刻能提示search、tag、language这些字段,返回的 data 也会被推断成包含 tags 关系的类型。全程不用看文档,不用 Postman 对着 URL 调试,类型的正确性由编译器把关。
3.4 变更操作的实现
查询有了,增删改也不能少。tRPC 里的 mutation 就是用来处理写操作的。我加了 create、update、delete 三个 mutation,其中 create 的逻辑是这样的:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(200), content: z.string().min(1), language: z.string().default("text"), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const user = ctx.session.user; const snippet = await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, userId: user.id, tags: { create: input.tags.map((name) => ({ tag: { connectOrCreate: { where: { userId_name: { userId: user.id, name } }, create: { name, userId: user.id }, }, }, })), }, }, }); return snippet; }), });这里我用connectOrCreate来处理标签。意思是一个标签如果在这个用户名下已经存在就连接它,否则就创建一个。这个操作很巧妙地支持了多对多关系里的“自动建标签”需求,省得先查一遍再写一遍。
注意输入校验用的是 zod。zod 在妙的地方是它既是运行时校验,又能被 TypeScript 推断类型。我在 input 里定义了tags: z.array(z.string()),那前端传参时编译器就会强制要求 tags 是字符串数组。
3.5 界面与交互实现
后端接口有了,前端页面要能跑通。我做了三个核心页面:
/:列表页,展示最新片段和搜索栏。/snippets/[id]:详情页,带语法高亮和复制按钮。/create:新建页,使用编辑器写内容、填标签。
列表页的数据流很简单,每次搜索输入变化时触发 query 参数更新,tRPC 会自动发请求并更新 UI。t3code 没有做本地状态缓存,因为代码片段更新频率不高,数据库查询也足够快。
代码高亮我用的 CodeMirror。初始化时把language字段映射到对应的支持模块,然后挂到 textarea 上。界面用 Tailwind 写响应式,小屏设备也能浏览,不过复制按钮这种交互在手机上需要额外处理一下点击范围。
登录页面直接交给 NextAuth 的 credentials 流程。[...nextauth].ts里配置了邮箱密码登录,密码用 bcrypt 加密存储。整个过程不复杂,但有个注意点:生产环境的 JWT 密钥一定要在环境变量里单独配置,千万别用默认值。
3.6 部署上线的具体步骤
本地开发没问题后,我部署到 Vercel。用的是 PostgreSQL 云数据库,这样生产环境的数据不落地本地机器,方便多人协作也不怕丢。
部署步骤:
- 在本地生成 Prisma Client:
npx prisma generate。 - 跑迁移:
npx prisma migrate deploy,这里不是在本地跑而是让数据库迁移脚本在生产库上执行。 - 推送代码到 GitHub,Vercel 接入仓库后自动构建。
- 在 Vercel 的控制台配置环境变量:
DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。 - 部署完成后,用生产域名测试一遍注册、登录、新建、搜索流程。
实际操作中我踩过一个坑:本地开发库和生产库的 schema 版本不一致。当时直接改了 schema 文件但没生成迁移文件,导致部署后 Prisma Client 预期的字段在数据库里不存在,接口直接报 500。后来我养成了一个习惯:每次改 schema 之后必跑prisma migrate dev,确认生成新的 migration 文件并提交到仓库。
4. 过程遇到的坑与排查实录
4.1 tRPC 类型同步的经典问题
用 tRPC 的第一个坑,大概率出在“改了服务端返回结构,但前端还在用旧字段”。按理说 tRPC 的类型是从服务端推导的,前端应该会自动变。但实际上,如果你的 API router 定义在服务端目录,而前端组件引用的类型是通过自动生成的类型文件拿到的,那需要确保类型生成流程被正确触发。
我的解决方案是把 tRPC 的实例创建单独放在src/trpc.ts里,统一引用 appRouter 的输入输出类型。这样每次改动 router,TypeScript 的增量编译会同步更新前后端之间的类型桥梁。别在页面文件里硬编码手写一个自定义 interface,那等于退回到手动同步的老路了。
4.2 生产环境数据库连接失败的排查过程
另一个非常常见的问题,是本地跑通了但部署后数据库连不上。我当时拿了报错日志看了半天,发现原因不是网络不通,而是DATABASE_URL里带了localhost。
用 Docker 起 PostgreSQL 时,本地连接串是postgresql://user:pass@localhost:5432,但 Vercel 的服务器跟本机不在同一个网络,这个地址当然连不上。解决办法很简单:在生产环境配置一个云端数据库的真实连接地址,本地开发保持本地依赖的地址不变。
这类问题排查时的关键技巧是分层验证:先确认环境变量有没有加载成功,再试数据库客户端能否连上,最后才是看应用层报什么错。别一上来就在业务代码里找原因。
4.3 搜索速度不够快的优化
t3code 第一版用的搜索是数据库里的 LIKE 模糊查询。数据量几百条时一点问题都没有,但导入几千条代码片段之后,搜索响应明显变慢了。
原因很朴满:LIKE 查询没法走普通索引,每次都要扫全表。我升级了 PostgreSQL,然后引入了全文本搜索:
@@index([title, content], type: FullText)Prisma 支持 raw SQL,我就针对标题列和内容列建立了全文索引,查询时统一走 tsvector 匹配。这里并不需要非常复杂的搜索引擎,个人知识库级别的数据量,PostgreSQL 自带的全文本搜索已经足够。
如果你也遇到这个问题,可以按这个顺序做:先建普通索引试优化排序逻辑,再用数据库全文本搜索,实在不行才考虑引入 Elasticsearch 或 Meilisearch。别为了一个小工具直接上重型组件,运维压力会把你劝退。
4.4 Tailwind 样式不生效的排查
还有一个很初级的问题,但新手常常遇到:Tailwind 编译后没有输出对应的工具类。比如你用了dark:bg-slate-900,但页面切换主题后背景没变。
这类问题通常有三个原因。一是内容路径没配置对,Tailwind 在自动检测时没扫到那个文件;二是类名中有动态拼接,bg-${color}-500这种写法会让编译器无法识别;三是暗色模式的方案没选对,需要显式配置 class 模式。
我处理 t3code 时把暗色模式切换按钮做成基于 class 的:给 html 节点加上.dark,Tailwind 配置里用darkMode: "class"。排查完才发现其实是第二类问题,有一段类名是从数据库字段里拼出来的,扫描不到。后来我把完整的类名映射表写在前端,确保 Tailwind 能提前识别到所有可能的组合。
4.5 权限校验不能只跟前端藏个按钮
最后说一个安全意识。刚开始我确实犯过一次错误,把“删除按钮仅管理员可见”当成权限控制。但实际上,让按钮不显示只是 UI 层面的隐藏,后端 mutation 如果没校验 role,别人照样可以通过调用 API 执行删除操作。
后来我把所有删除和修改操作都改成了 protectedProcedure,并且在服务端检查资源归属。也就是说,就算有人伪造请求拿改了 snippetId,只要这条记录的 userId 不等于当前登录用户,就直接拒绝。这种“后端兜底”的思路是权限设计里最不能省的一环,特别建议刚开始做全栈的朋友把它当成硬规矩。
5. 这项目后续还能怎么延伸
5.1 本地增强和离线优先
t3code 目前是纯 Web 形态,在线才能用。后来我想到一个很自然的扩展方向:把数据加入 localStorage 缓存,让用户没网的时候也能浏览自己保存过的代码,等网络恢复再同步。
实现思路也不难。列表加载后把数据写入 IndexedDB,每次查询先查本地缓存再发请求。代码片段这种读取多、写入少的内容,做离线优先很划算。如果你要做类似功能,可以关注一下 Next.js 的 Service Worker 能力和 tRPC 客户端的事件订阅机制。
5.2 浏览器插件和快捷采集
另一个我很想做的扩展,是配套的浏览器插件。日常逛技术文章看到一段好代码,鼠标选中文案,右键菜单里直接选择“保存到 t3code”,它会自动把选中的代码、页面标题、来源 URL 一起推送过来。
这个功能用浏览器插件就能实现,和 t3code 后端通过同一个 tRPC router 对接。因为 tRPC 的客户端类型是生成好的,插件的请求端也能复用同一套类型定义,不需要重新写一遍接口对接逻辑。
5.3 分享功能与小的数据可视化
如果用户量上来了,加一块“公开分享”功能是个不错的选择。给片段增加一个公开链接,别人点开来是只读页面,比自己贴代码截图要好看得多。还可以给标签做一个热度排名,统计哪些技术栈你保存得最多,做一个小型的数据分析页。
不过这些都是后话。工具类产品最有价值的部分始终是核心闭环被完整跑通,外围的扩展可以慢慢加。先保证搜索快、复制顺手、数据不丢,这才是 t3code 这个项目最耐用的基本面。
最后分享一个我的体会:做这种个人项目,技术选型可以激进一点,因为试错成本低。t3code 从立项到跑通线上,花的成本真的不多,但你亲自把 tRPC 这套链路由底到顶走一遍之后,对现代全栈开发的理解会完全不一样。如果你也打算动手做点小工具,别再搭那些绕弯的脚手架了,直接拿 T3 组合开干就行。踩坑是不可避免的,但每踩一个坑,你赚到的都是能写进简历里的实战经验。