☰
t3code:本地优先的代码片段管理器,用 T3 Stack 打造高效开发工具
2026/10/8 16:21:16 网站建设 项目流程

t3code 这个名字摆在你面前,你会想到什么?是某个游戏的版本号,还是…… TypeScript 技术栈的又一个轮子?说实话,我当初给这个项目起名的时候,两条都占了。t3code 是我从内部工具改出来的一个开源项目:一个本地优先、键盘驱动的代码片段管理器。它专门解决“代码写多了,但常用片段散得到处都是”的痛点。你不需要注册账号,不用启动 Docker 容器,甚至不联网也能用;打开浏览器,进到页面,按一个键就能把当前需要的代码段复制到剪贴板。如果你是前端、全栈工程师,或者平时要写大量脚本的运维,这篇文章会告诉你 t3code 是怎么想的、为什么这么设计,以及如果你也想做一个类似工具,核心功能要怎么落地。

1. t3code 到底是什么:一个被搜索框统治的工具

1.1 从名字拆解定位:T3 不只是一个版本号

很多人在 GitHub 上看到 t3code 的仓库,第一眼都以为这是 T3 系列的第三个版本之类的项目。其实名字里的 T3 指的是 T3 Stack,也就是当下 TypeScript 社区里很流行的一套组合:Next.js、TypeScript、tRPC、Prisma,再加上 TailwindCSS。code 就直白多了,这个项目管的就是代码片段。

起这个名字还有一个私心:T3 就像“第三层的工具”,有点“第三个维度”的意思。我自己的定义是,它应该成为你写代码时离不开的一个周边工具,而不是需要专门打开一个重型 IDE 去维护的东西。定位很简单:打开浏览器,输入几个字母,找到片段,复制走人。整个过程应该在五秒以内完成,否则这个工具就没有存在意义。

t3code 最初是我自己笔记本里的一个单页测试项目,后来发现每天能用到十几次,才慢慢长成了现在的样子。它的核心不是数据库,不是 UI,而是“检索”这个动作。所有功能都围绕一个居中的搜索框展开,这和很多启动器工具的思路是一致的。

1.2 它解决的真正问题:代码复用与碎片知识管理

你回想一下自己平时写代码,有多少次是在重复做这几件事:写防抖函数、封装请求、处理时间格式化、写一个深拷贝、找 redis 管道命令的模板、手写 SQL 分页。很多代码你明明写过,但是不记得写在哪里,于是去翻 Git 历史、翻自己的 Gist、翻微信收藏,最后大概率选择重新写一遍或者去 Stack Overflow 搜。

这里面最浪费的不是那几分钟,而是你被打断的思路。我把这类问题统一归为“代码片段的碎片化”:片段存在于多个地方,但它们没有统一的入口。t3code 要做的就是把散落的东西收拢到一个文件驱动、全文可搜的本地数据库里。

那些看起来“技术含量不高”的知识点反而是最值得沉淀的。比如某个第三方 SDK 的调用方式、某条正则、某个 docker compose 的配置片段。这些内容你一年可能只用几次,但每次都需要花功夫去回忆,如果有一个工具能在几秒内把它调出来,效率提升是实打实的。

1.3 与常见工具的边界:为什么不是另一个 Gist

做这个项目之前,我认真对比过 Gist、Notion、语雀、Raycast Snippets、utools 剪贴板这些方案。它们都有各自的优势,但我始终觉得缺一块拼图:Gist 是纯在线服务,依赖网络和账号,虽然可以 API 调用,但并不适合做本地秒开的瞬时工具;Notion 的数据库很强,但打开速度、快捷键响应和代码高亮体验都不太对;Raycast 和 utools 的片段功能倒是很贴近,但它们都依附于特定平台,而且数据格式封闭,没法自己控制。

t3code 的选择是“本地优先、Web 承载”:数据放在本机 SQLite 文件里,通过浏览器访问,不依赖任何云服务。这个模式下数据完全属于你,可以随时做一个文件备份,也可以自己写脚本去分析。它不适合同事协作,也不适合做公开代码分享,它就是给个体开发者的“第二大脑”做的。

2. 技术选型与架构思路:为什么是这套组合

2.1 技术栈全景:最少的心智负担

t3code 用的是标准 T3 Stack。Next.js 负责页面和 API 路由,tRPC 负责端到端类型安全,Prisma 负责操作 SQLite,Tailwind 负责样式。这套组合最大的优势不是性能,而是“少”:没有单独的前后端项目,没有 OpenAPI 文档要维护,没有 DTO 要写,类型天然联通。

我用一个很直白的例子解释这件事:普通的前后端联调,你在前端定义一个接口参数,后端还要再写一遍类型,跑起来之后可能因为字段名不一致直接 404。用 tRPC 之后,你在后端定义查询方法,前端 import 一个 hook,参数和返回值都有类型推导。如果后端字段改了,前端编译直接报错,不用等到运行时才发现。

对于 t3code 这种体量的小工具,这套技术栈非常合适。它不需要 K8s,不需要微服务,不需要 Redis,一个 Node 进程加一个 SQLite 文件就够了。考虑到后续可能做浏览器扩展或者命令行工具,Next.js 还能顺手提供一套 HTTP 接口,方便其他客户端调用。

2.2 本地优先:为什么坚持不引入账号系统

一开始也有人建议我加一个登录功能,做成在线服务。我拒绝了,原因有三点:第一,代码片段是很私密的内容,很多人不愿意把自己的数据库放在第三方服务器上;第二,登录、注册、找回密码这一套带来的开发成本和维护成本会立刻膨胀;第三,如果服务有一天挂了,用户的工具也就挂了,这对一个“效率工具”来说是不可接受的。

本地优先的架构让 t3code 变成了一种“打开即用”的工具。你只需要npm install && npm run dev,浏览器打开http://localhost:3000,数据文件自动生成。没有环境变量,没有服务器配置,没有收费计划。我甚至给它规划了最极端的用法:把整个项目拷贝到 U 盘里,在任何一台装有 Node 的电脑上都能现场运行。

代价也是明显的:你没法在手机上和办公室里同时维护一份数据。所以我做了一层数据导入导出,后续也会支持 Git 同步。本地优先不代表不能同步,而是同步机制要建立在用户自己可控的基础之上。

2.3 检索能力的实现:SQLite FTS5 才是隐藏主角

很多类似的工具会为了检索直接引入 Elasticsearch、Meilisearch,或者用 JS 实现一个内存索引。在我看来这些都是杀鸡用牛刀。SQLite 自带的 FTS5 扩展足够支撑几万条片段的搜索,而且它是数据库内置能力,不需要额外跑一个服务。

FTS5 的原理是把文本拆成 token,建立倒排索引。搜索时通过 MATCH 语法去匹配,还能给结果打分排序。在 t3code 里,我把每一条片段的标题、代码、注释、标签拼在一起建立了全文索引。这样你就可以搜索“防抖 函数”或者“docker compose 端口”这样的组合关键词。

不过 FTS5 默认的 unicode61 分词器对中文支持不太行,它会把中文文本拆成单个汉字,搜“数组去重”时得到的结果往往不理想。我后来切换到 trigram tokenizer,它会把连续三个字符作为一个 token,能匹配连续的中文子串。代价是最少三个字符才能命中,后面我会讲怎么补一个“短词降级”方案。

3. 从零搭一个 t3code:手把手实现核心功能

3.1 初始化项目与数据表设计

如果你也想复刻这个项目,直接用官方的 T3 脚手架最快。我用的是 create-t3-app,一路选择 Next.js App Router、Tailwind、Prisma、tRPC,其他不需要的全关掉。

npm create t3-app@latest t3code cd t3code npm install @tanstack/react-virtual shiki react-hotkeys-hook npx prisma init --datasource-provider sqlite

数据库模型我精简到了两张表:Snippet 和 SnippetFts。后者是 FTS5 的虚拟表,用来做全文搜索。Prisma 里的 snippet 模型长这样:

model Snippet { id String @id @default(cuid()) title String language String @default("plaintext") code String note String? tags String @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

注意,tags 我故意存成 JSON 字符串,而不是单独建一张多对多关联表。因为 t3code 的核心场景是快速存取,标签通常不会单独维护,直接用逗号分隔数组就够用了。如果后续要做标签管理再拆表也不迟。

FTS5 虚拟表不能直接通过 Prisma schema 声明,我是在 migration 里用原生 SQL 创建的:

CREATE VIRTUAL TABLE IF NOT EXISTS Snippet_fts USING fts5( title, code, note, tags, tokenize = 'trigram' ); CREATE TRIGGER snippet_ai AFTER INSERT ON Snippet BEGIN INSERT INTO Snippet_fts(rowid, title, code, note, tags) VALUES (new.rowid, new.title, new.code, coalesce(new.note, ''), new.tags); END;

触发器是重点。每次对主表做增删改,FTS5 索引都会自动同步,不用在业务代码里手动维护。刚开始写这个项目的时候我忘了删触发器,导致删除片段后搜索还能搜到,排查了很久才发现问题。

3.2 片段录入:让“粘贴”也叫持久化

新建片段页面的交互被我砍到了最简:标题、代码、语言、标签、备注。默认语言是plaintext,但我会根据代码内容做一次“猜语言”:如果标题里有.js,就设置成javascript;如果第一行有import,就猜typescript;如果包含select和from,就猜sql。这个启发式逻辑虽然粗糙,但足以减少百分之八十的手动选择操作。

保存按钮绑定Ctrl+Enter。保存时把 tags 数组序列化成 JSON 字符串写进数据库。这一步其实没有太多技术含量,但我建议你的保存操作一定要做成“可撤销”的体验:编辑页面左侧是文本框,右侧是预览,保存后立刻跳回首页,避免用户产生“是不是没存上”的疑虑。

3.3 全文搜索与标签过滤

搜索是 t3code 的脸面,所以这个路由我写得最认真。在服务端定义 tRPC query:

searchSnippets: publicProcedure .input(z.object({ query: z.string().default(''), language: z.string().optional() })) .query(async ({ ctx, input }) => { const q = input.query.trim(); if (!q) return ctx.prisma.snippet.findMany({ orderBy: { updatedAt: 'desc' }, take: 50 }); // 短词降级:不足三个字符,用 LIKE if (q.length < 3) { return ctx.prisma.snippet.findMany({ where: { OR: [ { title: { contains: q } }, { code: { contains: q } }, { note: { contains: q } }, ], }, take: 50, }); } const results = await ctx.prisma.$queryRaw` SELECT s.* FROM Snippet s JOIN Snippet_fts ON Snippet_fts.rowid = s.rowid WHERE Snippet_fts = ${q}::query ORDER BY bm25(Snippet_fts) LIMIT 50; `; return results; })

这里最关键的是bm25()排序函数,它能按相关性排序,原理类似于搜索引擎中的 BM25 算法:关键词频率越高、文档越短分数越高。SQLite 默认会把 FTS5 查询出的结果按 rowid 排,不加这个函数你会看到完全不相关的结果排在前面。

前端部分,搜索框的 onChange 会触发 tRPC 的useQuery,配合useDebounce做三到五秒的输入防抖,避免每敲一个字母都打一次数据库。如果你的片段量很少,其实不用防抖,反而更顺手。

3.4 快捷键体系:键盘完成跳跃动作

t3code 的快捷键设计遵循一个原则:“所有常用操作都不能离开键盘”。我实现了以下按键:

  • 按n新建片段
  • 按/聚焦搜索框
  • j/k在结果列表移动
  • Enter复制当前选中的片段到剪贴板
  • Esc清空搜索并回到首页

实现方式是在全局监听 keydown,但必须过滤输入框内的按键。不然你在文本域里敲一个/,结果焦点被抢到搜索框里,体验直接崩掉。我的处理是检查事件目标是不是 input、textarea、select 其中之一,是的话直接 return。

useEffect(() => { const handler = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return; if (e.key === 'n') { e.preventDefault(); router.push('/new'); } if (e.key === '/') { e.preventDefault(); searchRef.current?.focus(); } if (e.key === 'j') move('down'); if (e.key === 'k') move('up'); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [list]);

复制到剪贴板用的是navigator.clipboard.writeText。这里有个小坑:浏览器要求页面必须处于焦点状态才能读取剪贴板权限,如果用户焦点在开发者工具窗口,复制可能会失败。我写了一个 fallback,用document.execCommand('copy')兜底,临时代入一个 textarea。

3.5 Git 自动备份:不怕数据库文件走丢

本地优先的数据库有一个隐患:main.db可能因为误删、磁盘损坏或者用户手滑把它删了。所以在 t3code 里我做了一条最朴素的备份策略:每次启动时,把数据库文件复制到backups/目录,文件名带上时间戳。

import { execSync } from 'child_process'; import fs from 'fs'; export function backupDatabase() { if (!fs.existsSync('backups')) fs.mkdirSync('backups'); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); execSync(`cp data/main.db backups/snippet-${stamp}.db`); }

如果你想自动同步到远程,还可以在项目里初始化 Git 仓库,把data/、backups/目录里的数据库提交上去。注意不要把node_modules和.next提交进去,我通常会用.gitignore排除。这样即使换电脑,也能通过git clone拉回所有片段的历史版本。唯一的风险是 Git 对不断变化的二进制数据库文件会产生大量历史体积,所以备份目录里的文件最好定期清理,保留最近一百个就够了。

4. 实操过程:踩过的坑与排查技巧

4.1 FTS5 中文搜索漏结果:三字符限制的应对

trigram tokenizer 可以匹配中文子串,但索引只包含长度至少为三的 token,所以搜索“防抖”这类两字词时会直接匹配不到。我第一次遇到的时候以为是编码问题,后来查 SQLite 文档才发现 tokenize 的行为。

解决思路很简单:查询长度小于 3 的时候,用传统的 LIKE 代替 MATCH。虽然 LIKE 是遍历扫描,但片段库的数据量通常只有几万条,扫描一次也就是几十毫秒。如果想进一步优化,可以给title和code加上索引,并对小写化后的标题做前缀匹配。

另外,针对英文搜索,我额外在代码里做了小写归一化。FTS5 默认不区分大小写,但 LIKE 在 SQLite 里默认只对 ASCII 字符不区分大小写,所以短词 fallback 时需要把用户输入的 query 转成小写再匹配。

4.2 tRPC 查询缓存导致新增片段不刷新

这是典型的新手坑。tRPC 底层依赖 react-query,react-query 默认会缓存查询结果。第一次进入首页拉取片段列表后,你再新建一个片段,返回首页时用的还是旧缓存,新片段根本看不到。

解决办法是在 mutation 的onSuccess里显式让相关 query 失效:

const utils = trpc.useUtils(); const createSnippet = trpc.snippet.create.useMutation({ onSuccess: async () => { await utils.snippet.findAll.invalidate(); router.push('/'); }, });

注意这里一定要用await等待 invalidate 完成再跳转,否则上了列表页,数据还没失效,依然显示旧结果。第一次做的时候我没加 await,十个用户里还有一两个人复现了问题,后来加了个useEffect配合订阅才解决,其实不如一开始就写对。

4.3 代码高亮库的体积陷阱

为了让代码片段看起来舒服,我试过三个方案。最初用的 react-syntax-highlighter,好用,但构建产物重了一百多 KB,而且是同步打包,首屏时间明显变长。后来换成 Prism 的轻量封装,体积倒是下来了,但很多新语言的语法定义更新不及时。

最终我选了 Shiki,核心原因是它的主题渲染质量和 TextMate 语法的兼容性。Shiki 的包本身不算小,但可以通过动态 import 按需加载语言:

const highlighter = await import('shiki').then(m => m.createHighlighter({ themes: ['github-light'], langs: ['typescript', 'javascript', 'sql', 'bash', 'python'] }) );

这样只有用户选择了相应语言,才会加载对应的语法文件。首屏只要加载一个默认语言就够。代码渲染完成后以 HTML 字符串返回,前端用dangerouslySetInnerHTML插入。唯一要小心的是 XSS 风险,好在我们只展示自己数据库里的代码,但如果你接入多人协作,还是应该用 DOMPurify 过滤一遍。

4.4 快捷键冲突:小心浏览器的保留键位

快捷键设计不能太贪心。我一开始想把Ctrl+P作为“跳到上一个片段”,结果浏览器直接弹出了打印对话框。类似的还有Ctrl+W(关标签页)、Ctrl+T(新建标签页)、Ctrl+Shift+I(开发者工具),这些都应该尽量避开。

现在的方案是只用两类快捷键:一类是普通字符键(n、/、j、k),一类是Ctrl+Enter这种非保留组合。如果未来要加更多组合键,一定要先用event.preventDefault()阻止默认行为,但也不要覆盖浏览器刷新、关闭标签页之类的核心操作,否则用户会很愤怒。

4.5 虚拟列表高度跳动问题

当片段数量到了五六千条,一次性渲染所有列表项就开始卡了。我给结果列表接入了@tanstack/react-virtual,只渲染可视区域内的行。这个库用起来很顺,但要处理动态行高的问题。

代码片段列表里每一条预览内容长度不一样,如果 estimateSize 固定写死成 80px,实际内容高了就会被裁掉。我最终的配置是:

const rowVirtualizer = useVirtualizer({ count: snippets.length, getScrollElement: () => scrollRef.current, estimateSize: () => 96, overscan: 8, measureElement: (el) => el.getBoundingClientRect().height, });

然后在列表项上绑定ref={rowVirtualizer.measureElement}。注意如果列表项被组件分隔,测量会有偏差,一般建议把 measureElement 放在最外层的 item 容器上。虚拟化之后,万级的片段滚动起来依然流畅,但代码高亮本身的开销才是真正瓶颈,我现在只在高亮预览区域渲染前五十条结果。

4.6 数据导入导出:触发器不生效的诡异场景

t3code 支持导出 JSON 备份,当然也支持导入。导入逻辑一开始很简单:把每个 JSON 对象直接prisma.snippet.create()进去,结果导入完成之后搜索一片空白。

排查之后发现原因:FTS 触发器是绑定在同一个连接上的,如果你用 Prisma 的事务批量插入,每个插入确实应该触发。但我在导入时是先清空原表并删除了 FTS 记录,然后批量插入,这时有些 SQLite 版本的触发器在同一个事务内不会立即更新 FTS 索引。

解决办法是在导入完成后手动重建索引:

INSERT INTO Snippet_fts(rowid, title, code, note, tags) SELECT rowid, title, code, coalesce(note, ''), tags FROM Snippet;

或者更省事,导入时直接使用$queryRaw批量插入,绕开 Prisma 的影响。这件事也提醒我,凡是依赖触发器做同步的表,导出导入逻辑都必须在测试环境里多验证一遍。

5. 扩展思路:从单机工具到团队知识库

5.1 多设备同步方案怎么选

t3code 目前是单机优先,但如果你真的想多设备同步,有三个方案可以选。最简单的就是 Git 同步:在项目目录里初始化 Git 仓库,数据库文件定期提交,换电脑时 clone 下来。这个方案的缺点刚才说了,二进制文件冲突解决很痛苦,如果你同时在两台设备上各自新增了片段,第三次 pull 时大概率会遇到冲突。

如果你想引入真正的数据库同步,可以把 SQLite 换成 Postgres,然后用 Supabase、Neon 这类云服务作为后端。这个选择会带来账号体系和网络依赖,但好处是支持多人同时访问,团队协作场景也能覆盖。中间方案是保留 SQLite 作为本地缓存,后端只做文件同步,类似 Obsidian 的插件式同步,但对开发者来说还是要搞一套鉴权体系。

我的建议是:如果你不是特别需要团队分享,就继续用 Git 同步,最多配合一个定时任务git push。不要为了一个片段工具过早引入分布式数据库,复杂度会迅速吞噬掉它原本的效率优势。

5.2 给命令行和编辑器开个口子

单个 Web 页面覆盖不了所有使用场景。我在 t3code 里加了一个轻量 API,运行在同一个 Next.js 服务上,允许通过 HTTP 请求搜索片段:

curl 'http://localhost:3000/api/search?q=debounce&format=text'

返回结果默认是简洁文本,方便直接作为命令行输出。理论上你可以用更高级的姿势:在 VS Code 里配置一个快捷键,调用这个本地接口并把返回结果插入到当前光标处。这样 t3code 就不只是一个“浏览器里的剪贴板”,而是一个可以接入任何编辑器乃至 AI Agent 的本地知识库。

我现在比较喜欢的是在终端里写t3 debounce,立即得到已经格式化成粘贴片段的代码,再配合 shell 的管道把结果直接写进文件。这个思路对于运维人员和偶尔写脚本的人特别友好,毕竟你不可能为了找一个 redis 命令专门打开浏览器。

5.3 后续可能的新玩法和方向

t3code 的下一版我考虑加两个功能。第一个是模板变量:在片段里定义${name:默认值}这样的占位符,复制的时候自动弹出一个小输入框让你填写变量,适合做 async function、函数注释模板这种需要参数化的代码。第二个是本地大模型补全:基于你已经沉淀的片段,用本地或者云端模型生成新代码,相当于把你个人代码风格变成一个小模型。

另外,标签体系也可以做得更智能,比如根据语言自动统计使用频次,每周生成一个“最常复制片段”排行,帮助用户清理不再需要的内容。我注意到一个普遍现象:代码片段管理器最大的敌人不是搜索,而是冗余。用户往里面堆了太多吃灰片段,最后反而把真正常用的淹没了。所以 t3code 以后可能会增加“冷数据提示”,比如半年没用到的片段自动标记为待归档。维度很长远,但目前整个项目的核心已经立住了,它就是我每天打开浏览器时第一个停留的标签页。

踩过这么多坑之后,我最大的感受是:开发工具类项目,不要贪多求功能,真正决定你能不能持续用下去的,始终是那一两条最核心的路径——输入快、搜得准、复制顺畅。你对快捷键、数据安全和搜索质量的每一个细节打磨,都会在日常使用中一次又一次地回报你。如果你也有类似的一堆堆在收藏夹里的代码碎片,不妨照着上面的思路,自己动手做一个 t3code,它会改变你整理代码的习惯。

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

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

立即咨询