☰
用T3 Stack打造轻量代码片段管理工具:tRPC+Prisma+SQLite实战
2026/10/27 16:21:46 网站建设 项目流程

你有没有过这样的体验:同一个片段代码,在项目A里改过一版,又在项目B里复制了一份改改,三个月后想在项目C里找回来,发现哪个文件都不完整。我自己的笔记本里长期散落着十几个 notes.md,里面混着命令、代码、配置片段、临时方案,时间一久根本不知道哪个版本是对的。后来实在受不了,我决定动手做一个自己的代码片段管理工具,顺便把之前一直想试但没机会完整落地的技术栈一次用透。这就是 t3code 这个项目的由来——一个面向个人开发者和微型团队的轻量代码片段管理与检索工具,端到端打通类型安全,自带全文搜索,配套一个命令行入口,能把"存片段"和"找片段"这两件事真正做到顺手。

这篇文章我会从项目定位、技术选型、核心实现、部署维护、踩坑记录五个角度完整复盘整个项目。如果你是正在学习 T3 Stack、想了解 tRPC/Prisma 在生产级小工具里怎么组织代码的读者,或者单纯想找一个"周末能写完、周一能真用上"的个人项目参考,这篇内容应该能帮到你。我尽量把每一步"为什么这么做"讲清楚,而不是只给结论。

1. 项目定位与整体设计思路

1.1 先想清楚:t3code 到底解决什么问题

代码片段管理这个方向其实已经有不少成熟产品,比如 VS Code 自带的 Snippets、GitHub Gist、一些笔记软件里的代码块。但它们各有各的不顺手:Gist 走公开/Secret 模式,更适合分享而不是个人检索;笔记本里的代码块没有语言识别和全文索引;本地文件分散之后基本等于没有归档。我最初的痛点很直接:我需要一个工具,能让我在命令行里三秒查到一段写过但记不全的代码,能按语言、标签、关键词筛选,能看历史新增时间,最好还能直接复制到剪贴板。

所以 t3code 的核心定位不是做一个"界面漂亮的代码收藏夹",而是做一个"命令行优先的私人代码仓库"。Web 端负责录入和管理,CLI 负责快速查询和插入,SQLite 负责单机存储,FTS5 负责全文检索。这个取舍很重要——功能清单里没有用户系统、没有多端同步、没有团队协作,因为个人工具的护城河不在功能数量,而在"打开速度"和"找得到"。

1.2 为什么选 T3 Stack 作为技术底座

T3 Stack 严格来说不是一个框架,而是一组搭配经过验证的实践组合:Next.js 做前端与 API 层、tRPC 做端到端类型安全通信、Prisma 做数据库 ORM、Tailwind 做样式。这套组合的口号是"类型安全第一",对我这种单人开发、前后端一把梭的场景非常适合。

第一层好处是开发效率。我用 tRPC 写接口不需要额外定义 REST 路由、不需要手写接口文档、不需要在前后端各维护一套类型定义。procedure 写好后,前端调用的类型提示是自动推导的,改一个字段名,所有引用处立刻报错。这个体验和写本地函数几乎没有区别。

第二层好处是心智负担低。Next.js 本身就同时覆盖了页面和 API 层,tRPC 把 API 的传输协议抽象掉之后,我只需要关心业务函数怎么写。相比 Express + REST + OpenAPI 的方案,少掉的那部分胶水代码在单人项目里非常可观。

第三层是生态成熟度。Prisma 的 schema 定义、迁移、类型生成都很稳定;Tailwind 和 shadcn/ui 的组件库组合用起来很顺手。这些工具单独拿出来都不算亮点,但组合在一起,让我从零到可用只花了大概两个周末。

1.3 功能范围与 MVP 取舍

我给自己定了一个 MVP 功能列表,所有不在列表里的功能一律不做:

  • 创建、编辑、删除代码片段
  • 按标题、语言、标签、内容关键词检索片段
  • 支持```lang代码块语法保存内容和语言
  • 片段详情页展示、一键复制
  • 命令行工具支持t3 add和t3 find
  • 数据库存储用 SQLite,单文件部署,避免引入外部依赖

放弃掉的功能包括:标签自动提取(我手动维护就够)、代码高亮预览(编辑时用等宽字体即可)、多用户鉴权(个人工具不需要)、浏览器插件(需求不确定,后期再看)。事实证明这个范围恰好能让项目在两周内完成主体逻辑,后续再慢慢加东西。

2. 关键技术选型与设计解析

2.1 框架层:Next.js App Router,而不是 Pages Router

这个项目开始写的时候 App Router 已经稳定,我直接选它。理由有三:服务端组件默认执行能减少不必要的客户端 JS;路由目录结构对"片段详情页"这类动态路由很自然;layout.tsx 这种嵌套布局模型对"统一头部导航 + 子页面"的场景很顺手。

需要注意的坑是"服务端组件与 tRPC 的边界"。tRPC 客户端默认是为客户端组件设计的,在服务端组件里直接调用需要特殊配置。我的做法是把所有 tRPC 调用封装成独立函数,服务端组件通过函数调用而不是 tRPC 的 hook 来拿数据。这样虽然损失了一部分"端到端类型推导"的体验,但避免了组件边界混乱。我的建议是:如果一个页面本身不需要交互,就用服务端组件直接查 tRPC caller,不要为了"统一用 hook"而把组件全部改成客户端。

// 服务端组件里不要直接调用 api.snippet.list.useQuery() // 而是用一个独立函数包装 import { createCaller } from "~/server/api/root"; import { db } from "~/server/db"; export async function getSnippets({ query }: { query?: string }) { const caller = createCaller({ db }); return caller.snippet.list({ query }); }

这样服务端组件和客户端组件都能复用同一份业务逻辑,类型也没有丢失。

2.2 通信层:为什么是 tRPC 而不是 REST 或 GraphQL

项目只有我一个开发,但前后端类型共享依然重要。REST 方案需要我自己维护请求类型定义和接口文档,一旦字段改了很容易漏掉某个调用方;GraphQL 方案在当前规模下引入了太多复杂度(schema 解析、缓存策略、代码生成),对一个小工具是负担。

tRPC 的核心思路是"把远程调用伪装成本地调用"。服务端定义好 procedure 后,客户端得到的api.snippet.list是一个完全类型推导的函数,参数对象和返回类型都和服务端保持一致。写代码时我几乎感觉不到在调网络接口。

tRPC 的 procedure 是它的核心抽象。一个 procedure 由输入校验、中间件、解析函数三部分组成,我几乎每个 procedure 都用了 Zod 做输入校验。Zod 的 schema 既是运行时校验也是 TypeScript 类型来源,一份定义两处使用。

// 定义 snippet 的输入校验 const snippetCreateSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().max(50).default("plaintext"), tags: z.array(z.string()).max(10).default([]), }); export const snippetRouter = createTRPCRouter({ create: publicProcedure .input(snippetCreateSchema) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, tags: { connectOrCreate: input.tags.map((tag) => ({ where: { name: tag }, create: { name: tag }, })), }, }, }); }), });

这里我踩过的一个小坑是connectOrCreate对数组的处理。tags是数组时 Prisma 的connectOrCreate不能直接用在嵌套 create 的数组字段上,必须改成创建tags关联记录的方式。最后我用的是先查后建的策略:先查出已存在的标签 id,再对新标签做 create,然后 splice 成关联 id 列表。虽然多了一条查询,但对个人工具完全够用。

2.3 存储层:SQLite + Prisma,而不是 PostgreSQL

我最初其实是想上 PostgreSQL 的,但后来想明白了一个道理:这是一个个人工具,我不需要独立数据库服务,不需要主从复制,不需要云端备份策略。SQLite 单文件存储、零配置、备份就是复制文件,这些特性对个人工具来说是巨大的省心。Prisma 官方支持 SQLite,schema 层面和 PostgreSQL 只有 provider 一行之差,后期如果真要切换也能低成本迁移。

SQLite 在大约几万条片段下性能完全没问题。我用 FTS5 虚表做全文索引,比 LIKE 查询快了大概一个数量级。但要注意 FTS5 是 SQLite 编译时可选特性,Prisma 生成的数据库默认不启用 FTS5 扩展。我的做法是在 Prisma migrate 生成 SQL 之后,手写一个 migration SQL 去创建 FTS5 虚表和触发器。这一步需要手动确保 FTS5 索引和业务表数据的一致性。

数据模型很简单,就两张核心表加一张关联表:

表名字段说明
Snippetid, title, content, language, source, createdAt, updatedAt片段主表
Tagid, name标签表,name 唯一
TagsOnSnippetssnippetId, tagId多对多关联

source 字段我用来标识片段来源,比如manual、cli、gist-import。这个字段不算核心逻辑,但后面做统计和清理时会很有用。

2.4 交互层:Tailwind + shadcn/ui,快速搭出可用的界面

界面部分我用了 Tailwind + shadcn/ui。shadcn/ui 不是一个安装即用的组件库,而是把组件源码复制进项目里,自由度很高,看起来也干净。对于片段管理工具,我用到了 Dialog、Form、Select、Textarea、Tooltip 这几个组件,整体界面大概花了一天就搭完了。

配色方面,我的建议是不要花哨,代码工具类应用用深色主题更护眼。我用的是默认的 zinc 色调加一个 brand 色,列表行、标签气泡、代码块边框用灰色系。编辑器区域直接用等宽字体,行高严格,这样才能让代码粘贴上去不变形。

3. 核心功能落地实录

3.1 片段新增与编辑:数据结构设计要点

存代码片段最关键的是数据结构要简单且不限制表达。我最初的 schema 里设过description字段,但后来发现真正用的时候很少填。做工具要记住一个原则:能自动推导的字段不要让人手动填。语言类型可以由代码块围栏里的lang自动识别,标签可以留空,标题如果为空就用内容的前二十个字符补全。

新增操作的编辑页其实就是一个表单:标题、语言、标签、内容、来源。提交时后端做 Zod 校验,保存到数据库后跳转到详情页。详情页的核心输出是"一段带语言标记的 Markdown 代码块",直接交给代码高亮组件去渲染。

// 用 CodeBlock 组件渲染片段内容 <CodeBlock code={snippet.content} language={snippet.language} showLineNumbers theme="github-dark" />

编辑操作有个细节:更新时要同步处理标签关系。Prisma 的set操作会直接重置关联关系,传入当前标签的 id 列表即可。这里要注意避免标签重名,我用了标签名作为唯一键,更新时先按名称查出一批 id,再去重做关联。

3.2 全文检索:LIKE 与 FTS5 的实测对比

片段管理工具最大的价值在检索。我先试了 Prisma 自带的contains,它在 SQLite 里会生成LIKE '%xxx%',内容一多,扫描整个表,速度明显下降。实测 2500 条片段数据,一次LIKE '%promise%'查询大约 300ms 到 800ms 不等,索引帮不上忙,因为前置通配符决定了无法走 B-Tree 索引。

然后我切换到了 SQLite FTS5。FTS5 是 SQLite 自带的全文检索扩展,支持倒排索引、BM25 排序、前缀搜索、同义词扩展等能力。实现方式是在业务表之外创建一张 FTS 虚表,然后用触发器同步增删改。

-- 手动创建 FTS5 虚表 CREATE VIRTUAL TABLE IF NOT EXISTS fts_snippets USING fts5( title, content, language, content='Snippet', content_rowid='id' ); -- 触发器示例:新增时同步 CREATE TRIGGER IF NOT EXISTS snippets_ai AFTER INSERT ON Snippet BEGIN INSERT INTO fts_snippets(rowid, title, content, language) VALUES (new.id, new.title, new.content, new.language); END;

这里有个关键点:FTS5 支持content='外部表'模式,即不把内容冗余存储,而是通过 rowid 关联原表,查询时再回原表取内容。这样可以避免数据双写不一致,也能省磁盘空间。代价是 FTS5 不能自己排序,必须用ORDER BY rank,但实际用起来没问题。

切换之后,同样 2500 条数据的关键词查询从几百毫秒降到了几十毫秒,而且支持AND OR NOT组合检索。FTS5 默认分词器对中文支持不理想,按空格和标点分词会把一整句中文当作一个 token。我的处理方式是额外提供一个jieba风格的分词器配置,但个人使用场景里,中文片段检索我很少依赖全文索引,大多是靠标签和英文关键词命中,所以暂时够用。如果你要存大量中文代码注释且有中文搜索诉求,可以调研一下simple或unicode61之外的分词器方案。

3.3 tRPC 路由拆分:列表、详情、增删改

tRPC 路由设计我分成了两部分:snippetRouter负责业务,tagRouter负责标签管理。列表接口除分页外,还接收query、language、tagIds三个筛选条件。查询逻辑里我直接在 Prisma 的 where 里组合条件,FTS5 检索是通过$queryRaw先查 rowid 列表,再取总数和分页数据,避免把全部内容传出来。

search: publicProcedure .input(z.object({ query: z.string().optional(), language: z.string().optional(), tagIds: z.array(z.string()).optional(), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(20), })) .query(async ({ ctx, input }) => { const where = buildWhere(input); const [total, items] = await Promise.all([ ctx.db.snippet.count({ where }), ctx.db.snippet.findMany({ where, include: { tags: { include: { tag: true } } }, orderBy: { createdAt: "desc" }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }), ]); return { total, items, page: input.page, pageSize: input.pageSize }; }),

这段代码本身不复杂,但有一个细节值得讲:筛选条件里如果传了tagIds,Prisma 的some会生成 EXISTS 子查询,性能在小数据量下可以接受,但记得在 Tag 表上建唯一索引。另外count和findMany用Promise.all并发执行更高效。这个接口在 2500 条数据下打开列表页响应在 20ms 左右,实际体验和本地文件几乎没区别。

3.4 CLI 工具:命令行三秒查片段

t3code 的 CLI 是这个项目里我最常用也最喜欢的部分。CLI 的核心需求只有两个:t3 add快速新增片段,t3 find快速搜索片段。实现方式不是做一个客户端,而是直接请求部署好的 Web 服务 REST API——注意,服务端我已经通过 tRPC 提供了一层 procedure,但 CLI 不需要引入 tRPC 客户端,我用一个轻量 Node 脚本直接调 HTTP 接口,减少一整层依赖。

# 新增片段,内容从 stdin 读取 echo "const a = 1" | t3 add --title "example" --lang typescript --tags demo # 搜索片段 t3 find "promise all" --lang typescript --max 5

t3 add的逻辑是:读取 stdin 或临时文件内容,解析参数,POST 到/api/snippet。t3 find的逻辑是:请求/api/snippet/search?query=xxx,结果显示匹配片段标题、语言、文件片段。查出来之后按快捷键直接复制到系统剪贴板,这一步用clipboardy库完成,跨平台支持很好。

// CLI 搜索核心逻辑 const res = await fetch(`${API_URL}/api/snippet/search`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, language, pageSize: max }), });

CLI 的体验优化还有个细节:t3 find输出结果时我会加一个编号,用户输入编号后再调用t3 copy <id>就能把对应片段复制到剪贴板。这个交互比直接在终端里展示整段代码要高效率得多,因为很多片段可能几十行,直接铺到屏幕反而不方便使用。

4. 部署与运维要点

4.1 Docker 单容器部署,SQLite 数据持久化

t3code 的部署形态我选择了单 Docker 容器,原因很简单:整个服务只有一个 Node.js 进程加一个 SQLite 文件,不需要编排和集群。Docker 镜像用多阶段构建,先跑prisma generate再next build,最后用node server.js启动。SQLite 文件通过 volume 挂载到宿主机,备份就是复制文件。

FROM node:20-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile COPY . . RUN npx prisma generate && pnpm build FROM node:20-alpine WORKDIR /app COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/public ./public ENV NODE_ENV=production EXPOSE 3000 CMD ["node", "server.js"]

这里有一个值得注意的点:Prisma 需要在运行时访问 schema 文件才能执行查询。很多人部署 Next.js 项目时报prisma.generate的相关错误,就是因为镜像里漏了prisma目录。COPY --from=builder /app/prisma ./prisma这一步不能省。建议把prisma schema.prisma单独拷贝进来,避免整个源码目录进镜像导致.env泄漏。

4.2 备份与数据迁移

SQLite 的备份直接cp文件最简单,但要注意数据库可能正在被写入。稳妥的做法是用sqlite3的在线备份命令:

sqlite3 /data/t3code.db ".backup '/backup/t3code-$(date +%Y%m%d).db'"

这一步不需要停机,SQLite 在线备份采用文件锁机制,可以保证一致性。迁移场景目前不涉及,因为数据库结构还比较稳定。如果后期要加字段,我会用 Prisma migrate 生成迁移 SQL,在部署前手动执行。

运维层面的经验是:日志建议输出到 stdout,交给 Docker 日志驱动收集,而不是写文件。Next.js 自身的日志很精简,我额外在 tRPC 中间件里加了一个简单请求日志,记录 procedure 名、耗时、状态码。这样出问题的时候能快速定位是接口问题还是页面问题。

5. 常见问题与排查技巧实录

5.1 Next.js 服务端组件里调 tRPC,报错怎么办

这个是我最开始踩得比较深的坑。在 App Router 模式下,如果你在 Server Component 里直接api.snippet.list.useQuery(),大概率会拿到一个空对象或运行时报错。原因是 tRPC React hooks 依赖 React Context,服务端组件不经过 Provider,自然拿不到客户端实例。

我的解决思路是"封装 caller,不依赖 Provider"。服务端开一个createCaller,每次请求前手动创建 caller 实例:

import { createCaller } from "~/server/api/root"; import { db } from "~/server/db"; export async function getSnippetDetail(id: string) { const caller = createCaller({ db }); return caller.snippet.getById({ id }); }

这里有个更深的坑:db是全局单例,但创建 caller 时要传入当前请求的数据库实例。如果你在 Serverless 环境下部署,数据库连接管理不当会导致连接泄漏。我的实践是:SQLite 文件数据库本身会串行化写入,使用 Prisma 时必须确保prisma实例是全局唯一的,不要在每次请求时新建。可以在server/db.ts里用globalThis.prisma做缓存,避免开发模式下 HMR 导致实例重复。

5.2 FTS5 索引数据与主表不一致,搜索不到最新片段

出现这个问题的场景一般是手动改过数据库,或者用 Prisma Studio 增删数据但 FTS 触发器没有触发。排查思路很简单:先重建 FTS 索引。

INSERT INTO fts_snippets(fts_snippets) VALUES('rebuild');

FTS5 提供了rebuild命令,可以从外部内容表重新同步全部数据,比删表再插入要方便。日常使用中我建议加一个定时任务定时 rebuild,比如每天凌晨一次,防止触发器漏数据。

5.3 Zod 校验返回错误信息不友好

用过 tRPC 的人应该都遇到过:输入校验失败时,前端拿到的错误信息是英文的,字段名还是下划线格式,用户看起来一脸懵。我的做法是在 procedure 外层做统一错误处理,把 ZodError 的 issues 映射成中文提示。

export const withErrorHandler = (handler: TRPCHandler) => async (opts: ProcedureOptions) => { try { return await handler(opts); } catch (error) { if (error instanceof ZodError) { const message = error.issues .map((issue) => `${issue.path.join(".")} ${issue.message}`) .join("; "); throw new TRPCError({ code: "BAD_REQUEST", message }); } throw error; } };

这个小改动虽然不影响功能,但对"工具类应用"的体验提升很关键。个人项目的用户往往就是自己,报错信息写得清晰,之后排查问题能省不少时间。

5.4 Prisma 与 SQLite 并发写入的限制

SQLite 在单写多读方面很强,但多连接并发写会碰到database is locked错误。t3code 的使用场景里,主要写入来源是 Web 表单和 CLI,并发度很低,基本不会触发锁错误。但如果后期接入自动化导入工具,就要注意控制并发。

我采用的策略是 Prisma 连接池调整为单连接,配合 SQLite 的 WAL 模式:

datasource db { provider = "sqlite" url = "file:./data/t3code.db?connection_limit=1" }

加连接限制后,并发请求会被串行化,虽然吞吐量下降,但稳定性和一致性大幅提升。对个人工具完全够用。如果你确实有高并发需求,SQLite 的方案就得换成 PostgreSQL 了。

5.5 中文分词与全文检索体验问题

中文场景下 FTS5 默认分词器把整段中文当成一个 token,搜"定时任务"可能完全匹配不到"每五分钟跑一次"的内容。我目前的规避方案是允许用户在片段里自带英文关键词标签,搜索时优先匹配标题和标签。下一阶段我考虑引入一个自定义分词器或简单的中文拆词逻辑,但前提是不能显著增加项目复杂度。

如果是纯中文代码注释的检索,建议暂时用LIKE兜底,或者给 Tag 建单独索引,优先通过标签而不是全文检索来找片段。这个方案虽然不够优雅,但实用。

结尾:一点使用心得

t3code 从动手到部署大概花了两个完整周末,修修补补又用了一个月,现在是我日常开发里打开频率最高的工具之一。我最大体会是:像这种个人工具,技术选型不需要追新,更不需要全能,关键是想清楚"什么场景会真正用起来"。

对我来说,CLI 入口是 t3code 的灵魂。我在终端里工作的时间远比在浏览器里多,t3 find一行命令能直接把我几个月前写的代码翻出来,这个效率收益是任何花哨界面都替代不了的。如果你也想做一个类似的私人知识库工具,我强烈建议先把命令行入口做好,再去完善界面——先明确如何获取信息,再谈美观。

还有些小设计值得提:我在每个片段详情页加了一个"随机一条"按钮,查不到东西的时候点一下,时常能翻出一些自己都忘了的代码片段,有时还能间接帮我回忆起当时的设计背景。这种小功能不复杂,但对工具的"人味"提升明显。

后面如果再迭代,我计划加入片段导出为本地文件目录结构的功能,让 t3code 的数据库不至于成为数据孤岛——毕竟长期存代码的地方,最稳妥的备份永远是能直接打开看的文本文件。最后再分享一个习惯:定期用sqlite3导出全量片段到 Git 仓库,形成历史版本,哪怕数据库文件丢了也还能从 Git 历史里恢复。这个习惯成本极低,但让我对 t3code 的信任度提高了不少。

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

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

立即咨询