☰
基于T3 Stack的代码片段管理工具:从数据建模到部署全解析
2026/10/26 9:12:02 网站建设 项目流程

个人项目做到一半,最难的不是写代码,而是反复去翻自己以前写过的片段:某个日期格式化函数、某段正则、某个组件的基础用法。项目多了之后,本地笔记、聊天记录、旧仓库散落一堆,真正要用的时候反而找不到。这就是我做 t3code 的起因——一个基于 T3 Stack 的轻量级代码片段管理工具,专门解决"自己的代码自己找不到"这个问题。如果你正在学全栈、想找一个结构清晰且能直接跑通的参考项目,或者跟我一样受不了碎片化笔记,这篇文章会把从选型、数据建模到部署踩坑的完整过程都拆给你看。

1. 从一个"记不住代码"的场景,到 t3code 的定位

这个项目最初的形态根本不是 web 应用。我最开始是在本地用 Markdown 文件按语言分目录整理代码片段,配合编辑器全局搜索。用了一阵子就发现问题了:本地文件没有标签体系,跨语言搜索只能靠文件名,想按"场景"回忆(比如"带鉴权的 fetch 封装")的时候,文件名根本不管用。

后来临时起意写了个小的命令行工具,用 SQLite 存片段、用 fts5 做全文搜索,终端里 prompt 一下就能查。命令行工具解决了一部分问题,但换电脑、手机上临时要看、想给别人分享某个片段,这些场景全都没覆盖。于是我把需求重新梳理了一遍,确定 t3code 的核心边界:

  • 以"片段"为最小单位,每个片段属于一个用户,带标题、代码内容、语言、描述和多个标签
  • 支持全文搜索和标签筛选,这是整个工具的刚需
  • 片段即开即用:进入页面就能搜、能复制、能保存,不搞复杂的工作流
  • 零成本部署,个人使用为主,但架构上预留多用户能力

最初的 MVP 我只做了三件事:新增片段、列表展示、按语言和标签筛选。搜索做到"边输入边过滤"就停住了,没有一开始就上全文检索引擎。这个克制很重要——很多业余项目死在"第一版就想把搜索、多人协、收藏、统计全做出来",结果半年了还跑不起来。t3code 最大的教训就是:把核心场景(存、找、复制)做到极致的体验,远比功能堆砌有意义。

做这个项目之前我也对比过现成的方案:GitHub Gist 功能足够强,但它是公网可见的,私人片段要建 secret gist,而且在移动端浏览体验一般;Notion 的代码块管理能力不错,但每次想插入一个片段都得层层展开页面。t3code 的差异化就一条——它是为了"快速存、快速找、快速抄"这个单一目的设计的,所有交互都围绕这个目标转。

2. 技术选型复盘:T3 Stack 的每一层都不是白选的

T3 Stack 这个名字来自 create-t3-app 这个脚手架,核心组合是Next.js + TypeScript + tRPC + Tailwind CSS,数据库一般配 Prisma。选这套而不是自研后端 + 前端分离,不是因为"流行",而是它非常匹配这个项目的实际约束:单 repo、全栈 TypeScript、类型安全贯穿前后端、部署简单。

2.1 为什么是 Next.js App Router 而不是传统 SPA

t3code 的页面分为两类:首屏搜索结果页和片段编辑页。用传统 React SPA 方案的话,需要单独维护一套路由体系,还得考虑 SEO(虽然个人工具 SEO 需求很弱),更麻烦的是服务端逻辑要额外起 Node 服务,部署成本翻倍。Next.js App Router 把页面路由、API 路由、中间件都收在一个框架里,部署到 Vercel 的时候一个项目就够了。

实际用下来,App Router 的 Server Component 对这个项目帮助很大:首屏直接用服务端组件读取数据库渲染列表,客户端组件只负责搜索框的受控输入和交互状态。这样首屏加载没有任何客户端请求瀑布,Lighthouse 的 First Contentful Paint 压到了 0.8 秒以内。

2.2 tRPC 为什么比 REST 和 GraphQL 都顺手

这是 T3 Stack 里我最想展开讲的一层。先看个对比:

方案类型安全端到端类型共享学习成本服务端工作量
REST + OpenAPI中等需要代码生成低高(写路由 + 文档 + 客户端 SDK)
GraphQL高需要 codegen较高高(写 schema + resolver)
tRPC极高零成本原生共享低低(写一个函数导出即可)

tRPC 的做法颠覆了传统 API 设计思路:不需要定义 URL、不需要序列化协议,直接在服务端写一个函数(procedure),客户端 import 类型后就能像调用本地函数一样调用远程函数。类型从数据库查询结果一路“流”到 React 组件的 props,中间没有任何断裂点。

我举个例子,t3code 里获取片段列表的 tRPC procedure 大概是这样的:

// server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ language: z.string().optional(), tag: z.string().optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { userId: ctx.session.user.id, ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.search ? { OR: [ { title: { contains: input.search } }, { code: { contains: input.search } }, { description: { contains: input.search } }, ], } : {}), }, orderBy: { updatedAt: "desc" }, }); }), });

客户端调用更是简单到不像话:

const { data: snippets } = api.snippet.list.useQuery({ search: keyword, language: selectedLanguage, });

改一个查询条件,类型立刻同步,编辑器里写错字段名直接红波浪线。这种体验用惯了以后,回去写 REST 接口会非常不适应。

2.3 Prisma 与数据库选型的实际考量

T3 Stack 默认推荐 Prisma,我在 t3code 里也确实用了 Prisma。选它的两个决定因素:一是 schema 文件足够直观,改完模型跑一次prisma migrate就能拿到迁移 SQL,对个人项目来说几乎不需要额外学习成本;二是它的类型生成机制和 tRPC 是绝配——Prisma 查出来的对象类型可以直接作为 tRPC 的返回类型,不需要手动定义 DTO 层。

数据库我选的是 PostgreSQL(Neon 的 serverless 版本),原因也很简单:部署到 Vercel 的环境里,传统常驻数据库连接不划算,serverless Postgres 支持连接池,能扛住冷启动后的并发连接压力。Neon 还提供分支数据库功能,我本地开发时直接neonctl branch create开一个分支,测试完再合并,非常干净。

2.4 Tailwind 与组件方案的取舍

样式层选了 Tailwind CSS + shadcn/ui。这里我说下原因:t3code 是个人工具,没有设计资源,shadcn/ui 提供的是可复制到项目里的源码组件,不是封装死掉的 UI 库。我可以把 Dialog、DropdownMenu 这些组件直接改造成自己需要的样式,而不需要去读一个抽象组件的源码。Tailwind 的实际体验是省掉了很多"写类名"的心理负担,但要注意的是,如果项目交互复杂,className会变得很长。t3code 的做法是给高频组件提取了一层 wrapper(比如SnippetCard),把 Tailwind 类收敛在组件内部,页面层保持干净。

3. 数据模型与权限设计:标签多对多、所有权隔离和搜索实现

数据层是 t3code 里改动次数最多的地方。第一版我只设计了 Snippet 单表,后来加标签、加用户关联、加搜索需求,前后重构了三轮。下面把最终的模型和思路完整展开。

3.1 Prisma Schema 的最终形态

// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] tags Tag[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String code String @db.Text description String? @db.Text language String @default("plaintext") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tags Tag[] } model Tag { id String @id @default(cuid()) name String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) snippets Snippet[] @@unique([name, userId]) }

几个设计决策:

  • Snippet 和 Tag 是多对多,中间表 Prisma 会自动生成,不需要手写。这么做的好处是搜索某个标签的时候可以反向拿到所有相关片段,数据冗余最小。
  • Tag 的 name 不是全局唯一,而是[name, userId]联合唯一。也就是说每个用户的标签空间是独立的。这点很容易漏掉,如果不加 userId,就会出现 A 用户创建的react标签干扰 B 用户的筛选。
  • 所有和用户相关的表都带 userId 外键,并且删除用户时级联删除。虽然是个人工具,但从第一版就把所有权隔离做掉,后面真要开放注册的时候不用回头改表结构。

3.2 所有权隔离:tRPC 中间件的作用

前端反馈的时候,有人可能漏掉一层权限校验:即使页面不展示别人的片段,如果 tRPC 路由写得粗心,别人拿你的 API 地址直接调用snippet.detail传一个别人的 snippetId,就可能会读到别人的数据。t3code 在 tRPC 路由层做了两道防线。

第一道是protectedProcedure。它会在执行查询前检查 session,没有登录直接抛 UNAUTHORIZED 错误。这个在 create-t3-app 里是默认就有的:

// server/api/trpc.ts export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } }, }); });

第二道是在每个涉及具体资源的 mutation / query 里强制带上userId过滤条件:

detail: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findFirst({ where: { id: input.id, userId: ctx.session.user.id, // 关键 }, include: { tags: true }, }); if (!snippet) throw new TRPCError({ code: "NOT_FOUND" }); return snippet; }),

这里用findFirst而不是findUnique,就是为了能附加 userId 条件。findUnique只能按唯一键查,根本拼不进所有权条件,所以这类场景记住用findFirst配where。

3.3 搜索方案:从"怕慢"到"先跑起来"

搜索是最纠结的功能。第一想法是上 PostgreSQL 全文检索(tsvector + GIN 索引),确实性能最好,但要在 Prisma 里执行原始 SQL,对个人项目来说有点重。后来我算了一笔账:t3code 单用户的片段量撑死几千条,每条 code 可能几百上千字,总量不过几 MB,在 PostgreSQL 里做LIKE '%keyword%'虽然不能命中 B-tree 索引,但全表扫描几 MB 数据也就几十毫秒。于是 MVP 阶段直接用了最朴素的contains查询:

search: z.string().optional(), ... ...(input.search ? { OR: [{ title: { contains: input.search, mode: "insensitive" } }, { code: { contains: input.search, mode: "insensitive" } }] } : {}),

实测下来,3000 条片段、平均 5KB 文本的情况下,搜索响应稳定在 80 ~ 120ms 之间。这个数据后面可以在数据库加 pg_trgm 索引进一步优化,但现阶段完全够用。我的原则很简单:先用最简单的方案跑通核心体验,当查询时间真的超过 200ms 再考虑引入更复杂的索引结构。很多开发者栽在提前优化上,为了一个根本不会发生的并发场景,把架构搞复杂了十倍。

4. 前端交互的细节打磨:从"能用"到"每天想打开"

t3code 本质是个工具型应用,用户打开它的频率取决于"找到片段"这件事有多顺滑。所以前端我没有把精力花在花哨的动画上,而是集中在几个高频交互上反复打磨。

4.1 编辑器方案:CodeMirror 6 而不是 Monaco

代码片段编辑器是核心输入入口。对比过两个方案:

MonacoCodeMirror 6
体积较大(~2MB 起步)模块化,按需加载(~300KB)
语言支持极强(VS Code 同款)常见语言足够
React 集成需要自己封装有官方 React wrapper
定制性一般极高(一切皆可配置)

t3code 的场景是编辑 5 行到 100 行的小片段,不需要断点调试、不需要多光标、不需要 IntelliSense。Monaco 那种重型 IDE 能力根本用不上,还拖慢首屏。CodeMirror 6 按语言加载语法包,我只需要导入常见的javascript、typescript、python、go、rust等几个语言包,其余 fallback 到纯文本。最后选择 CodeMirror 6,首屏体积少了将近 1.5MB。

4.2 快捷键与"无鼠标流"

真正的效率工具必须能用键盘完成高频操作。t3code 做了三个快捷键:

  • Cmd+K:全局聚焦搜索框,输入内容实时过滤列表
  • Cmd+Enter:在编辑器里保存当前片段(同时支持Cmd+S防浏览器默认行为)
  • Ctrl+Shift+F:快速切换语言筛选,比如输入ts直接筛 TypeScript

这里有个细节:Cmd+S在浏览器里默认行为是"保存页面",如果不e.preventDefault(),用户每次想保存片段都会弹下载框,体验非常差。处理方式是在全局监听了 keydown:

useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); if (isEditing) handleSave(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [isEditing, handleSave]);

4.3 复制按钮的自带反馈

代码片段页面最高频操作是"复制"。我最初只做了一个navigator.clipboard.writeText(code),点击后没有任何反馈,经常出现用户点了好几次以为没复制成功的假象。后来改成三步:复制成功 -> 按钮文案变成"已复制" -> 2 秒后恢复原状。这是一个极小但体验提升巨大的改动。

顺便提醒一句:navigator.clipboard在非 HTTPS 环境下不可用,本地开发用 localhost 没问题,部署到自定义域名时一定记得开 HTTPS,否则这个 API 会静默失败。

4.4 暗色主题的一点点小坑

工具型应用必然要支持暗色模式,但代码高亮比较特殊:编辑器背景色和语法 token 的颜色需要跟着主题切换,否则会出现"页面是暗色的,代码区域却是白底黑字"这种割裂感。t3code 的做法是用 CodeMirror 的 theme 配置重新生成一组高亮颜色,并且在 localStorage 里保存主题偏好:

const editorTheme = isDark ? darkTheme : lightTheme; <CodeMirror theme={editorTheme} value={code} onChange={setCode} />

5. 部署上线实测:Vercel + Neon Postgres 的完整闭环

部署自带一种"项目终于要见光了"的忐忑感。t3code 的部署目标很明确:成本趋近于零、不需要自己维护服务器、支持自动 HTTPS。最终选了 Vercel 托管 Next.js,Neon 提供 serverless Postgres,两家的免费额度对个人工具来说完全够用。

5.1 首次部署的全过程

按顺序走一遍实际操作:

  1. 在 Neon 控制台创建项目,复制连接字符串(注意选 connection pooling 的那个地址,直接连数据库主机的地址在 serverless 场景容易撞连接数限制)
  2. 在 Vercel 项目设置里配置环境变量:DATABASE_URL和NEXTAUTH_SECRET
  3. 本地跑npx prisma db push把 schema 同步到数据库(也可以生成 migration 后在 Vercel 构建时自动跑,但个人项目不需要那么复杂)
  4. 推送代码到 GitHub,Vercel 自动识别 Next.js 项目并构建部署

T3 Stack 在这种情况下几乎"零配置"就能跑起来,因为 create-t3-app 已经预设好了构建命令和输出配置。唯一要注意的是环境变量在构建阶段就要存在,否则 TypeScript 类型检查会因为拿不到数据库地址而在 CI 报错。

5.2 首屏性能实测数据

部署完我跑了一次 Lighthouse 和数据统计:

指标实测结果
First Contentful Paint0.8s
Largest Contentful Paint1.2s
Time to Interactive1.5s
首屏 JS 体积约 190KB gzip
首屏请求数8(含静态资源和接口)

数据比预期乐观,原因有两个:一是 App Router 服务端组件把首屏数据直接在 HTML 里渲染出来了,没有客户端瀑布请求;二是 CodeMirror 只在编辑页面异步加载,列表页完全不碰它。这个结果证明了一件事——个人工具完全可以通过架构选择达到很好的性能,而不需要专门做性能优化。

5.3 成本与运维现实

跑了一个月,账单如下:Vercel 免费档(个人项目流量没有超出),Neon 免费档(Postgres 实例保持休眠,白天用的时候被唤醒)。整体费用为 0。运维上唯一会遇到的情况是 Neon 实例休眠后第一次访问会有 3 秒左右的冷启动延迟,对个人工具来说可以接受。如果实在忍受不了,可以加一个定时 ping 的脚本保持实例活跃,但免费档不推荐,还是让它在不用的时候休眠省资源。

6. 踩坑实录:四个问题从报错到修复的完整排查链路

前后花了很多个晚上,积累下来的这四个坑最有代表性,每一个单独拎出来都够让人查两小时。

6.1 Prisma Client 在 Vercel 构建时找不到数据库连接

现象:第一次部署时日志在prisma generate阶段报错,提示Environment variable not found: DATABASE_URL。

排查链路:先确认环境变量已经在 Vercel 控制台配置,随后发现构建阶段的环境变量和运行时还不完全一样——Vercel 构建时会执行prisma generate,这个阶段如果拿不到DATABASE_URL,Prisma Client 会拒绝生成默认的 cjs 产物。Vercel 的构建环境默认会读取控制台上配置的变量,所以按理说不该报错,但我的情况是环境变量名写错了——在 Neon 里复制的是DATABASE_URL_POOLED,粘贴到 Vercel 时键名却写成了DATABASE_URL_POOL。检查完全部配置后确认是键名不一致导致的问题。

修复方案:在 Vercel 环境变量里统一使用DATABASE_URL作为键名,值为 Neon 提供的 pooling 连接串。

6.2 NextAuth 回调地址报错:redirect_uri_mismatch

现象:本地能登录,部署后在 Vercel 点 GitHub 登录,跳转回来就报redirect_uri_mismatch。

排查链路:NextAuth 的 GitHub Provider 需要配置回调地址,默认是http://localhost:3000/api/auth/callback/github和https://你的域名/api/auth/callback/github两个都要在 GitHub OAuth App 里配。我只配了 localhost 的,线上当然校验失败。修改 GitHub App 配置后,还需要确认NEXTAUTH_URL环境变量也指向线上域名,否则 NextAuth 在服务端生成的回调地址还会是 localhost。

修复方案:在 GitHub OAuth App 设置里补上线上回调地址,同时 Vercel 环境变量NEXTAUTH_URL设为正式域名。

6.3 tRPC 查询总是返回 500,但本地开发完全正常

现象:线上的snippet.list返回 Internal Server Error,本地却一切正常。

排查链路:先看 Vercel 函数日志,报错是PrismaClientInitializationError: PrismaClient failed to initialize。本地正常、线上失败,最常见的原因是Prisma Client 产物与当前 Node 环境不匹配或者数据库连接串在运行时拿不到。日志里还能看到一行Invalidprisma.snippet.findMany()invocation的提示,指向的是数据库连接无法建立。

接下来验证数据库连通性:在本地用psql "$DATABASE_URL" -c "select 1"能通,说明 Neon 的连接串本身没问题。那问题大概率出在 Vercel 的运行时环境变量。检查后发现,我在 Vercel 环境变量里配置的是 Neon 的连接串,但 Prisma schema 里的连接串名称默认是DATABASE_URL,如果环境变量名大小写不一致,运行时 Prisma Client 拿到的就是 undefined。为了避免环境变量名大小写混淆,统一改为DATABASE_URL全大写,并重新部署,问题消失。

修复方案:确认 Vercel 环境变量键名与 Prisma schema / NextAuth 完全一致,统一使用DATABASE_URL和NEXTAUTH_SECRET。

6.4 自动保存竞态导致片段内容被旧数据覆盖

现象:编辑片段时切到另一个片段,再切回来,发现刚才对前一个片段的修改丢了,编辑器里显示的是更早的内容。

排查链路:这不是服务器问题,而是前端状态管理的疏漏。t3code 里编辑器组件是被复用的,code状态存在组件的 useState 里,没有跟当前选中的 snippetId 绑定。切换片段时,新的 snippet 数据传入组件,useState 的初始值只在组件挂载时生效,切回来时组件因为同一个 key 没有重建,所以 state 还停留在上一次的旧内容。

另一个相关问题是防抖自动保存:输入停止 1.5 秒后自动调update接口,但如果用户在保存过程中切换了片段,旧片段的请求还没完成,等它返回后会把新片段的数据覆盖掉。这是一个典型的竞态条件。

修复方案:两个改动。第一,编辑器组件通过key={snippetId}强制在切换片段时重建状态。第二,自动保存请求里增加一个"当前编辑 id"的校验,只有请求返回时用户仍然在编辑同一个片段,才更新 UI 状态:

// 伪代码思路 const onAutoSave = async (snippetId: string, content: string) => { const savedId = snippetId; await api.snippet.update.mutate({ id: snippetId, code: content }); if (editingIdRef.current === savedId) { setLastSavedAt(new Date()); } else { console.warn("保存返回时用户已切换片段,丢弃过期更新"); } };

7. 后续扩展的想象空间:标签自动生成、全文检索与移动端

t3code 作为个人工具,核心闭环已经跑通,但我脑子里有几个明确的迭代方向。

第一个方向是自动打标签。手动维护标签确实是最费精力的一环,尤其是片段量过千之后,给每个片段想 3 个标签很消耗意志力。基于嵌入模型做一批"标题 + 代码摘要 -> 标签"的分类是可行的,成本也不高。第二版可以先把所有已有片段离线处理一遍,打上推荐标签,之后新片段入库时再实时推荐。

第二个方向是真正的全文检索。当前contains查询在几千条数据时已经能用,但到了几万条,加上多条件筛选,响应时间会明显上行。换 PostgreSQL 的pg_trgm扩展,配合 GIN 索引,可以把 LIKE 查询加速几个数量级,而且不需要改 Prisma 存储层,只是在查询时执行SELECT ... WHERE code ILIKE '%xxx%'就能命中索引。

第三个方向是移动端适配。现在手机浏览器打开 t3code,列表和编辑器勉强能用,但复制操作和多标签筛选的体验很一般。做一个 PWA 版本,把核心的浏览、搜索、复制场景搬到手机上,是性价比最高的一步,不需要单独开发原生 App。

代码片段管理这个需求大概率不会消失,t3code 这个项目给我的收获已经不只是一个工具了,它把全栈开发里那些"看起来很简单、做起来全是细节"的部分完整暴露了一遍。如果你也想动手做个类似的工具,我的建议是从最朴素的方案起步,每一步都问自己"这个复杂度能不能等需要的时候再加",把精力留给真正影响使用体验的环节。毕竟一个好的开发者工具,核心从来不是技术炫技,而是让使用者顺畅地完成他想做的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询