最近我终于把我折腾了一个多月的 t3code 项目正式开源了。先说清楚它是个什么:这是一个基于 T3 Stack 技术栈开发的代码片段分享平台,核心功能就是让大家像发推特一样随手粘贴一段代码、配上标题和标签,然后生成一个短链接分享给朋友。你也许会觉得“GitHub Gist 不就能干这事吗?”,但我的出发点很明确——趁手、轻量、有社区感。t3code 没有用传统的 REST API 那一套,而是把整个应用建立在 Next.js、tRPC、Prisma、Tailwind 这四大件上,也就是大家常说的 T3 Stack,整个项目从前端组件到数据库表结构都是端到端类型安全的,这一点在实际开发中帮了我大忙。
如果你最近正在纠结要不要入坑 T3 Stack,或者你正好想做一个带分享功能的代码工具型网站,这篇复盘应该能给你不少参考。我会把当时做技术选型的思考、数据库结构怎么设计、tRPC 的 service 层如何组织、部署上遇到的坑、以及几个实测过的调优细节全部倒出来。这不是一篇“教你怎么复制粘贴”的教程,更像是一个老开发做完项目之后的现场总结,你从里面能直接拿走的,都是我踩出来的经验。
1. 项目起源与技术选型:为什么是 T3 Stack
1.1 t3code 到底要解决什么问题
先说项目动机。我平时写代码的时候经常遇到一个场景:在群里帮别人排查一个报错,我把关键代码贴出来,结果被聊天软件的格式化功能蹂躏得完全没法看。又或者我想把一个有意思的算法技巧分享给同事,但在微信里传文件太蠢,直接发代码块又会被压缩。GitHub Gist 虽然成熟,但总觉得它的交互偏重,我没法很快地给片段加标签、按语言筛选,更没有办法像论坛帖子那样让其他人评论几句。t3code 想要解决的就是这个“轻量分享”的缺口:输入一个标题,粘贴代码,选一下语言和标签,一分钟内得到一条可分享的链接。整站风格走极简路线,你不用注册登录也能浏览,只有你自己发帖的时候才需要验证邮箱。后来我还加了一个非常实用的功能:支持短链自定义,可以让分享链接变成t3code.dev/c/onboarding-guide这种形式。这个需求听起来简单,但从产品逻辑到技术实现拆开来看,涉及的东西一点都不少。
1.2 选型依据:没有选 XX,而是选了 Next.js + tRPC + Prisma + Tailwind
其实一开始我脑子里蹦出的方案是“Next.js + Express + PostgreSQL”,前端用 React,后端单独开一个 server,再配上 Swagger 文档。这种方案当然没有问题,但很快我意识到,对于一个以“代码分享”为核心的小型项目来说,维护两套服务器和一套 REST 规范本身就超重了。T3 Stack 的思路是:不要让类型定义在前后端之间来回拷贝,而是让编译器直接帮你校验。tRPC 的出现把这个过程做成了“写一个函数,然后在组件里直接调用”,函数参数和返回值的 TypeScript 类型是全程传递的,我改了一个数据库字段的类型,前端调用的地方立刻标红报错。这一点在一个人开发的全栈项目里极具价值,因为它变相替我守护了 API 契约。
换一个角度说,如果你是做中小型项目、团队不大、又想快速把想法落地成产品,Next.js 的 App Router 配合 tRPC 的效率远高于前后端分离方案。tRPC 不需要像传统 REST 那样定义 path、method、query param,它的每个procedure就是一个函数端点,天然支持输入校验(zod)和输出序列化。Prisma 则把数据库访问变成了类型安全的 ORM,我个人不太喜欢写 SQL 和 migration 混在一起,Prisma Schema 那种声明式的模型定义让我可以在 5 分钟内理清所有表关系。Tailwind 更不用多说了,写页面的时候不用来回切 CSS 文件,这个对于做分享类 UI 非常友好,因为代码高亮、布局响应式这些都需要大量微调。
1.3 技术栈背后的“组合拳”逻辑
单纯说选型很容易变成罗列名词,真正好用的地方在于这几个工具之间的默契。T3 Stack 的默认组合就是 Next.js + tRPC + Prisma + Tailwind,这个组合里 Next.js 负责页面路由、SSR 和部署,tRPC 管理内部数据流,Prisma 直接连接数据库,Tailwind 处理样式。因为都是 TypeScript 生态,所以从数据库里的Snippet模型到页面上渲染出来,数据类型没有断过层。服务器端拿到一条记录,通过 tRPC 返回给前端,前端直接就有自动补全提示。再配合zod做输入校验,我可以在同一个文件里定义参数 schema 和函数实现,不用像以前那样跑去查 OpenAPI 文档。这个“组合拳”可以说就是为“快速开发又不想放弃类型安全感”的人准备的。对我这个具体项目来说,选用这个组合让我节省了至少三天的联调时间,因为几乎所有类型错误都在编译期暴露了,上线后几乎没出现过“字段名拼错”的线上 bug。
2. 核心功能拆解与数据结构设计
2.1 核心功能清单梳理
动手写代码之前,我给 t3code 规划了一圈 MVP 功能。首要的当然是代码片段的发布:标题、正文代码、语言标签、说明性副文本。第二个功能是浏览和搜索:首页展示最新片段,支持按语言筛选,还做了一版简单的关键词搜索,用的是 PostgreSQL 的全文检索。第三个功能是详情页:展示代码高亮、便捷复制按钮、以及基于标签的相似内容推荐。第四个功能是用户系统,我一开始不想做复杂登录,就用 NextAuth 接 GitHub OAuth,配合邮箱校验。后面又加了用户主页,展示这个用户发布过的所有片段。评论功能我放到了第二期,因为第一版核心是“分享”而不是“讨论”。另外还有管理后台的一些计数功能:浏览数、点赞数、复制次数,这些数据在列表页不需要,但详情页可以用来优化推荐算法。功能清单听起来很平常,但每一条落实到数据结构上都需要仔细考虑。
2.2 数据库模型设计(Prisma Schema 要点)
Prisma Schema 是整个 t3code 的根基。我先把最核心的Snippet模型放出来看一下:
model Snippet { id String @id @default(cuid()) title String code String @db.Text plainText String? @db.Text description String? @db.Text language String @default("text") tags Tag[] @relation("SnippetTags") author User? @relation(fields: [authorId], references: [id]) authorId String? slug String @unique viewCount Int @default(0) likeCount Int @default(0) forkCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([language]) @@index([createdAt]) }这里面有几个关键考量。首先是slug字段必须唯一,我生成 slug 的逻辑是如果标题是纯英文就转小写加连字符,如果是中文就加一段随机 hash 后缀,避免重复。然后是plainText字段,这个字段是原代码的纯文本形态,用来做全文检索和高亮降级。为什么要专门存一份纯文本而不直接用code?因为我在前端使用代码高亮库时会预先做 HTML 转义,存储时如果用富文本格式反而会增加复杂度。language字段直接用字符串而非外键,是因为 Prisma 里的枚举对大量语言支持很不友好,我宁愿在应用层做白名单校验。tag 表我用了多对多关系,每个 snippet 最多打五个标签。其实一开始我考虑过用数组字段,但为了后面做标签云和标签筛选,还是开了关联表。
2.3 关键业务逻辑:代码片段的保存与分享
保存逻辑中最重要的不是 insert 语句本身,而是“防注入、防破坏、防垃圾内容”。前端提交上来的代码先经过 zod schema 校验,限制正文最长 50000 个字符,语言必须在支持列表里。真正保存的时候,我不会直接存用户传入的高亮 HTML,而是把原始代码存进code字段。这个动作在当初写的时候费了不少劲,因为很多项目为了图方便会把前端渲染好的 HTML 直接入库,其实这是大坑:一旦前端渲染逻辑更新,历史数据全部需要重新转换,而且存入带样式的内容极容易产生 XSS 漏洞。我要分享的链接是/s/[slug],详情页读取时根据language动态选择 PrismJS 语言包进行服务端高亮,同时保留一份纯文本用于复制按钮和代码对比。这样既保证了展示效果,也保证“复制代码”不会复制一堆 span 标签。
用户分享后看到的是一个站内短链,这里我额外处理了短链冲突检测。因为 t3code 允许自定义 slug,所以我在createSnippet这个 tRPC procedure 里做了捕获唯一性冲突的逻辑,如果用户自定义的 slug 被占用了,就自动在后面加上一个短 id。这里我踩过一个很隐蔽的坑:tRPC 里的错误如果直接抛Prisma.PrismaClientKnownRequestError,前端会拿到很抽象的 message,所以我封装了一个TRPCError映射层,把 Prisma 的错误码转成中文用户提示。
3. 实操细节:从零搭建 t3code 的关键步骤
3.1 初始化项目与依赖配置
T3 Stack 推荐用create-t3-app脚手架来初始化,我实际操作的时候用的命令是:
pnpm create t3-app@latest t3code初始化的时候建议直接勾选NextAuth、Prisma、Tailwind、tRPC四个选项,因为后面加进来比较麻烦。初始化完成后,第一件事是修改环境变量文件,配置数据库连接串、NextAuth 的 GitHub 回调地址。这里有个经验:如果本地和线上用一个数据库,连接串里的 schema 名字一定要指定清楚,否则 Prisma 的 migrate 可能会作用到错误的目标库。我的.env长这样:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="..." NEXTAUTH_URL="http://localhost:3000" GITHUB_ID="..." GITHUB_SECRET="..."然后执行:
pnpm db:push pnpm dev如果是全新项目,db:push会直接创建表结构,非常快。但后续改动 schema 必须用prisma migrate dev,我习惯了给每个 migration 写提交信息,方便回滚。
3.2 tRPC Router 编写与类型安全实践
tRPC 的核心在于 router 的拆分和类型归纳。我在server/api/root.ts里把子路由snippetRouter、userRouter、tagRouter合并到了总路由,组件里调用 API 是完全的类型穿透,这是 T3 Stack 最爽的体验。减少重复操作,用户可以一次请求获取“片段详情加作者信息加相似标签”的聚合数据,这时候直接新建一个 procedure 来整合数据。有一个重要性极高的点:tRPC 的input schema和output schema要独立构建,比如 list snippets 的时候,前端并不需要plainText字段,我就在输出 schema 里用.omit()剔除,能轻量一点传输。上传片段时的流程是先验证用户登录(protectedProcedure),再验证语言是否在列表里,然后生成 slug。代码大致如下:
export const createSnippet = protectedProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; const slug = await generateUniqueSlug(input.slug || input.title); const snippet = await ctx.db.snippet.create({ data: { title: input.title, code: input.code, plainText: input.plainText, language: input.language, slug, authorId: userId, }, }); return snippet; });这里有一个我刚用 tRPC 时容易犯的错:把所有校验都写在input的 zod schema 里,但没考虑数据库的唯一约束。比如 slug 在数据库层已经是 unique,但我只做了前端格式校验,结果并发请求时会有两个一样的 slug 同时走进创建逻辑,然后其中一个报错。后来我兜底捕获了唯一约束冲突,再重新生成一个带随机数的 slug 重试一次,才算平稳落地。
3.3 前端页面与 Tailwind 样式的实现
t3code 的 UI 走的是黑底白字的极简风,原因是代码展示需要较高对比度。Tailwind 在这里最大的帮助是我能够直接在组件里通过@apply抽公共样式,比如代码编辑器容器、标签组件等。具体到实现上,首页和列表页我都是放在 RSC(React Server Component)里调 tRPC 的吗?这里必须澄清一下:tRPC 的 server caller 在 Server Component 里使用需要极其谨慎,因为它们依赖请求上下文,如果直接调用没有包裹请求作用域的 caller,很多 session 信息是拿不到的。所以我选择了更稳的方案:在客户端组件里使用useQuery,然后配合 Next.js 的getServerSideProps做首屏数据注入的替代品——其实更准确的说法是,我用的是app router里的 Server Component 配合 server caller 传入createContext的方式。这块细节比较复杂,我个人建议不要一上来就在 RSC 里调 tRPC,最好是先在页面里初始化一个trpcServercaller,然后只把返回结果序列化传给客户端。
页面布局方面,列表页就是一个响应式网格,卡片展示标题、语言标签、时间、第一段代码摘要。代码高亮我用的是prism-react-renderer,配合一个自定义主题。这个库在 SSR 时直接渲染不会产生闪烁,而且支持自定义语言包,我只加载了 30 个常见语言包,打包体积控制在 250KB 左右。
3.4 部署与运维:Vercel + 数据库连接池
t3code 部署我选择了 Vercel,因为 Next.js 和 Vercel 的适配性确实没得说。但部署后第一周就遇到了一个经典的坑:Vercel 的无服务器函数在使用 Prisma 时,如果连接数据库的配置不对,冷启动会非常慢,甚至直接超时。解决思路是把 Prisma 客户端实例挂到全局对象上,避免每个函数都新建一次 database connection。我在lib/db.ts里这样写:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;数据库方面,如果直接用 Vercel Postgres 的 serverless driver,和 Prisma 的适配需要我们显式配置driverAdapters,不然只能走普通的 TCP 连接。为了少折腾,我最后把数据库放在 Supabase 上,连的是连接池端口,而不是直连端口。这里有个重要细节:地址是aws-0-us-east-1.pooler.supabase.com,如果你不小心配成了直连地址,在 Vercel 函数里会因为连接数限制而频繁报 504 错误。
另外,我在部署环境里专门加了几个只读副本的查询入口,列表页用副本,详情页写操作走主库。这个优化也是被逼的——上线第三天,列表页的查询因为多个热门片段同时被访问,导致主库 CPU 直接飙到 90%。分离读写之后,主库压力下降了 70%。
4. 实测中踩过的坑与性能调优记录
4.1 代码高亮与防 XSS 的取舍
代码高亮是分享平台最核心的体验,但也是最容易出现安全漏洞的环节。最开始我图省事,直接把 Markdown 转成 HTML 后在前端用dangerouslySetInnerHTML渲染高亮结果,结果被内部测试发现可以在代码片段里注入<img onerror>造成反射型 XSS。后来我痛定思痛,做了一套严格的净化逻辑:服务端接受代码后用isomorphic-dompurify做消毒,再交给prism-react-renderer的 token 机制逐个渲染。逐个 token 渲染虽然相对慢一点,但它天然避免了所有 HTML 注入面。为了保持性能,我给 Prism 语言包加了缓存,所选语言的 token 结果第一次计算后存储在 Redis 上,有效期 24 小时。这个方案实测下来,详情页的 TTFB 时间从 1.2 秒降到了 320 毫秒,收益非常明显。
另一个容易被忽略的坑是:代码片段里包含了回车符和 tab 键,很多高亮库在转换时会把某些字符切换成 或<br/>,如果复制按钮直接复制 innerText,原来的缩进会全部丢失。我最后做复制功能时,统一使用navigator.clipboard.writeText并传入plainText字段,而不是页面上的高亮文本,这样彻底解决了复制变形的问题。
4.2 tRPC 在 SSR 和客户端调用的缓存陷阱
tRPC 和 React Query 配合虽然舒服,但也意味着你要理解 React Query 的缓存机制,否则会产生非常奇怪的问题。我在开发列表页时发现:当用户从首页进入详情页,再返回首页时,首页数据虽然被缓存了,但分页参数一旦变化,它会自动重新请求新数据,旧数据也不会被清理。默认配置下,React Query 的gcTime是 5 分钟,如果你在短时间内在列表和详情之间多次切换,内存里会积攒很多数据,还会触发较多的重复请求。为了缓解这个问题,我自定义了 tRPC 的defaultOptions:
const trpcClient = trpc.createClient({ links: [ httpBatchLink({ url: "/api/trpc" }), ], });这里的httpBatchLink是很关键的一招,它能把同一时间窗口内多个调用合并成一个 HTTP 请求。比如详情页同时请求 snippet、user、tags 三个 query,浏览器只会发一个请求,显著降低了网络往返。缓存时间方面,我把列表数据设为staleTime: 30_000,详情页设为staleTime: 120_000,不改动的话,用户看到的数据平均延迟最多两分钟,体验完全可以接受。
当然也有极端场景:用户 A 在自己主页删除了一个片段,但详情页的缓存还是会残留。我个人的解决方案是:删除成功后调用trpc.useUtils().snippet.invalidate(),强制让相关查询失效。只要你在 mutation 之后记得 invalidate 对应的 query key,就不会出现这种逻辑不一致。
4.3 Prisma 连接数与冷启动问题处理
冷启动的另一个角度的坑是 Prisma 在 Vercel 上默认会做一次 schema 校验,这个校验会增加几百毫秒的启动时间。如果你用的是服务端渲染页面,会比较明显。我后来直接把previewFeatures和校验开关关了:
generator client { provider = "prisma-client-js" binaryTargets = ["native"] }还有一个经验是数据库连接池的数量。Supabase 免费版的连接池默认只能支持 10 个并发连接,如果你的 Vercel 函数同时收到一批查询,尤其是像 tRPC 批量请求那种,就很容易把连接池打满。我做了两层优化:第一层,在 Prisma 客户端的datasource url里配上pgbouncer=true和connection_limit=3,让每个函数实例最多占一个连接;第二层,列表页和详情页全部加上 Redis 缓存自动降级,当查询失败或者超时的时候,直接走预渲染的静态快照。上线之后,我没有再收到过量连接导致的错误报告。如果你也要做类似的部署,我建议一定要在本地先模拟一下几十个请求同时打进来的场景,用wrk或者k6跑一下,别等到线上炸了才去翻日志。
5. 我的最终体会与几个超有用的扩展方向
整个 t3code 做下来,我最大的体会是端到端类型安全带来的安全感远超想象。一个人维护全栈项目时,最怕的不是写不出功能,而是数据流在某个地方发生悄然失真。tRPC 把前后端契约固化在 TypeScript 类型上,我重构字段时几乎不需要去前端到处找哪里访问到了被改名的属性。第二个体会是:不要为了追求所谓的主流架构而过度设计。最开始我也曾想上一套微服务,用 Docker 把 Redis、Postgres、Nginx 全部编排起来,但后来我把最核心的部分一直控制在 Vercel + Supabase + Upstash 这个极简组合上,结果证明稳定性非常高。
如果你也想搭一个类似的代码分享工具,我还想给你三个扩展建议。
第一,把代码片段模块封装成一个可嵌套的组件,比如你的片段内部还可以引用另一个片段,这样就能做出“代码拼接”的玩法。第二,可以做基于标签的隐式关系推荐,因为我已经给每个 snippet 挂上了标签,之后只需要用余弦相似度计算标签集合之间的距离,就能实现轻量的关联推荐。第三,给用户主页增加一个“代码足迹”时间线,记录用户发布的代码和点赞过的代码,这个功能虽然不复杂,但社区归属感一下就上来了。
最后分享一个小技巧:在提交 tRPC mutation 的时候,我习惯统一把错误信息通过errorFormatter转换成语义化文案。前端拿到formattedMessage直接展示,就不会让用户看到“Request failed with status code 500”这种既吓人又没用的话。这个技巧看起来很小,但对于一个面向开发者群体的工具来说,每一个细节都在影响口碑。t3code 目前还在快速迭代中,我最近正在琢磨怎样把评论功能做成推流式实时更新,让分享者们真正交流起来。如果你也在做一个轻量工具型产品,希望这篇记录能帮你少踩几个坑。