
t3code 这个名字听起来像某家公司的内部代号实际上是我花一个周末搭出原型、又陆续打磨了两个多月的代码片段管理工具。核心就一件事让开发者保存、检索、分享代码片段的速度追得上你脑子里突然闪过的那个想法。每次要复用一段踩过坑才调通的配置、一段高频出现的逻辑第一反应不是打开笔记软件而是直接敲一个 t3code 的快捷键粘贴、选语言、打上标签完事。很多开发者的现实是Gist 不够隐私语雀/Notion 太重本地文件夹翻起来全得靠记忆。这个项目用 T3 Stack 那套成熟组合——Next.js、TypeScript、Tailwind CSS、tRPC、Prisma——把代码片段做成一个带账号体系的 Web 应用强调端到端类型安全和轻量级的交互闭环。如果你正打算做一个自己的小工具练手或者团队需要一个内部代码分享平台这个选题和实现思路应该能给你不少可复用的经验。1. 项目定位t3code 到底解决什么问题1.1 为什么不是 Gist也不是语雀GitHub Gist 很好但它天然绑定了 GitHub 账号而且公开/私密的管理粒度偏粗搜索能力也基本靠仓库那套逻辑。语雀这类笔记工具功能强不过打开一篇文档的成本太高你要是只想存一个“React useEffect 里防抖的写法”还得新建文档、起标题、设置目录整套动作下来灵感早凉了。t3code 的定位是“剪贴板之上、笔记软件之下”的那一层。它不需要你有任何现成的账号体系认知负担注册之后就是一个属于自己的代码片段仓库。每个片段只关心四件事标题、内容、语言、标签。一切都围绕快速录入和快速检索设计不搞文件夹、不搞富文本、不搞协同编辑。这种克制恰恰是它能活下来的原因。我最初想加“版本历史”后来又认真地删掉了这个需求。为什么因为版本历史听起来很专业但多一个功能就多一套 UI、多一张表、多一类边界情况。对于个人工具当前版本够用对于后来接的团队版我只需要在数据库层面预留一个revisions表等真有人需要时再上。这个取舍直接决定了项目能在两周内达到可用状态。1.2 功能清单与优先级第一版只做五件事用户注册与登录采用邮箱账号体系代码片段的新建、编辑、删除、列表展示通过语言类型和自定义标签进行筛选对片段内容做关键词搜索支持公开分享无需登录即可查看公开片段具体实现时我给每个功能排了优先级。登录是刚需没有账号就谈不上“我的片段”新建和列表是核心哪怕 UI 很丑也要先用起来搜索和标签在原型阶段就能带来巨大体验提升公开分享则放在数据模型设计阶段就考虑进去但 UI 可以晚点做。这个顺序直接决定了我不会在某个环节卡死整体推进非常顺。还有一个容易被忽略的需求代码高亮必须够快。我是把高亮放在服务端渲染阶段完成的这样浏览器拿到的是已经带颜色的 HTML而不是在客户端跑一套几千行的解析库。对于代码片段这种内容服务端渲染高亮几乎是唯一合理选择。2. 技术选型T3 Stack 的长处与克制2.1 tRPC 带来的端到端类型安全T3 Stack 最吸引人的不是某个单一框架而是它们组合之后产生的“类型安全闭环”。我在这个项目里深切感受到tRPC 让前后端共享类型的体验比 REST 或者 GraphQL 都顺滑得多。你不需要手写 OpenAPI 文档不需要维护 schema 文件也不需要定义 DTO 类型。你在后端写一个 procedure前端的useQuery会自动获得输入和输出的完整类型推断。刚接触 tRPC 的人可能觉得“动态类型魔法”不可靠其实它只是利用了 TypeScript 的泛型推断。我给 snippets 写一个getAll查询返回字段是title、content、language、updatedAt前端data.title和data.content一定存在且类型正确。改后端字段名前端立刻报错不存在“后端改了接口前端还在用旧字段”的典型事故。这里有个小前提tRPC 的类型安全只在同一个 monorepo 里彻底生效。也就是说Next.js 的路由处理程序和 tRPC router 最好放在同一个 TypeScript 工程或者用 pnpm workspace 管理。我在项目里直接采用 Next.js 的标准目录结构把 router 放在src/server/api/root.ts页面通过api钩子调用效果最稳。2.2 数据库从 SQLite 到 Postgres开发阶段用 SQLite 是节省精力的妙招。拿 Prisma 配 SQLite不需要安装数据库服务不需要配连接串prisma db push一次就能把表建好。等上线部署的时候再换成 Postgres只需要改 Prisma schema 里的provider和DATABASE_URL环境变量业务代码一行都不用动。有人会质疑这种“先凑合再迁移”的方式。我自己的体感是对于 CRUD 密集型的小工具SQLite 在单机开发场景下性能足够核心区别只是并发能力和数据类型差异。比如 SQLite 没有真正的枚举类型但 Prisma 会在应用层自己兜着SQLite 的全文搜索能力偏弱但一般代码片段的搜索量级用contains也能撑住。真正上线用的 Postgres 我选择了托管方案省去自己维护服务器的成本。T3 Stack 生态对 Vercel Postgres、Supabase、Neon 都有良好的 Prisma 支持。我用的路线是 Vercel Postgres因为环境和部署平台天然打通连接池也不需要自己操心。迁移时唯一要注意的就是prisma migrate deploy的时机必须在应用代码发布前先执行完数据库迁移否则新代码会访问到不存在的列。2.3 高亮方案选 Shiki 的理由代码高亮是代码片段工具的颜值担当。我对比过 Prism、Highlight.js、Shiki 三个方案最后选了 Shiki。核心差距在渲染质量。Prism 和 Highlight.js 是用正则匹配 token在一些复杂语法比如嵌套泛型、模板字符串上容易拆错。Shiki 底层用的是 TextMate 语法引擎也就是 VS Code 的着色逻辑对语言的解析颗粒度完全不是一个水平。同样一段 TypeScript 代码Shiki 标出来的类型和变量边界明显更准确。代价是包体积和计算量。Shiki 的 WASM 包加载动辄几百 KB如果放在浏览器端执行首屏体验会比较吃力。我的解决方式是把它放进服务端组件调用codeToHtml把代码转换成带行号和高亮的 HTML 字符串然后直接dangerouslySetInnerHTML渲染。体验上完全无感因为转换过程是在 Node.js 环境完成的浏览器只接收 HTML。还有一个小坑Shiki 默认支持的语言特别多打包时会包含大量 grammar。我在服务端通过createHighlighter只加载项目需要的十来种语言显著加快了冷启动速度。实际使用中最常用的也无非就是 TypeScript、JavaScript、Python、Go、SQL、Bash、JSON、CSS、HTML这些足够覆盖绝大多数场景。3. 核心实现数据模型与接口层3.1 Prisma 模型设计t3code 的数据模型一共四张核心表用户、代码片段、标签、片段标签关联表。用户表沿用 NextAuth 的标准结构没有额外扩展字段因为昵称和头像这些能力我已经能白嫖 auth.js 的默认行为。model User { id String id default(cuid()) name String? email String unique emailVerified DateTime? image String? snippets Snippet[] sessions Session[] accounts Account[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Snippet { id String id default(cuid()) ownerId String owner User relation(fields: [ownerId], references: [id], onDelete: Cascade) title String content String db.Text language String default(plaintext) isPublic Boolean default(false) tags SnippetTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([ownerId, updatedAt]) index([isPublic, updatedAt]) } model Tag { id String id default(cuid()) name String unique snippets 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]) }几个设计决策值得展开说说。content采用db.Text而不是db.VarChar因为代码片段动不动就几百行VARCHAR 的默认长度限制会很尴尬。Text 类型在 SQLite 和 Postgres 下都能顺滑工作。updatedAt的索引非常重要。首页列表默认按更新时间倒序如果数据量大到几千条没有索引会出现明显的慢查询。同样isPublic加索引是因为公开片段会以游客身份高频读取不能每次都全表扫。多对多关系的处理上我没有用 Prisma 的隐式多对多而是显式定义了一个SnippetTag中间表。原因是我以后可能想在关联上补充额外信息比如标签的权重、排序、创建时间。显式关系表给未来留了后路现在改起来代价也低。3.2 tRPC 路由与授权校验核心路由我拆成两类不需要登录的 publicProcedure 和必须登录的 protectedProcedure。publicProcedure 是用来查公开分享的片段protectedProcedure 则保证所有私密数据只能被本人访问。以创建片段为例输入校验我用 zod 处理。zod 的好处不只是格式校验它还能让 tRPC 自动把类型推给前端省掉了重复声明类型的环节。export const snippetRouter router({ listMine: protectedProcedure .input( z.object({ keyword: z.string().optional(), tag: z.string().optional(), }) ) .query(async ({ ctx, input }) { return ctx.prisma.snippet.findMany({ where: { ownerId: ctx.session.user.id, ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.keyword ? { content: { contains: input.keyword } } : {}), }, orderBy: { updatedAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), content: z.string(), language: z.string().default(plaintext), isPublic: z.boolean().default(false), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const tags await Promise.all( input.tags.map((name) ctx.prisma.tag.upsert({ where: { name }, update: {}, create: { name }, }) ) ); return ctx.prisma.snippet.create({ data: { ownerId: ctx.session.user.id, title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, tags: { create: tags.map((t) ({ tagId: t.id })), }, }, }); }), });这段代码里藏着一个团队协作时的血泪教训很多安全问题出在“列表接口查出别人数据”。我在listMine里强制ownerId: ctx.session.user.id相当于在数据访问层加了一道保险。就算有人猜测 tRPC 接口参数也只能拿到自己的数据。另一个细节是upsert处理标签。用户新建片段时输入了“React”和“Hooks”两个标签如果库里面已经有“React”就不能再插一条重复记录。用upsert按唯一键name去查有就复用没有就创建逻辑收敛得很好。中间表再创建一个关联记录让这个片段和标签绑定。3.3 标签和全文搜索的实现细节搜索是最容易被人低估难度的地方。第一版我用 Prisma 的contains做子串匹配对代码片段这种规模完全够用。比如搜“debounce”就能把所有包含该词的片段捞出来。但contains只能做简单的包含查询不能处理分词、同义词、拼音、模糊匹配将来数据量大了肯定要换方案。我给未来的搜索升级留了一个明确的 Plan BPostgres 的pg_trgm扩展或者干脆上向量数据库做语义搜索。当前阶段上这些纯属过度设计一个个人工具的片段量级可能就几百条SQL 的LIKE %keyword%配合索引已经能在十几毫秒内返回结果。标签筛选的实现是反向的。我通过“中间表反向关联”查询tags: { some: { tag: { name: tagName } } }。这个写法的意思是“找出那些关联了指定标签名的片段”。Prisma 对多对多筛选的表达能力很强这段查询生成的 SQL 本质是三表 JOIN但实际耗时依然很低。关于标签的录入交互我做了个糖果盒式的小组件输入内容后按回车自动生成一个 tag 块支持点击删除。这些 tag 块会被收集成数组传给 tRPC 的 create procedure。交互成本极低用户基本不需要去背标签管理逻辑。4. 前端实操编辑器、列表与分享页4.1 新建页面的交互细节新建页布局我做得非常聚焦顶部一个标题输入框中间一个巨大的代码编辑区域底部一行语言选择器、标签输入器和保存按钮。没有侧边栏、没有预览面板、没有花里胡哨的提示。目的是让用户打开页面后三秒内就能开始敲代码。代码编辑区域我最初想用 Monaco Editor就是 VSCode 同款编辑器。但集成 Monaco 需要加载大量 worker对于一个片段管理工具来说用户大部分时间只是粘贴一段已有代码而不是真的在编辑器里长时间编写。过度投资太重。于是我用一个textarea加font-mono样式做基础编辑快捷键Cmd/Ctrl Enter就能提交保存完全够用。textarea value{content} onChange{(e) setContent(e.target.value)} placeholder把代码粘到这里... classNamemin-h-[60vh] w-full resize-y rounded-lg border border-gray-700 bg-gray-900 p-4 font-mono text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 /隐藏的细节是键盘快捷键。我在组件里挂了一个useEffect监听keydown事件如果检测到Cmd/Ctrl Enter就触发表单提交。这个习惯在很多笔记工具里都是标配但对代码工具尤其重要因为开发者早就习惯手不离键盘了。语言选择器我用一个原生select标签配合从服务端返回的受支持语言清单。为什么不做一个自定义下拉因为原生 select 在不同浏览器下的交互最稳定而且这段代码放在移动端也不容易出问题。对工具类产品不要追求所有控件都是定制化的能用原生就用原生。4.2 代码高亮与渲染性能刚才提到我用 Shiki 做高亮这里把具体实现展开。实际上我封装了一个独立的renderCode函数放在服务端组件里调用。import { codeToHtml } from shiki; const LANGUAGES [ts, tsx, js, jsx, py, go, sql, bash, json, css, html]; export async function renderCode(content: string, language: string) { const lang LANGUAGES.includes(language) ? language : text; return codeToHtml(content, { lang, theme: github-dark, }); }在片段详情页我直接用这个函数生成高亮 HTML塞进一个dangerouslySetInnerHTML的容器。服务端渲染的好处是用户打开页面的第一个 HTML 请求里就已经包含着色后的代码搜索引擎也能直接抓取内容。性能方面有一个计数器Shiki 首次调用需要初始化语法解析器冷启动耗时要几十毫秒。我用了模块级别的缓存把createHighlighter的结果做成单例避免每次请求都重新加载 grammar。实测下来一个包含 300 行代码的页面服务端渲染耗时稳定在 30ms 左右体验很顺。还有一个容易被忽略的地方代码展示组件必须能处理极长的行。我给它加了overflow-x-auto样式同时保留空格和换行的原生格式。Shiki 产出的 HTML 里已经有white-space: pre所以不需要额外做 text wrapping否则代码对齐会乱掉。4.3 URL 驱动筛选与分享列表页的筛选状态我用 URL 参数管理。比如当前选中了“React”标签URL 就变成/dashboard?tagReact搜索关键词则对应/dashboard?keyworddebounce。这给用户带来的好处是筛选状态可以复制链接分享给同事或者直接加入浏览器书签。实现上只是一个自定义 hook把 filter 状态和useRouter同步起来。当用户点击标签时更新 URL query页面从 URL query 读取参数作为 tRPC 查询的输入。const router useRouter(); const { tag, keyword } router.query; function setTag(name?: string) { const params new URLSearchParams(); if (name) params.set(tag, name); if (keyword) params.set(keyword, keyword as string); router.push(/dashboard?${params.toString()}); }这个操作看似简略实际上避开了大量状态管理复杂度。我不需要全局 store不需要 context页面一刷新还能保持筛选状态浏览器的前进后退按钮也天然可用。现代 React 里URL 本身就是最干净的全局状态载体。分享页稍微特殊一点。公开片段有独立的/[id]路由游客可以访问但如果是私密片段服务端会重定向到登录页。这里是服务端组件直接读取 session再根据isPublic和ownerId决定能否渲染。所有逻辑都在数据源头校验不在前端靠隐藏按钮做安全隔离。5. 部署上线与问题排查5.1 部署到 Vercel 的前置工作T3 Stack 的标配部署平台是 Vercel因为 Next.js 就是它家的产品。部署前需要配好的环境变量主要有这几个DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。其中NEXTAUTH_URL在生产环境必须填正式域名否则 auth.js 在构造回调链接时容易出问题。Prisma 的部署有一个高频陷阱把数据库迁移和应用代码发布分开。我的流程是先跑npx prisma migrate deploy把数据库结构更新到最新再触发 Vercel 重新部署。顺序反了就会导致新代码访问到旧数据库结构接口报 “column does not exist”。这一步看起来是常识但在自动化部署的流水线里经常被忽略因为两个步骤通常由不同的人或不同的任务触发。还有一个细节是建表时不要用prisma db push操作生产库。db push更适合本地开发环境快速同步它的语义是“让数据库尽量匹配 schema”某些情况下会丢数据或需要你手动确认破坏性变更。生产环境必须走 migration 文件这样每一步结构变更都有记录回滚和协作都更可控。5.2 常见问题与排查速查表我整理了一份在 t3code 开发过程中频繁遇到的问题有些是 T3 Stack 独有的有些是部署环境的通用问题。问题现象可能的根因解决办法页面报PrismaClientInitializationError数据库连接串配置错误或数据库尚未创建检查DATABASE_URL确认数据库实例已创建部署后登录后立刻跳回首页NEXTAUTH_SECRET未设置或部署后变更在 Vercel 环境变量里固定一个随机密钥高亮代码闪烁或样式错乱Shiki 主题 CSS 与 Tailwind Preflight 冲突检查 Shiki 生成的 CSS 作用域必要时手动挂在_documenttRPC 报Input validation failed前端传参类型不匹配核对 Zod schema 与前端字段名大小写列表接口查询很慢缺少updatedAt或ownerId索引在 schema 中补充index登录回调地址变成http://localhostNEXTAUTH_URL被错误读取为开发环境在部署平台设置生产域名对应的环境变量页面出现 hydration mismatch 警告组件依赖客户端环境变量或随机值用useEffect延迟渲染非首屏内容最典型的是第三项。Tailwind 的 Preflight 会重置所有元素的样式而 Shiki 高亮代码依赖.shiki类名下的若干样式。如果二者没有协调好代码会显示成一片纯色。我的做法是把高亮样式单独导入并且设置了足够具体的 CSS 选择器优先级。5.3 权限与安全注意点即便是一个小工具权限问题也不能马虎。t3code 的权限模型非常简单每个片段属于一个用户公开片段任何人可见私密片段只有本人可见。但“简单”和“安全”之间还隔着实现细节。首先所有数据访问都必须在服务端完成。前端只能通过 tRPC 接口发起请求后端在ctx.session中拿到当前用户然后强制带上ownerId过滤条件。我见过不少项目只在页面上根据用户身份显示隐藏按钮接口本身却裸奔那样等于把用户数据挂在门上。其次公开片段要提防内容注入。虽然 Shiki 的输出基本安全但dangerouslySetInnerHTML始终是一把双刃剑。我严格限制只把codeToHtml生成的 HTML 注入容器用户输入的原始内容只是作为字符串传给高亮引擎不会直接作为 HTML 渲染。这样即使某段代码里藏了script也只是被当作代码文本展示不会执行。最后多用户场景下的越权访问是重灾区。我在实现update和delete接口时先检查这条 snippet 的ownerId是否等于当前用户 id不等就抛UNAUTHORIZED错误。靠前端隐藏操作按钮是容易绕过的后端校验才是真正的防线。6. 后续扩展与实际体会6.1 我进一步想做的功能t3code 目前已经可以日常使用但我的规划里还有几个明确的方向。第一是“从 URL 采集代码”。很多时候代码是散落在网页里的我希望有浏览器插件帮忙一键抓取选中内容并发送到 t3code。技术雷达已经有人实现了类似功能但真正做成生产级还需要处理 CORS、内容清理和标签推荐。第二是“相似片段推荐”。同一类问题大概率写过不止一次类似“找出所有关于 JWT 的片段”这种需求如果只是标签筛粒度不够。我打算在数据累积到一千条以上时用 PostgreSQL 的向量扩展做个简单的语义搜索。Postgres 本身支持pgvectorPrisma 通过原生 SQL 也能调用成本远低于上一套独立搜索服务。第三是“标签自动补全”。根据已输入标签的前缀下拉展示历史标签省去重复输入的麻烦。输入“de”就自动联想出“debounce”“deploy”“devops”这个小功能虽然不起眼但对录入手感的提升非常明显。6.2 这段实操带给我的一些体会回过头看t3code 这个项目最大的收获不是代码而是对“类型安全”和“最小可行功能”这两件事的重新理解。类型安全不是形式主义它直接砍掉了前后端对接层的调试时间。我以前做接口联调最怕的就是后端多了一个字段、前端少传一个参数这种低级错误在 tRPC 场景下根本不会发生编译期就被拦住了。最小可行功能则是一场持续的自我克制。开发途中我无数次想加“代码对比”“评论”“收藏”“暗黑主题切换”但每次都问自己一个问题这个功能能让我每天保存代码片段的速度变快吗如果答案是否定的它就放进 backlog。实际结果就是t3code 只用了很少的开发时间就达到了可以日常使用的状态而真正让我愿意持续维护它的恰恰是这种轻盈感。最后分享一个后续维护的实用技巧在package.json里加好db:push、db:migrate、dev这些短命令并且坚持每天提交一次代码。功能不完整不要紧关键是保持项目一直处于“能跑”的状态。这样你随时可以停下做别的事回来之后还能从干净的基线继续往前走不用花时间回忆上次到底改到哪一步。工具也好项目也好能持续用下去比什么都重要。