2026/10/26 17:16:06

T3技术栈实战:自托管代码片段管理工具t3code全栈开发复盘

T3技术栈实战:自托管代码片段管理工具t3code全栈开发复盘 t3code 这个项目是我近期花了一个多月做的自托管代码片段管理工具。起因很单纯我电脑里散落着几百个常用代码片段有的在 Notes、有的在 GitHub Gist、还有一部分躺在微信收藏里想找的时候永远找不到找到了格式也乱得一塌糊涂。与其继续将就我干脆基于 T3 技术栈从头写了一个属于自己的代码片段平台顺便把整个选型、实现和排错过程记录了下来。这篇博文就是完整的项目复盘适合想入门全栈开发、或者打算自建工具替代第三方服务的读者参考里面每一段配置和代码都是实际跑过的。1. t3code 的定位与 T3 技术栈选型逻辑1.1 为什么自建而不是继续用现成工具一开始我确实想过直接用开源的 hn 或 massCode 这类本地工具但对比下来发现几个无法忽略的痛点。第一我需要多端同步。本地软件方案在单机上体验确实好但换电脑、临时用别的设备时数据同步就成了麻烦事。第二我需要的不是单纯的收藏功能还要能按语言筛选、按标签聚合、支持团队协作分享。很多开源工具要么只做单机版要么同步服务还要额外部署。第三是我个人的怪癖我希望整个工具的界面和交互完全按自己的习惯调整比如快捷键、列表密度、代码高亮主题甚至未来想加一个从截图中识别代码的实验功能这些用别人的产品改起来成本太高。所以结论很明确自己写一个 Web 版工具部署在自己可控的服务器上数据放在自己的数据库里前端按照自己的审美来做。项目名就定为 t3codeT3 代表下面的技术栈code 代表服务的对象。1.2 T3 栈到底是什么为什么是它T3 Stack 目前的主流搭配是 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS同时可以按需加入 NextAuth.js。这个组合最核心的价值不是某个单一框架而是端到端类型安全从前端组件到 API 路由再到数据库查询类型定义一路打通几乎没有断裂点。我用这套组合还有一个现实原因t3code 是一个 CRUD 比例极高的应用核心动作无非是增删改查代码片段、维护标签、处理用户登录。这个场景下写一堆 REST API 和手动的接口文档反而是一种负担。tRPC 的思路是把服务端函数直接暴露给客户端调用类型完全继承改一个字段名前端立刻就能检查出来联调成本大幅下降。1.3 在选型阶段我做过的一次关键对比在正式进入开发前我在Next.js 全栈 Prisma和单独前后端分离两种方案之间犹豫过一阵。如果走 NEST Vue 或者其他前后端分离结构项目会多出双倍的基础代码比如接口封装、类型定义、鉴权中间件、跨域配置。而 t3code 本身没有复杂的业务模型不需要独立扩展 API 给第三方使用架构分层越简单反而越容易维护。我最后选的方案是Next.js App Router 作为整体框架tRPC 作为 API 层Prisma 作为 ORM数据库先用 PostgreSQL 起步认证用 NextAuth.js 的 Credentials GitHub 两种方式。Tailwind 负责样式这个组合能最大化减少胶水代码。方案接口层类型安全后端代码量适合场景前后端分离REST需要手写类型多需单独部署多端对外提供 APINext.js tRPCRPC自动继承少单仓库内部工具、中小业务Next.js Server Actions函数有限最少简单表单类应用t3code 选择了中间那行原因是我既希望逻辑代码相对集中又不想被 Server Actions 在复杂度膨胀时的限制卡住。2. 初始化项目与数据库建模的实操细节2.1 create-t3-app 初始化与环境变量初始化部分强烈建议直接用官方脚手架别自己拼配置否则第一天就会耗在版本兼容上。npm create t3-applatest在交互式提示里我勾选了 Next.js、TypeScript、tRPC、Prisma、Tailwind、NextAuth.js。目录结构生成之后关键的几个文件分别是src/server/api/trpc.ts、prisma/schema.prisma、src/env.mjs。特别要提醒的是src/env.mjs这里做了环境变量校验如果数据库连接串或者 NextAuth 密钥缺失服务启动时会直接报错不会给你蒙混过关的机会。我最初为了方便把数据库连接串写成了本地 SQLite 的格式结果生产环境部署时验证失败。这个教训就是环境变量的格式必须从一开始就和目标环境一致。本机开发可以用 SQLite但生产环境我换成了 PostgreSQL所以.env和.env.production里面的 URL 要分别维护。2.2 Prisma 数据模型设计代码片段的核心表t3code 里最核心的数据结构是代码片段Snippet和标签Tag以及它们之间的多对多关系。我在设计时有意分成了三张表而不是在 Snippet 里直接存一个数组字段这样后续做标签统计和筛选会舒服很多。下面是经过多次调整后的简化版本model User { id String id default(cuid()) name String? email String? unique image String? snippets Snippet[] snippetTags SnippetTag[] accounts Account[] sessions Session[] } model Snippet { id String id default(cuid()) title String content String db.Text language String default(plaintext) isPublic Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) tags SnippetTag[] } model Tag { id String id default(cuid()) name String unique tags SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) id([snippetId, tagId]) }有几个设计点需要展开说一下。首先是isPublic字段它决定了片段是否公开。这个字段未来会配合分享链接功能私有片段只有登录用户自己能看公开片段则可以生成只读链接给团队伙伴。其次是中间表SnippetTag的两个onDelete: Cascade。如果不加这两个级联删除一个标签或者片段时Prisma 会直接报外键约束错误需要手动先清理关联记录。加上之后删除操作就省心很多。最后是id([snippetId, tagId])这种复合主键写法。它既支持两个字段的组合唯一又让多对多查询走索引性能比单独再加一个自增 id 好而且不需要额外维护无意义的字段。2.3 数据库选型与迁移流程开发阶段我直接用 SQLite因为零配置、文件即数据库跑测试非常快。但上线之前我换成了 PostgreSQL主要考虑到生产环境需要并发写入和更好的索引支持。切换数据库时有一个顺序问题先改schema.prisma里的 provider再修改环境变量然后重新执行迁移。你要是先迁移后改连接串Prisma 会提示你生成的迁移文件和应用到数据库的状态不一致排查起来很别扭。npx prisma migrate dev --name init npx prisma generate迁移文件生成之后必须提交到 Git。多人协作时别人拉代码后直接执行npx prisma migrate dev就能把数据库结构同步过来依赖数据库直接导出的操作反而是不稳定的。3. 核心功能实现tRPC 路由设计与前后端协作3.1 tRPC 实例、Context 与鉴权tRPC 的起点是创建实例和 Context。Context 本质上是在每次请求时注入的环境对象我在这里把数据库连接和当前用户会话塞进去后续所有路由都能直接用。// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { getServerAuthSession } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, headers: opts.headers, }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, }); export const createTRPCRouter t.router; export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这条中间件是整站权限的基础。所有需要登录才能操作的接口统一用protectedProcedure而不是publicProcedure这样前端哪怕不小心把请求发出去了服务端也会拦截。3.2 片段路由查询、创建、更新与删除t3code 的片段路由是整个应用的主干我的代码里包含五个主要方法。all负责列出当前用户的所有片段byId负责查询单条create和update负责写入delete负责删除。import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const snippetRouter createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { updatedAt: desc }, include: { tags: { include: { tag: true } }, }, }); }), byId: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.db.snippet.findFirst({ where: { id: input.id, OR: [ { authorId: ctx.session.user.id }, { isPublic: true }, ], }, include: { tags: { include: { tag: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default(plaintext), isPublic: z.boolean().default(false), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, }, }); await connectTags(ctx.db, snippet.id, input.tags); return snippet; }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const existing await ctx.db.snippet.findUnique({ where: { id: input.id }, }); if (!existing || existing.authorId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.snippet.delete({ where: { id: input.id } }); }), });这里有一个必须注意的安全细节delete不只是判断内容存在还要判断authorId是否等于当前用户。如果不做属主校验只要知道 id 就能删别人的数据这在真实项目里是不可接受的。3.3 多对多标签的处理与 upsert 技巧标签处理是代码片段管理工具里最容易写出 bug 的地方。标签名不能重复但又可能是用户输入的新词我的处理方式是先遍历输入对每个标签执行 upsert拿到真实 id 后再创建关联记录。async function connectTags(db: PrismaClient, snippetId: string, tagNames: string[]) { for (const name of tagNames) { const tag await db.tag.upsert({ where: { name }, update: {}, create: { name }, }); await db.snippetTag.create({ data: { snippetId, tagId: tag.id, }, }); } }upsert这个 API 是我在整个项目里用起来最顺手的之一。它把查不存在就创建存在就更新两个操作合并成一次数据库请求避免了先查询再判断的竞态问题。唯一要注意的是where里的字段必须有唯一约束这里我用的是name的unique。3.4 客户端调用与 React Query 的配合tRPC 客户端通常配合 React Query 使用它的useQuery和useMutation函数内部封装了数据请求、缓存、重试和失效逻辑。我实际用起来最舒服的是修改数据后自动失效这一段const utils api.useUtils(); const createSnippet api.snippet.create.useMutation({ onSuccess: async () { await utils.snippet.all.invalidate(); }, });当创建新片段成功后snippet.all.invalidate()会标记缓存过期并重新拉取列表。这样做的好处是页面数据永远是最新的不需要手动 setState 去更新列表。第一次用的人可能会不理解为什么这么简单但当你习惯了改数据自动刷新这个模式后就再也回不去手写 loading 状态的年代了。4. 代码编辑体验优化与前后端协作细节4.1 为什么选 CodeMirror 而不是 Monaco代码片段管理工具里代码编辑器的体验决定用户的留存。我对比过 Monaco Editor 和 CodeMirror最后选的是 CodeMirror 6。Monaco 编程体验确实好自动补全、错误提示、调试断点功能强大到没话说。但对于一个纯片段管理工具来说它太重了打包体积动辄几 MB首屏加载有明显卡顿。CodeMirror 6 是模块化的可以只引入高亮、自动补全、折叠这几个我需要的模块体积控制在很小的范围内和 Tailwind 的配合也更好。代码高亮部分我用了codemirror/lang-sql、codemirror/lang-python、codemirror/lang-rust等语言包再加上codemirror/legacy-modes的meta注册机制把常见几十种语言的识别都纳入了进来。你不需要为每个语言写独立的解析逻辑只需要在语言包里注册对应的LanguageSupport即可。4.2 从服务端渲染到客户端渲染的易错点这是我在 t3code 上踩得比较久的一个坑。CodeMirror 这类编辑器在服务端无法渲染因为它依赖浏览器 DOM API。Next.js App Router 默认所有组件都是服务端组件直接在页面里 import 编辑器组件启动时就会报找不到全局 window 的错误。解决方法不复杂但必须意识到把编辑器组件单独抽出来标记为客户端组件然后把渲染过程放在端口挂载之后。use client; import CodeMirror from uiw/react-codemirror; import { useEffect, useState } from react; export function CodeEditor({ value, onChange }: { value: string; onChange: (v: string) void }) { const [mounted, setMounted] useState(false); useEffect(() { setMounted(true); }, []); if (!mounted) return div classNameh-[500px] w-full animate-pulse bg-slate-100 /; return ( CodeMirror value{value} height500px onChange{onChange} / ); }要点有两层。第一层是use client这个指令告诉 Next.js 该组件在客户端渲染第二层是mounted状态它确保在 SEO 和首屏时输出一个占位等浏览器环境就绪后再真正挂载 CodeMirror。一开始我偷懒没做 mounted 判断结果每次刷新页面编辑器区域都会闪一下白屏这个体验是绝对不能接受的。4.3 列表页的筛选与搜索策略列表页支持按语言筛选、按标签筛选、关键词搜索。这里我坚持了一个原则筛选动作由前端即时完成关键词搜索走服务端数据库查询。原因很简单一个用户的片段总量通常在一千条以内前端内存中过滤完全够用没必要为了这种量级频繁请求数据库。但关键词搜索是模糊匹配写成 SQL 才能用到数据库索引否则前端遍历全部内容会随着数据量增加越来越卡。我的实现路径是先把所有片段一次性拉下来存进 React Query 缓存前端做语言和标签的过滤搜索关键词的时候再调用专门的搜索接口。页面切换筛选条件时是毫秒级响应体验非常好。5. 部署与性能优化从本地到线上的一次完整流程5.1 部署架构与环境变量管理t3code 的部署我采用的是前端和 API 部署在托管平台数据库使用托管的 PostgreSQL。整体架构非常常规没有复杂的服务器运维负担。部署时最容易翻车的就是环境变量配置。我在本地开发时.env里会写本机的 PostgreSQL URL而生产环境必须换成线上数据库地址。更关键的是 NextAuth 密钥还有 OAuth 应用的回调地址。GitHub OAuth 应用在本地开发时回调地址是http://localhost:3000/api/auth/callback/github线上就是https://你的域名/api/auth/callback/github这个必须在 GitHub 的 OAuth 设置里配置两套否则线上登录永远报 redirect_uri 不匹配的错误。5.2 数据库迁移与连接池调整迁移到生产数据库时我先在本地对线上数据库执行了一次prisma migrate deploy这条命令只应用已有的迁移文件不会重新生成。它和migrate dev的定位完全不同dev适合迭代中频繁改动模型deploy适合生产环境的不可逆应用。连接池方面Prisma 默认的连接数对托管型 PostgreSQL 来说有时会超限。托管的免费或入门套餐连接上限通常是几十个并发进来时 Prisma 每个连接都会占一个配额多了会直接报 too many connections。我的解决办法是在数据库连接串后面加上连接池参数然后把 Prisma 引擎改为连接池模式同时把connection_limit调低到合理范围。5.3 首屏性能优化实测上线后我用 Lighthouse 测了一轮首屏有好几个优化空间。图片资源和代码高亮样式是主要瓶颈。我采用了几项优化首屏只展示片段列表的标题、语言和更新时间不加载编辑器编辑器在进入详情页时才动态加载。代码高亮的语言包按需加载把常用语言打进主包冷门语言通过动态导入处理。列表页用 React Query 的staleTime设置了 5 分钟的缓存窗口5 分钟内用户反复进出列表页不会重复请求接口。这几项改完首屏加载时间从原来的接近 3 秒降到 1 秒左右体感变化非常明显。5.4 遇到的一个真实线上问题上线后有一个用户我朋友反馈持续滚动列表时偶尔出现同一片段重复显示的问题。排查了很久最后定位到是 React Query 的分页缓存和前端筛选逻辑冲突导致的筛选条件变化后旧的查询结果没有完全清空就叠加了新的分页数据。修复方式是在筛选条件改变时用更严格的 query key 隔离缓存并且在组件卸载时执行一次缓存清理。这个案例给我的启发是前端缓存的坑往往不是缓存本身而是多套缓存逻辑之间的边界没有划清楚。6. 踩坑实录值得反复看一眼的排错链路6.1 tRPC 路由嵌套过多导致的类型导出问题我在组织路由时一开始用了很多层嵌套类似snippetRouter.nest.tag。这样组织很清晰但类型推导在多级嵌套时会偶尔报出很难读的错。后来我把多层结构压平成一个扁平的snippetRouter规格上损失一点组织感但开发和排查快了很多。经验是tRPC 的嵌套层级建议不要超过两层。第三层以上的类型推导在跨组件使用时IDE 提示会变得不够直观团队协作时沟通成本也会上升。6.2 Prisma 级联删除与外键约束这事我在测试环境直接翻车过一次。我在删除一个标签时报了Foreign key constraint failed on the field: snippetId。原因是我漏掉了SnippetTag表上的onDelete: Cascade。Prisma 对外键约束执行的是数据库层级的检查不配置级联数据库就不允许你直接删主表记录。修复就是回到schema.prisma把两个关系上都改成onDelete: Cascade然后重新迁移。这还是本文前面提到的那份模型现在这个坑已经被堵死了。6.3 搜索功能从模糊匹配到索引优化我的搜索接口一开始用的是where: { content: { contains: keyword, }, }数据量小的时候完全没问题两三万条字符串遍历也还能接受。但数据涨到十万级别后接口响应明显变慢。后来我做的优化是给关键词加上长度限制同时过滤掉无意义的单字查询并配合数据库的全文索引去处理真正高频的搜索词。对 t3code 这种个人工具来说短期够用长期再考虑引入独立的搜索引擎也不迟。7. t3code 后续扩展与我的整体感受7.1 我计划加入的几个功能当前版本完成了基础功能但还有几个明确的扩展方向分享链接协作公开片段可以生成带失效时间的链接接收方不需要登录也能查看。浏览器插件在 GitHub 或文档网站上选中代码后一键收藏到 t3code。自定义主题把代码高亮主题和页面主题解耦允许用户导入自己的主题。7.2 给想复刻这个项目的人几点建议如果你也想做一个类似工具我的核心建议是不要一上来就追求功能完整。先把增删改查、鉴权、部署这三个闭环打通就已经是一套合格的全栈练习了。再从使用体验出发加东西哪怕只是把列表页的字号和行距调舒服一点也比堆砌一堆用不上的功能有价值。另外T3 栈的学习路径有个特点它把前端、后端、数据库、部署这四件事全部压缩在一个仓库里。好处是你能在很短的时间内看到一个完整产物坏处是如果对其中某一层不熟排查问题时会被同时牵动多个方向。我的建议是先在本地把 CRUD 跑通再考虑部署和优化别一上来就追求完美。7.3 我的实际使用体会项目上线后我自己已经连续用了三周最满意的功能不是技术多难而是每天打开页面的时候我能一眼看到最近更新的片段按语言筛选的响应速度几乎感觉不到等待。这些体验说起来平淡但正是它们让我觉得自己写这个工具是值得的。最后再分享一个小技巧代码片段管理工具的标题和内容搜索一定要成为两个独立输入框不要混在一起做综合搜索。混在一起后你会发现当你输入的是一个语言名它必须既匹配标题又匹配正文很容易返回一堆不相关的结果。分开后筛选逻辑清晰用户也更容易理解每个输入框的职责。t3code 目前的体验能保持这么清爽很大程度上就归功于这个看似不起眼的决定。