news 2026/10/28 6:20:38

T3 Stack实战:用tRPC和Prisma构建全栈代码片段管理工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
T3 Stack实战:用tRPC和Prisma构建全栈代码片段管理工具

1. 从"又一个Demo"到t3code:这个项目到底解决了什么问题

如果你跟我一样,每天要在多个项目之间切换,一定会遇到一个很真实的问题:代码片段散落得到处都是。有写在VS Code里的临时文件,有丢在团队聊天里的一段优化算法,有存成书签的Stack Overflow答案,还有自己博客里早就忘了用途的工具函数。每次要复用的时候,不是翻聊天记录,就是去旧项目里grep,运气不好还得重新手敲一遍。我开发t3code的初衷特别朴素——给自己一个能快速保存、随手检索、甚至能分享给同事的代码片段管理工具。

t3code这个名字,直白说就是"T3 Stack + Code"的合体。我选择用T3 Stack来搭建,因为它能把TypeScript的优势从数据库一路贯穿到浏览器端,这种"全栈类型安全"的体验一旦用惯,就很难再退回从前那种接口、类型、数据库字段各管各的日子。项目本身的结构并不复杂:用户可以创建片段,为片段添加标题、描述、语言标签和可见性设置,支持按关键词和标签筛选,还能生成只读分享页面。听起来像是一个简化版的GitHub Gist,但对想系统学习T3 Stack的人来说,这个项目的覆盖范围刚好能让人把Next.js、tRPC、Prisma、Tailwind四个核心成员用个遍。

如果你正打算上手T3 Stack,或者想找一个真实的全栈TypeScript项目作为参考,这篇博文的实战记录应该能帮到你。我不会只贴代码,还会把每一个关键选择的理由、踩过的坑、以及文档没写明白的细节一起说出来,这样你照着做的时候能少走弯路。

2. 为什么是T3 Stack,而不是别的全家桶

很多朋友一开始会问,现在全栈方案那么多,Next.js可以配Express、配NestJS,甚至直接用Next.js的Route Handlers自己写REST接口,为什么还要专门用tRPC?这个问题我在t3code里找到了很具体的答案。

2.1 端到端类型安全的实际体验

T3 Stack的核心理念是"类型安全是最高优先级",它不是营销口号,而是实实在在的工作流变化。我以前写前后端联调,最痛苦的环节就是定义接口返回类型:后端定义DTO,前端又写一遍interface,中间多了一层心智负担。字段改名、嵌套结构调整、可选类型变化,前端编译器根本不知道后端发生了什么,只有运行时才能发现。

在t3code里,我用tRPC定义了一个snippet.create的mutation,前端调用的时候,所有入参和返回值的类型都是自动推断出来的。比如后端返回了{ id: string, title: string, tags: string[] },前端编辑器里就会自动提示这些字段,一旦后端改了结构,前端代码直接编译报错,根本轮不到上线才发现问题。这种约束让我在重构表结构的时候特别有底气,因为只要TS类型能编译通过,接口层面基本不会出什么幺蛾子。

2.2 选型对比:tRPC vs REST vs GraphQL

很多教程都会说tRPC适合内部项目,不适合对外API。这个观点大体没错,但容易让人误解。在我这个场景里,t3code同时服务两个端:一个是浏览器里的管理界面,一个是将来要开放的分享页面。这两个端都由我自己控制,没有任何第三方消费者,所以不需要REST那种通过OpenAPI契约来沟通的机制,也更不需要GraphQL的订阅能力。

如果选择传统REST + React Query,我需要自己维护路由路径、URL参数、query string,还要为每个接口写请求函数和类型定义。GraphQL会好一些,GQL Codegen可以自动生成类型,但引入Schema、Resolver、Fragment这套体系,对小体量项目来说明显是过度设计。tRPC最讨喜的地方是它复用TypeScript函数的直觉:后端写一个async function,前端像调用本地async函数一样调用它,网络传输细节被完全封装了。代价是服务端和前端必须共享代码仓库,但t3code本来就是单体仓库,这一点完全不是问题。

2.3 为什么数据库用了Prisma而不是别的ORM

选Prisma之前,我其实犹豫过。Drizzle那时候还没那么成熟,TypeORM在Active Record和Data Mapper两种模式之间来回摇摆,Knex则偏底层。Prisma最打动我的不是自动迁移,而是它生成的类型体验特别顺滑。在t3code里,查询Snippet的时候经常要include它的Tag列表,Prisma返回的对象天然带有嵌套结构,而且类型推导完全准确,前端拿到数据直接就能渲染标签,省掉了一堆map处理。

另一个好处是Prisma Schema的可读性。t3code目前只有四张表,但用prisma/schema.prisma一眼就能看明白数据关系,后续如果加"收藏"和"评论"功能,只需要在schema里补模型,然后跑prisma migrate dev,数据库和TypeScript类型会自动同步。这种"单一事实来源"的体验,比维护手写SQL迁移清爽太多。

3. t3code的数据库设计:Prisma模型是怎么一步步长的

数据库设计是整个项目的基石。t3code第一版只有User和Snippet两张表,后来因为标签需要复用,才拆出了Tag和SnippetTag两张关联表。下面把我最终使用的Schema展示出来,并说明每个设计当时的取舍。

3.1 User、Snippet、Tag的核心Schema

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String description String? @db.VarChar(500) code String @db.Text language String @default("text") visibility Visibility @default(PRIVATE) author User @relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId, visibility]) @@index([language]) } enum Visibility { PRIVATE PUBLIC } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } 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]) }

整套架构的起点是User,它直接沿用了NextAuth的默认模型字段。Snippet表的visibility用来控制公开和私密状态,这个设计后续在查询过滤时非常重要。Tag和Snippet之间是多对多关系,连接表SnippetTag使用复合主键,并且两个外键都配置了onDelete: Cascade,这样删除一个Snippet时,中间表的关联记录会自动清理,不会留下孤儿数据。

3.2 为什么ID用了cuid而不是自增整数

可能要有人说,自增整数不是更简单吗?t3code选择cuid有两点很现实的原因:一是Next.js项目如果后面要做乐观更新,前端在创建立即拿到ID很重要,而cuid可以在客户端生成,自增ID就必须等数据库响应。二是当API公开后,整数ID很容易被遍历,别人只要把/snippet/1改成/snippet/2,就能猜到系统里有多少条数据。cuid虽然不能完全隐藏数据,但至少在链接分享时多了一层模糊保护。

另外在Prisma里用@default(cuid())非常省心,不需要在应用层额外生成。运行时也会自动处理冲突,不需要我操心并发场景。

3.3 标签设计:连表还是JSON数组

标签这块我纠结过两次。第一种方案是在Snippet表里加一个tags String[]字段,Postgres本身支持数组,查询用has操作符也能做到标签筛选。但问题在于标签没法统一管理,比如我想统计所有唯一的标签,用数组字段就得把所有行取出来再在应用层去重,非常别扭。第二种方案是直接存逗号分隔的字符串,写起来最简单,但搜索和更新都很难受。最终我选择了标准的多对多连表,虽然看起来多了一层表,但换来的是标签主数据表可以单独增删改查,未来加"标签颜色""标签描述"都很顺手。

在查询Snippet的时候,Prisma的include: { tags: { include: { tag: true } } }可以一次性拿到带标签的完整对象,虽然嵌套稍微深了一层,但类型安全给了我信心。

4. 从数据库到页面:一次tRPC请求的完整追线

数据库模型落定之后,接下来就是最核心的部分——tRPC如何把数据库操作变成前端可直接调用的API。这里我以t3code的创建Snippet功能为例,完整走一遍请求链路。

4.1 定义tRPC Router:输入校验和业务逻辑

在tRPC里,一个mutation不只是"接收数据、操作数据库"这么简单。我在server/api/routers/snippet.ts中定义了创建Snippet的完整逻辑。

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(200), description: z.string().max(500).optional(), code: z.string().min(1), language: z.string().max(50).default("text"), visibility: z.enum(["PUBLIC", "PRIVATE"]).default("PRIVATE"), tags: z.array(z.string().max(30)).default([]), }) ) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; const snippet = await ctx.db.snippet.create({ data: { title: input.title, description: input.description, code: input.code, language: input.language, visibility: input.visibility, authorId, tags: { create: input.tags.map((tagName) => ({ tag: { connectOrCreate: { where: { name: tagName }, create: { name: tagName }, }, }, })), }, }, }); return snippet; }), });

这段代码有几个值得解释的点。第一,protectedProcedure表示这个调用必须携带有效会话,未登录用户会在tRPC层面直接被拦截,不会进入业务逻辑。第二,使用Zod做输入校验,比手写if判断优雅得多,前端传空标题或者超长字符串,都是自己的问题,后端不背锅。第三,标签部分用connectOrCreate保证同一个标签名称不会重复创建,这是t3code里我用的最频繁的Prisma技巧。

4.2 前端怎么调用:React Query把一切变简单

tRPC在前端使用时会自动生成带类型的hooks。你不需要手动写什么useEffect加fetch,只需要这样调用:

const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { void utils.snippet.getAll.invalidate(); }, }); const handleSubmit = (values: FormValues) => { createSnippet.mutate({ title: values.title, code: values.code, language: values.language, tags: values.tags, visibility: values.visibility, }); };

api.snippet.create.useMutation返回的mutation对象自带isPending、error、data这些状态,React Query在背后负责缓存和失效。我特别喜欢invalidate的设计:创建成功后只需要让列表相关的查询失效,React Query会自动重新拉取最新数据,不用手动刷新页面,也没有冗余的全局状态管理。

4.3 一个容易被忽视的坑:不要把逻辑全写在router里

t3code早期犯过一个错误:router文件里塞了大量业务逻辑。比如"创建Snippet时自动给作者生成一份统计",我会直接写在mutation里,测试起来特别费劲。后来我养成了一个习惯:router只做"协议"层面的组装——校验入参、调用service、返回结果。业务逻辑抽到server/services/snippet.service.ts里,比如查找公开Snippet并附带作者信息,就是一个独立函数,路由里只是把它包一层。

这样做好处很明显。一是写单元测试时可以直接针对service函数测试,不需要mock整个tRPC上下文。二是多个router可以复用逻辑,比如后续要加"复制Snippet"的mutation,直接调用service里的cloneSnippet就行。你从一开始就保持这个分层习惯,项目后期维护会轻松很多。

5. 前端界面的打磨:Tailwind布局和代码编辑器的实战取舍

T3 Stack的前端除了TypeScript之外,最亮眼的就是Tailwind CSS。t3code的界面没有用任何组件库,所有布局都是靠Tailwind手写的,这让我对原子化CSS有了更深的体会。

5.1 三栏布局如何用Tailwind快速落定

t3code的界面参考了代码编辑器的经典布局:最左侧是片段列表,中间是编辑表单,右侧是实时预览。如果用传统CSS写响应式三栏,得花不少时间处理flex布局和断点。Tailwind里几行类名就搞定了:

<div className="flex h-screen bg-gray-50 dark:bg-gray-950"> <aside className="w-72 shrink-0 border-r border-gray-200 dark:border-gray-800 overflow-y-auto"> <!-- 片段列表 --> </aside> <main className="flex-1 flex flex-col min-w-0"> <!-- 编辑区 --> </main> <aside className="w-120 shrink-0 border-l border-gray-200 dark:border-gray-800 hidden xl:block"> <!-- 预览区 --> </aside> </div>

我印象最深的是min-w-0这个类。flex子项默认min-width: auto,当中间编辑区的代码很长时,它会把整个布局撑破。加上min-w-0后,中间区域才能正确收缩并出现滚动条,这个坑不踩一次很容易被忽视。

5.2 深浅色主题的切换细节

t3code支持深浅色主题,靠的是Tailwind的dark:变体。项目里直接用next-themes包处理主题切换,并在tailwind.config.ts中设置darkMode: "class"。切换按钮只是一个<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>,全局的CSS变量会自动变化。这里有个细节:在服务器端渲染时,theme初始值可能会和客户端不一致,导致闪烁。next-themes自带的suppressHydrationWarning可以解决这个问题,但要在<html>标签上显式加上。

和很多教程只讲"怎么切主题"不同,我想强调的是配色方案本身的价值。t3code里的代码字体用了JetBrains Mono,编辑器背景和文本颜色我直接从VS Code Dark+主题里提取,这样前端预览和本地开发环境视觉上保持一致,减少切换成本。

5.3 Monaco Editor太重了,我最终选了轻量方案

最初想直接在t3code里集成Monaco Editor,毕竟那是VS Code的编辑器内核,支持语法高亮、自动补全,体验最好。但实测后我发现两个问题:一是Monaco的包体积很大,首屏满载能超过3MB,对轻量工具来说太奢侈;二是Monaco在React StrictMode 下需要额外处理loader配置,动态加载逻辑稍不注意就会报错。

后来我换成了@uiw/react-textarea-code-editor,它本质上是Textarea加Prism语法高亮,体积小,支持常见语言,也够用了。配合Tailwind可以自定义样式,编辑体验对片段管理场景完全足够。如果你的需求只是记录代码,没必要让浏览器扛一个完整IDE。

6. 部署到Vercel与线上优化:文档没写明白的那些坑

项目开发完成只是第一步,真正让我长记性的是部署上线过程。t3code目前部署在Vercel上,Postgres数据库用的一个兼容Serverless的托管服务。下面把部署中遇到的几个核心问题以及优化手段逐一说明。

6.1 Serverless环境下Prisma的连接管理

Prisma在传统Node.js服务器上运行没有问题,但到了Vercel的Serverless函数里,如果每个请求都新建一个数据库连接,连接数很快会耗尽。Prisma官方推荐在全局缓存一个PrismaClient,t3code的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: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这段代码利用了Next.js的热重载机制:开发环境修改文件后,模块重新加载,但globalThis上缓存的PrismaClient可以复用,避免重复实例化。部署后,Serverless的每个冷启动实例也会保留单例连接,配合数据库连接池,基本不会出现连接数告警。

6.2 数据库迁移应该在CI里做而不是在本地

我在部署初期犯过一个错:直接在本地跑prisma migrate deploy然后重新部署。本地开发机和线上数据库环境不一样,有时候本地通过了,线上会因为数据库版本或权限问题失败。后来我把迁移流程放到了GitHub Actions里:每次Push到main分支,先安装依赖,跑Prisma生成客户端,再执行prisma migrate deploy,等数据库迁移成功后才触发Vercel构建。这样保证了数据库结构一定先于应用代码更新,不会出现代码已经引用某个新字段、数据库还没有对应列的尴尬情况。

6.3 tRPC查询的SSR与缓存策略

t3code的片段列表页需要服务端渲染吗?我把snippet.getAll这个query在创建时设置成了预取到SSR数据里,使用trpcServer在服务端直接调用,把查询结果放入Next.js的缓存。这样首屏HTML里就带有片段列表,用户等待时间明显缩短。但对于代码内容比较敏感的PRIVATE片段,我不会做SSR预取,而是等客户端拿到会话后再请求,避免隐私数据泄露到响应HTML源码中。

另外,React Query的缓存策略也被我用起来了:staleTime默认是0,表示数据永远过期,切换页面时会重新请求。在t3code的列表页面,我设置了staleTime: 60_000,让数据在一分钟内不用重复拉取,浏览不同片段时切换速度会明显变快。代价是数据可能不是绝对实时,但对于片段管理工具来说,一分钟的延迟完全可接受。

7. 用t3code复刻一遍,你真正能得到什么

如果你已经把前文看完,大概率已经理解T3 Stack每个部分在真实项目中承担的角色了。但我还想多说一句,这个项目我最大的收获不是某个具体API的用法,而是"全栈TypeScript"带来的心智稳定感。以前我写项目,精力要分成两半:一半写功能,一半防意外。现在tRPC在编译期就把前后端类型绑死,Prisma把数据库层纳入了TS的类型体系里,剩下的时间我可以放心地花在交互体验上。

如果你想复刻一个类似t3code的项目,我建议你从最小模型起步。第一版不需要做标签系统,不需要分享页,甚至不需要编辑器的语法高亮,只要一个create和getAll能跑通,你就已经掌握了T3 Stack的主干。第二版再加标签,通过这个需求体会多对多关系怎么建、tRPC怎么处理嵌套创建。第三版再加分享页和主题切换,这样每一步的改动都在已有基础上增量叠加,排查问题会容易很多。

最后分享一个小技巧:t3code的所有业务函数,我都保持"非tRPC依赖"的写法。也就是说,service层只接收普通参数、返回普通对象,不依赖ctx和req这些上下文。这样将来不管你是想给同一个逻辑加REST接口,还是写CLI脚本直接调数据库,都能轻松复用。这也是我在这个项目里踩过不少次坑之后收获的最值钱的经验,希望你在做类似的项目时也能少走这一步弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 6:20:00

军事指控人机协同:关键技术解析与实践

1. 项目概述"军事指控人机协同"这个课题听起来就让人联想到科幻电影里的场景&#xff0c;但实际上它已经是现代军事领域正在面临的现实挑战。作为一名曾在军工系统工作多年的技术专家&#xff0c;我亲眼见证了从传统指挥模式向智能化协同转型过程中的种种阵痛。简单来…

作者头像 李华
网站建设 2026/10/28 6:18:36

C语言实现高效Hash对象:数据结构与核心算法

1. 理解真正的Hash对象在C语言中实现一个真正的Hash对象&#xff0c;本质上是要构建一个能够高效存储和检索键值对的数据结构。与简单的数组或链表不同&#xff0c;Hash对象通过哈希函数将键映射到特定的存储位置&#xff0c;从而实现接近O(1)时间复杂度的查找操作。1.1 Hash对…

作者头像 李华
网站建设 2026/10/28 6:18:33

MySQL索引底层原理:B+树数据结构详解

1. MySQL索引的底层数据结构解析在数据库领域&#xff0c;索引就像图书馆的目录系统&#xff0c;它能让我们快速定位到需要的数据。MySQL作为最流行的关系型数据库之一&#xff0c;其索引实现采用了B树这种数据结构。为什么不是哈希表、二叉树或者普通的B树&#xff1f;这需要从…

作者头像 李华
网站建设 2026/10/28 6:18:33

NativeScript与Jetpack Compose融合开发指南

1. NativeScript与Jetpack Compose的跨界融合当NativeScript遇上Jetpack Compose&#xff0c;这场跨平台与原生UI框架的碰撞正在重塑移动开发体验。作为长期从事混合应用开发的实践者&#xff0c;我见证了太多开发者在技术选型上的纠结——既想要跨平台的高效&#xff0c;又渴望…

作者头像 李华
网站建设 2026/10/28 6:16:40

Agent-Reach:构建Agent可达性观测体系,根治静默失联事故

凌晨一点三十八分&#xff0c;我盯着脚本输出的最后一行日志发呆。那行日志告诉我“全部主机已下发完成”&#xff0c;但我知道这不是真的——那批 320 台机器里&#xff0c;有 174 台根本没有执行任何命令。它们沉默地待在机房里&#xff0c;像集体断线了一样&#xff0c;而没…

作者头像 李华
网站建设 2026/10/28 6:15:28

用t3code打造跨平台开发环境:一行命令搞定配置管理

1. 从一行命令到一个趁手工具&#xff1a;t3code 项目的起源说起 t3code 这个名字&#xff0c;可能有人觉得它是什么神秘框架的名字&#xff0c;其实它是我在本地折腾开发环境时攒出来的一个个人项目代号。t3 这个后缀一看就知道和“三步走”有关系——我的目标非常简单&#x…

作者头像 李华