t3code 是我最近花了两个星期业余时间做完、并且还在持续维护的一个代码片段管理工具,名字就直接取自 T3 Stack,算是用这套技术栈练手时顺手落地的真实项目。它不是要替代 GitHub Gist,也没想去跟 Notion 抢笔记市场。它要解决的事情很小但很烦:代码写多了之后,那些曾经写过、调试过、最终证明有效的代码碎片,散落在本地 Markdown 文件、VS Code 片段配置、聊天记录和命令行历史里,等你想用的时候死活找不到。
我举一个真实场景:上周我在写一个自动化脚本时需要一段“用 Rust 读取环境变量并优雅处理缺失值”的代码,我很确定自己两个月前在某个项目里写过一段特别满意的版本,但硬是翻了三个仓库、翻遍聊天记录才在一个旧备份里找到。这种经历不是一次两次。我也试过 Gist,私有仓库的体验总感觉是为了同步而同步,打开网页、新建、粘贴、起名、保存,一套流程下来还不如直接翻老项目来得快;本地 Markdown 仓库靠 grep 搜倒是能搜到,但代码没有高亮,标签、语言分类这些东西完全不存在,文件一多就是一团乱麻。
所以 t3code 的定位就是一个自部署的、轻量的“代码片段第二大脑”。核心流程只有三条:快速捕获——复制一段代码、填标题和语言、打上两三个标签,完事;快速检索——打开网页全局搜索,支持按语言、按标签过滤;干净展示——代码有语法高亮,详情页一键复制。仅此而已。没有团队协作、没有评论、没有复杂工作流,第一版全都不做。它本质上是在解决一个信息架构问题:代码片段的捕获、索引、检索,这三件事做好,工具的价值就到位了。
如果你也经常觉得“这段代码我写过但找不到”,或者你想看看 T3 Stack 这条技术路线在一个真实项目里到底怎么落地、有哪些坑,这篇记录应该对你有用。技术栈是 Next.js App Router + TypeScript + tRPC + Prisma + Tailwind CSS,数据库用 PostgreSQL,部署在 Vercel。下面我从选型逻辑、数据模型、核心实现、部署成本和踩坑过程几个角度逐一讲清楚。
1. 技术栈选型:T3 组合到底替我省了什么
1.1 create-t3-app 给了一整套规范起点
项目不是从零搭的,而是直接用 create-t3-app 初始化。选它不是因为命令行快,而是因为它给的目录约定和分层方式,比我自己随手搭要干净得多。初始化之后拿到的是一个标准的 T3 Stack 工程结构:
src/app/:Next.js App Router 的页面与布局src/server/api/routers/:tRPC 的服务端路由,每个文件对应一组接口src/server/db.ts:Prisma Client 的单例实例src/trpc/:服务端和客户端调用链路的封装
这个结构最大的价值在于“约定优于配置”。个人项目和团队项目最怕的就是每个人接口风格不一样,有人喜欢在页面里直接写 fetch,有人喜欢单独抽 service 层。T3 的约定把所有数据访问收敛到 tRPC router 里,页面组件不直接碰数据库,也不直接拼 fetch URL。我关掉了 NextAuth 选项,因为第一版不需要用户体系,连登录都没有,先把“本地单机自用工具”这个闭环跑通。说实话,这一步如果选错了分层,后面所有功能都会往越来越乱的方向走。
1.2 为什么 API 层选 tRPC 而不是 REST 或 GraphQL
先说 REST。它不是不好,只是对于一个纯全栈 TypeScript 项目来说太啰嗦。写一个接口要定 URL、定请求方法、定参数格式、定错误码,前端要用fetch拼 URL,拿到响应还要再写类型断言。小项目里这些东西全是心智负担,而且是重复劳动。
GraphQL 是另外一个极端,能力强但体系重:Schema、Resolver、Client 缓存、代码生成,一套下来光基建就要花掉两三天。对于“一个用户自己用的片段管理工具”,这显然是过度设计。
tRPC 的做法就非常直接——服务端定义一个 router,里面每个 procedure 就是一个接口,前端调用的时候看起来像调用本地函数:
// 服务端定义 export const snippetRouter = createTRPCRouter({ byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findUnique({ where: { id: input.id }, include: { tags: true }, }); }), }); // 前端调用 const { data } = api.snippet.byId.useQuery({ id });类型从服务端自动推导到客户端,字段写错了编译期就会报错。不需要维护 OpenAPI 文档,不需要手写类型定义,更不需要在联调的时候对字段名。对于两三个人用的项目,这种“接口即函数”的体验不只是方便,而是能直接消灭一整个类别的 bug——类型不一致。我在实际使用里最深的感觉是:改服务端返回结构的时候,前端所有用到这个接口的地方会一起报错,逼着我把所有受影响的地方一次改完,而不是上线之后才发现某个页面字段对不上。
1.3 Prisma:数据模型即事实源
数据库层直接选 Prisma,理由是它的开发闭环特别顺:用 Schema 定义模型,跑prisma migrate生成迁移文件,跑prisma generate生成类型。这三步做完,整个项目从数据库到后端到前端全部共享同一套类型,和 tRPC 的类型推导正好配合。
我用过 TypeORM 也用过 Sequelize,它们不是说不好,而是日常开发里需要手动同步的地方太多。Prisma 的 Schema 写得非常直白,比如一个片段和标签的多对多关系就是两行:
model Snippet { id String @id @default(cuid()) title String code String language String tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] }Prisma 根据这个 Schema 生成的 client 自带snippet.create、snippet.findMany、tag.upsert等方法,方法的参数和返回值在 TypeScript 里都定义得清清楚楚。一个没有专门后端经验的读者,只要会写 TypeScript,看一遍 Schema 就能上手写数据访问代码,这也是我敢在个人项目里快速推进的原因。
2. 核心数据流:从创建片段到三秒搜到
2.1 数据模型设计:为什么用标签而不是文件夹
第一版数据模型只有三张表,除了上面说的 Snippet 和 Tag,再加上一张隐含的中间表(Prisma 自动维护的_SnippetToTag),没有用户表、没有收藏表、没有团队表。我特意砍掉了“文件夹”这种树状结构,全部用标签做扁平化分类。
原因很简单:一片代码天然属于多个分类,比如“React”和“性能优化”和“TypeScript 类型”,如果用文件夹就得复制一份或者做链接,用标签就能多对多关联,将来搜索和过滤都可以自由组合。这种扁平结构的另一个好处是创建成本低,打标签是一个轻量动作,而把代码拖进某个文件夹是一个重量动作,前者更容易养成习惯。
t3code 的创建表单也刻意做得很短:标题、语言、代码、标签(逗号分隔)、可选备注。上传后立刻显示在列表顶部。我想让“记录一段代码”的成本降到最低,这样工具才有可能真正被用起来。
2.2 创建片段的后端事务
创建片段时有一个细节值得单独讲:标签怎么处理。用户输入的标签可能是新的也可能是已有的,而 Tag 表有@unique(name)约束,所以不能直接create,否则会撞唯一约束报错。正确做法是upsert,然后在片段和标签之间建关联。
这里我最开始直接写了循环加嵌套create,后来发现一个 session 里如果有多个用户的并发写入就可能出问题,还会产生重复标签。改成 Prisma 的$transaction后,创建逻辑看起来是这样:
create: publicProcedure .input( z.object({ title: z.string().min(1), code: z.string().min(1), language: z.string().min(1), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.$transaction(async (tx) => { const snippet = await tx.snippet.create({ data: { title: input.title, code: input.code, language: input.language, tags: { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }, }, include: { tags: true }, }); return snippet; }); });connectOrCreate的意思是:如果标签已存在就直接关联,不存在就创建后再关联。加上事务之后,中间任何一个标签创建失败,整个片段都不会落库,不存在“片段建好了但标签没挂上”的脏数据。这个小细节其实是大坑,我后面踩坑章节会再展开。
2.3 搜索和过滤:先够用,再谈完美
t3code 的搜索在最开始不打算上 Postgres 全文索引,因为数据量就几百条,一个contains完全够用。但代码要写成可以平滑升级的样子。我现在的搜索是这样实现的:
search: publicProcedure .input( z.object({ q: z.string().optional(), language: z.string().optional(), tag: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = { ...(input.q ? { OR: [ { title: { contains: input.q, mode: "insensitive" } }, { code: { contains: input.q, mode: "insensitive" } }, ], } : {}), ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), }; const snippets = await ctx.db.snippet.findMany({ where, orderBy: { createdAt: "desc" }, take: 20, include: { tags: true }, }); const total = await ctx.db.snippet.count({ where }); return { items: snippets, total }; });注意mode: "insensitive"是做大小写不敏感匹配的关键,不写的话React和react搜出来不是一回事。数据库层面它走的是ILIKE(PostgreSQL),性能在小数据量下完全没问题。分页我用了最简单的take: 20搭配total计数,没有上 cursor,因为列表本身按时间倒序,用户翻到很后面的概率很低。
如果哪天真到了几千条片段、每次搜索都要全表扫一遍的地步,升级路线也很清楚:给 PostgreSQL 加pg_trgm扩展做 trigram 索引,或者直接换成 Postgres full-text search。到时候 Prisma 的 raw query 能支持,业务代码不用大改。这种“现在够用、未来可升级”的写法是我比较推荐的——一开始就上全文索引反而会让小项目变得复杂。
2.4 语法高亮:选 shiki 并在服务端完成
代码片段的展示质量决定了工具值不值得用。高亮方案对比了半天,最后选了 shiki,它是 VS Code 同款的 TextMate 语法高亮引擎,支持任意 VS Code 主题,渲染效果和编辑器里一模一样。相比 Prism 和 highlight.js,shiki 的缺点是包体积大,但优势是颜色准确、语言覆盖全、不会出现“高亮了个寂寞”的情况。
更关键的设计决定是:高亮在服务端完成,而不是前端运行时做。因为片段是静态内容,存进数据库之后代码字符串不会变,完全可以在每次查询时用 shiki 生成带颜色标记的 HTML,再把 HTML 返回给前端展示。这样前端不需要加载语法高亮库,首屏渲染速度也快。
代价是服务端每次查询都要跑一遍高亮,小项目不在乎这点 CPU,但过度重复渲染我也觉得不优雅。更好的做法是:在创建或编辑片段时把 shiki 高亮后的 HTML 直接cache在记录里,查询时如果有缓存直接用缓存,没有才现渲染。我在Snippet表里加了一个highlightedHtml字段,这个字段对用户透明,只在后端内部维护。
3. 前端交互里那些容易被忽略的细节
3.1 CodeMirror 6 和 Monaco Editor 的选择
编辑代码片段需要编辑器,但这里有个大坑:Monaco Editor 是 VS Code 的编辑器组件,功能确实强,但体积巨大,一个编辑器打包下来少说 2MB 起步,对 Next.js 的客户端 bundle 是重负。我只是要一个“能写代码、能高亮、能撤销”的编辑框,不需要断点调试、不需要终端、不需要源码管理集成,那 Monaco 就是典型的杀鸡用牛刀。
最后选了 CodeMirror 6。它的核心思路是模块化,基础编辑器很小,语法高亮、自动补全、快捷键这些功能按需加载。在 t3code 里我只加载了@codemirror/lang-*的几个语言包、自动补全和行号,最终增量体积控制在 100KB 左右,加载速度快了两个数量级。
CodeMirror 6 的 API 和 5 完全不一样,刚开始用确实有点迷,但官方文档的“basic setup”示例可以直接照抄,很快就能跑起来。
3.2 列表页、详情页与全局命令面板
页面结构我做了两个主要路由:/是列表页,右侧是片段列表;/snippet/[id]是详情编辑页。列表页自带搜索框和语言/标签过滤下拉,这已经满足八成使用场景。真正提升体验的是两个细节:
第一个是全局命令面板。我集成了cmdk这个命令菜单库,按Cmd/Ctrl + K弹出一个搜索面板,输入关键字直接模糊匹配标题和标签,回车跳转到详情页。用过 Raycast 的人应该秒懂——这个交互比刷新列表再滚动查找高效太多,几乎变成我打开 t3code 的第一入口。
第二个是复制交互。详情页代码块的右上角固定一个“复制”按钮,点击后按钮文案短暂变成“已复制”,配合一个极简的 toast 提示。这个交互本身不稀奇,但很影响日常手感。我加了一个小细节:如果选中了代码区域的部分文本,复制按钮就复制选中的内容而不是整段代码,这个更适合“只想抄三行配置”的场景。
3.3 shiki 高亮后的 XSS 边界
讲一个不安全做法:shiki 返回的 HTML 是经过 token 化并插入 span 标签的,理论上它会对<、>、&做转义,所以直接dangerouslySetInnerHTML在小范围私用工具里风险不大。但只要是面向多用户、将来要开放分享的工具,这个风险就不可接受了。
我的做法是对高亮结果做二次校验:后端有一个函数会检查返回的 HTML 里是否包含<script、onerror=、javascript:这类敏感片段,有任何可疑内容就丢弃高亮结果,退回纯文本展示。同时代码原文在前端永远走文本插值渲染,不会以 HTML 形式出现。这样即使某一种语言的 grammar 有漏洞,攻击面也被封死了。
另外,shiki 在服务端渲染高亮的时候要小心“语言不存在”的情况。用户手填了一个不存在的语言标识,shiki 会直接抛错。我在后端做了一个语言列表白名单,所有输入先 check 一遍,不是白名单里的就按纯文本处理。这个错误我在开发时踩了不止一次,每次都是编辑器页面白屏,排查半天才发现是语言标识写错了。
4. 部署上线与 Serverless 环境的现实问题
4.1 Vercel + Neon PostgreSQL 的组合
部署选择非常保守:前端和后端都跑在 Vercel 的 Hobby 计划(免费),数据库用 Neon 的 Serverless PostgreSQL(免费额度足够个人项目用几个月)。选 Vercel 是因为 Next.js 应用部署零配置,push 代码自动构建;选 Neon 是因为它提供无服务器 PostgreSQL,连接串直接能用于 Prisma,不需要自己维护数据库服务器。
这套组合的优点是启动成本几乎为零,缺点也很明确:Serverless 函数的冷启动和数据库连接池管理需要额外注意。我第一次部署完兴冲冲打开页面,结果第一次访问等了 5 秒多——数据库连接是一次性新建的,没有复用。下面细说怎么处理。
4.2 Serverless 环境下的 Prisma 连接池
Prisma 在传统 Node.js 服务里默认会维护一个连接池,但在 Vercel 的 Serverless 环境里,函数实例被频繁创建和销毁,每次冷启动都重新建连接,慢得离谱。标准解法是在DATABASE_URL之外再配置一个directUrl,让 Prisma 在迁移时用直连,运行时走连接池代理。
Neon 提供的DATABASE_URL本身就是一种支持连接池的地址(带-pooler后缀),我把它配置为 Prisma 的连接串,然后在prisma/schema.prisma里加:
datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }DATABASE_URL指向 Neon 的连接池代理,DIRECT_URL指向真实的数据库地址,这样 Prisma Client 在 Serverless 运行时通过连接池复用连接,冷启动时间从 5 秒优化到 1 秒以内。这一步如果不做,Serverless 部署的性能瓶颈会非常明显。
4.3 查询性能、分页与缓存策略
有了连接池之后,查询本身的性能瓶颈主要在列表页。我的列表接口每次最多返回 20 条,加上标签的预加载(include: { tags: true }),单次查询在几十毫秒内。前端用 tRPC + React Query 做数据请求,React Query 默认会缓存查询结果,同参数请求在 30 秒内不会重复发请求,这天然削掉了一部分重复加载。
真正花时间优化的是“创建片段后列表立刻更新”这个体验。tRPC 的 mutation 成功后,前端手动触发一次列表的invalidate,React Query 会重新拉取列表数据。这里有一个 Next.js App Router 的坑:如果页面是用 fetch 做静态渲染或带缓存,那么即使后端数据变了,页面也不会自动刷新。tRPC client 绕过了 fetch 缓存,但如果你在某个 Server Component 里直接用了 fetch,就要小心缓存问题。我的做法是列表页全部走客户端渲染,服务端只吐初始数据,后续更新全靠 React Query,这个取舍保存了大量调试时间。
4.4 真实成本评估
这个项目到目前为止的实际开销是零。Vercel Hobby 计划每个月有 100GB 带宽和 100 小时构建时间,个人项目远远用不完;Neon 免费额度 0.5GB 存储足够放几万条代码片段。如果你数据量更大,Neon 付费版也就一杯咖啡的钱。最重要的是,这套免费组合的稳定性我实测下来是够用的,至少没有出现过一次数据丢失或服务宕机。
5. 踩坑记录:类型、事务、缓存三个拦路虎
5.1 改完 Prisma Schema 前端死活查不到新字段
这是一个非常容易忽视的步骤:修改schema.prisma后必须运行npx prisma generate,否则 Prisma Client 的类型和运行时都不会包含新增字段。我一开始给 Snippet 加了description字段,改了 Schema、跑了迁移,结果前端用api.snippet.byId.useQuery的返回值里怎么都取不到description,编译也没报错——因为类型还是旧的。
我花了不少时间检查代码、重启 dev server,最后猛然想起prisma generate没跑。这种问题在工程化项目里像笑话一样,但确实容易卡新手一下午。我现在把prisma migrate dev和prisma generate绑成了一个 npm script,跑一次迁移自动生成新类型,再也不会忘记。
5.2 tRPC 类型不同步的迷惑瞬间
tRPC 的类型是从服务端 router 推导出来的,当你修改了某个 router 的输入或输出结构,理论上前端保存文件后类型会自动更新。但我在实践里遇到过一次:服务端改了返回结构,前端调用处报了“属性不存在”的错误,于是我去改前端,改完保存,错误还在。
排了很久发现是 tRPC 的类型推导依赖 TypeScript 的 project reference,因为我把前后端放在了同一个 monorepo 里,编辑器有时候没能及时刷新类型缓存。最简单可靠的办法是:改完 router 后把 dev server 重启一次,或者直接在 IDE 里触发一次 TypeScript Restart Server。这个操作听起来很低级,但救过我三四次,到现在已经成为肌肉记忆。
5.3 Prisma 嵌套写入:connect 和 create 的区别
前面创建接口里用了connectOrCreate,如果你把这里写成create,那每创建一个片段时,Prisma 都会尝试创建同名标签,然后被唯一约束暴打一顿。如果你写成connect,那遇到新标签又会直接报错“记录不存在”。connectOrCreate就是为这种场景设计的——按唯一键查找,找不到就建,找到就关联。
另一个关联坑是:Prisma 的嵌套写入在多对多关系里不能直接既create又connect同一个模型,这需要用connectOrCreate逐条处理。我还试过先手动查一遍标签名,再决定 create 还是 connect,不仅多了一次查询,还会在多用户并发时出问题。用对connectOrCreate之后,代码少了一大截,正确性也提升了。
5.4 Next.js App Router 缓存引发的“数据改了页面不刷新”
这个坑是最冤枉的。我在某个 Server Component 里用fetch获取了片段数量统计,为了省事没加任何 cache 策略,结果发现数据更新后页面上的数字怎么都不变。排查到最后发现 Next.js 15 里fetch默认开启了缓存,页面在构建时就把旧数据缓存下来了。
解决方式是在获取统计的请求里显式加cache: "no-store",或者调用revalidatePath在 mutation 成功后主动让相关路由重新渲染。但我后来把统计显示也改成客户端用 tRPC 读取之后,这个问题就彻底消失了——因为 tRPC 的 query 走的是 React Query 运行时请求,不经过 Next.js 的数据缓存层。这里的原则很明确:在 App Router 里,能别在 Server Component 里用的数据就别用,尤其是那些需要频繁变化的数据,全都丢给客户端查询处理,省心得多。
5.5 冷启动优化里的小技巧
最后分享一个优化细节。即使配好了连接池,Serverless 函数的冷启动仍然不可避免。我在 Neon 控制台开启了一个小功能:定期给数据库发一个轻量 ping 请求,让连接池保持活跃。同时在 Vercel 的 cron 配置里加了一个每分钟的定时任务访问 t3code 的首页,这会让函数实例在用户真正打开前先预热。实测下来,之前第一次打开要等 5 秒,现在打开基本是瞬开,体感提升非常明显。
这类技巧对个人工具项目特别划算,因为你的用户主要就是自己,等待体验差一点都会减少使用频率。
做到这里,t3code 已经稳定跑了一小段时间,我的日常开发流程变化很明显:再遇到可复用的代码,随手就存进去了,写新项目时用全局命令面板一搜,三秒钟把旧代码调出来改改用。每天结束的时候扫一眼当天存了什么,也算一种复盘。下一步我想给它加一个类似“分享链接”的功能,把某一段代码生成一个只读的公开链接,方便丢给朋友或另一个工作环境使用。如果你也要做类似的东西,我的建议是保持 MVP 足够枯燥:先把捕获、搜索、展示三件事做透,其他所有花哨功能都放第二版。