T3栈实战:从零构建自托管代码片段工具t3code

发布时间:2026/10/14 6:23:18
T3栈实战:从零构建自托管代码片段工具t3code
1. 为什么做 t3code被碎片化笔记逼出来的练手项目t3code 是我最近花了两周业余时间做完的一个代码片段收集工具整体基于 T3 栈Next.js TypeScript tRPC Tailwind CSS Prisma搭建。起因很简单我的本地代码碎片已经多到检索成本失控而我翻了半天那些散落在各个文件夹里的代码最后还是在聊天记录里找到的。与其继续忍不如自己动手。1.1 需求来源一个临时文件夹引发的自救写代码这些年我养成了一个坏习惯看到一段有用的代码第一反应就是复制到本地某个文件里。文件名叫新建文本文档(3).txt放在一个名为临时的文件夹里这种场景估计不少同行都不陌生。时间一长这些碎片就变成了死数据——想找的时候翻半天翻到了又缺上下文忘了当初为什么存它、它依赖什么环境、适用什么版本。我试过用本地 Markdown 按目录分类也试过各种在线 snippet 服务但要么搜索太弱要么标签体系太重要么数据不在自己手里哪天平台改版就全没了。真正让我下决心自己写工具的是一次找七个月前写过的带重试的 fetch 封装我在五个地方翻了两小时才找到。那一刻我就明白了我需要的是一个轻量、自托管、能快速存取代码碎片的工具。所以 t3code 的定位从头到尾都很清楚不做知识库不做博客不做社区只做三件事——快速保存一段代码、打上标签方便检索、一键复制回剪贴板。评论、点赞、社交分享这些功能一概不做。需求收敛到这个程度项目才不会做烂尾。1.2 选型动机为什么明知学习曲线陡还是选 T3 栈这次选型我是故意的。之前做过的几个项目都是前后端分离 手写 REST API 联调文档的套路对这种模式下的类型断裂已经忍了很久前端改了接口字段后端没跟上编译期什么都不报运行时才炸炸了还得靠肉眼对接口文档。T3 栈的核心卖点恰恰就是端到端类型安全。用它做的事是把前端调用和后端实现绑在同一套 TypeScript 类型系统里。我改了一个 tRPC 路由的入参类型前端所有调用点立刻飘红编译阶段就能抓到而不是等上线后由用户触发 500。对我来说这个吸引力足以让我接受它的学习成本。当然T3 栈不是什么银弹它适合的是数据密集型、CRUD 为主、表单交互多的 Web 应用恰好 t3code 就是这种形态。如果你要做的是重客户端交互的编辑器、实时协作工具那这套组合未必舒服。这个结论是我把项目做完之后才真正体会到的后面我会专门展开说。2. T3 栈的组合逻辑五件套各自负责什么先给不了解 T3 栈的读者画个轮廓它通常包含 Next.js 做应用框架、TypeScript 做类型系统、tRPC 做 API 层、Tailwind CSS 做样式、Prisma 做数据库访问可选地加上 NextAuth 做认证。这五件套不是随便拼在一起的每一环都有明确的职责边界理解了它们怎么咬合你才能用好这套东西。2.1 Next.js App Router 与 tRPC 的职责切分很多人一开始会困惑Next.js 自己就能写 API Route为什么还要引入 tRPC我的理解是Next.js 的职责是页面怎么渲染tRPC 的职责是数据怎么安全地流动。两者有交集但重点不同。在 t3code 里页面骨架和数据预取用了服务端组件比如 snippet 列表页的初始数据我在 Server Component 里调用了 tRPC 的 server caller 直接查库页面首屏就是完整 HTML。而所有需要用户交互触发的数据变更——新增 snippet、改标签、删除记录——都走 tRPC mutation由客户端组件发起。实际操作中你要在app/api/trpc/[trpc]/route.ts里创建一个 Next.js Route Handler把 tRPC 的请求转发到后端处理函数。这个文件是两者衔接的枢纽import { fetchRequestHandler } from trpc/server/adapters/fetch; import { appRouter } from /server/api/root; import { createTRPCContext } from /server/api/trpc; const handler (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };这段代码本身不难难的是理解 Context 是怎么把数据库实例、当前会话信息注入到每个路由处理函数里的。我建议新手把createTRPCContext里的headers()提取和db实例初始化单独抽出来看这是整个 tRPC 调用的起点。2.2 Prisma 数据建模从需求到 schema.prismat3code 的数据模型很朴素但设计时我还是认真想过的。核心表就三张用户表、片段表、标签表外加一个多对多的关联表。model User { id String id default(cuid()) name String? email String? unique image String? snippets Snippet[] tags Tag[] } model Snippet { id String id default(cuid()) title String content String language String default(plaintext) 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 authorId String author User relation(fields: [authorId], references: [id]) snippets SnippetTag[] unique([authorId, name]) } model SnippetTag { snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) snippetId String tagId String id([snippetId, tagId]) }几个关键决策想说明一下。语言字段language我没用枚举而是普通字符串因为代码高亮会涉及的语言列表很长用枚举反而要频繁迁移表结构。Tag上加了unique([authorId, name])保证同一个用户的标签不会重复但不同用户可以有同名标签——这是多租户设计里很容易忽略的点。关联表里两个外键都加了onDelete: Cascade删 snippet 时自动清掉关联记录不然会有孤儿数据。2.3 Tailwind 与 shadcn/ui写样式的节奏感样式部分我用的 Tailwind CSS 加上 shadcn/ui 的组件。说实话用 Tailwind 的头两天我是有点抵触的类名一长串HTML 看起来像被 JS 压缩过。但用顺手之后发现它的优势不需要在 CSS 文件和各种 class 命名之间来回跳改样式就在元素上改效率确实高。shadcn/ui 我主要拿来做了下拉菜单、弹窗、输入框这些基础组件。它的特点是组件代码直接复制进项目而不是装成 npm 包所以你可以完全掌控样式不会被库的内部实现卡住。我在 t3code 里给代码片段详情做了个弹出式预览用的就是 shadcn 的 Dialog改动起来非常顺手。有个细节值得提Tailwind 的暗色模式。默认情况下 Tailwind 的dark:变体是根据系统偏好判断的但 t3code 需要用户手动切换所以我在tailwind.config.ts里设置了darkMode: class然后写了一个小的ThemeToggle组件把主题状态存到localStorage并在html根节点上切换dark类。这样写比默认方案多花十分钟但用户体验完全不一样。2.4 NextAuth 登录第三方授权的接入记录认证层面我选了 NextAuth主要图它接入 GitHub 授权登录足够省事。配置里设置好 client ID 和 secret在app/api/auth/[...nextauth]/route.ts里挂上相关配置即可。踩过的坑有两个。第一个是回调地址本地开发时回调地址填http://localhost:3000/api/auth/callback/github部署到线上后必须改成线上域名否则授权会一直报 redirect_uri 不匹配。第二个是会话策略t3code 这种场景我用了 JWT session 而不是数据库 session因为每个请求都要在读库之前确认用户身份JWT 省了查询开销但要注意 JWT 里不要塞太多数据存个 id 和名字就够了。3. 核心功能落地t3code 的 CRUD、标签与检索如果说前面是搭骨架那这一章就是填肉。t3code 真正让我花时间思考的不是能不能增删改查而是怎么在类型安全的前提下把查询、标签、搜索这些功能组织得干净。3.1 tRPC 路由划分按资源拆还是按场景拆tRPC 的路由设计有两种常见思路按数据库资源拆snippet router、tag router或者按业务场景拆listSnippets、createSnippet。我最后用的是两者混合——基础 CRUD 按资源拆跨资源的复杂查询单独拆一个 search 路由。export const snippetRouter createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), tagId: z.string().optional(), keyword: z.string().optional(), }) ) .query(async ({ ctx, input }) { const where buildWhereClause(input, ctx.session?.user?.id); const [items, total] await Promise.all([ ctx.db.snippet.findMany({ where, include: { tags: { include: { tag: true } } }, orderBy: { createdAt: desc }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }), ctx.db.snippet.count({ where }), ]); return { items, total, page: input.page }; }), });这里有个小技巧把 where 条件的构建抽成buildWhereClause函数因为list和search两个 query 都用到相同的过滤逻辑不抽的话代码会重复。类型上input经过 zod 校验后是精确推断出来的所以在buildWhereClause里拼 Prisma 查询条件时字段名错了、类型不匹配编译器都会报错。3.2 多标签系统的存取逻辑标签的存取是最能体现多想一步价值的地方。保存一个 snippet 时用户可能输入好几个标签有的标签已存在有的得新建。我的做法是先按当前用户查已存在的标签名把新的批量创建然后建立关联。const saveTags async (db: PrismaClient, authorId: string, tagNames: string[]) { const uniqueNames Array.from(new Set(tagNames.map((t) t.trim()).filter(Boolean))); const existing await db.tag.findMany({ where: { authorId, name: { in: uniqueNames } }, select: { name: true }, }); const existingNames new Set(existing.map((t) t.name)); const newNames uniqueNames.filter((n) !existingNames.has(n)); if (newNames.length 0) { await db.tag.createMany({ data: newNames.map((name) ({ name, authorId })), }); } const allTags await db.tag.findMany({ where: { authorId, name: { in: uniqueNames } }, select: { id: true, name: true }, }); return allTags.filter((t) uniqueNames.includes(t.name)); };注意createMany是批量创建设标签时如果一次性提交几十个性能上比逐个 create 好很多。createMany在 SQLite 的 Prisma 版本支持可能受限所以在本地开发时要注意数据库方言的差异后面章节我会专门说这个。3.3 全文搜索的朴素实现t3code 这个体量上 Elasticsearch 或者上 pg 的全文索引都属于杀鸡用牛刀。我的搜索策略就是标题模糊匹配 内容模糊匹配 标签命中三个条件用 OR 连接关键词拆分成词后用contains逐个匹配。const buildWhereClause (input: { keyword?: string; tagId?: string; }, userId: string) { const conditions [{ authorId: userId }] as Prisma.SnippetWhereInput[]; if (input.keyword) { const keywordWords input.keyword.trim().split(/\s/); conditions.push({ OR: keywordWords.map((word) ({ OR: [ { title: { contains: word } }, { content: { contains: word } }, { tags: { some: { tag: { name: { contains: word } } } } }, ], })), }); } return { AND: conditions }; };这个方案的优点是简单直接缺点是数据量到几千条之后会变慢。到那时候再切 PostgreSQL 的全文检索也不迟Prisma 支持search查询模式迁移成本不算高。我个人的原则是不在项目早期为用不上的性能做复杂设计。3.4 代码高亮与纯客户端组件的边界代码高亮我用的 Shiki而不是 Prism。原因很实在Shiki 的 highlighter 是基于 TextMate 语法的各种语言的语法覆盖率比 Prism 高不少而且可以直接在服务端调用生成纯 HTML 返回给浏览器不用加载一堆 JS。但实际上我把代码展示组件设成了use client因为要处理复制按钮的点击事件和暗色模式切换。这里有个 Next.js 开发中容易犯的错整个组件加use client之后Shiki 的高亮逻辑如果能放服务端就别放客户端否则打包体积会暴涨。我的做法是高亮在服务端完成把 HTML 字符串传给客户端组件客户端只负责展示和绑定事件。// 服务端获取高亮后的 HTML const highlighted await shiki.highlight(code, { lang: language, theme: github-dark });这个在服务端做重活在客户端做交互的思路是 App Router 下写组件的关键准则。凡是纯展示的、计算结果不依赖浏览器 API 的内容都尽量在服务端算好。4. 部署与线上排查从本地能跑到生产可用每次项目做到本地能跑的时候我都会觉得胜利在望然后被现实教育。t3code 上线过程中遇到的三个问题我觉得值得单独写一节因为它们非常典型。4.1 种子数据与本地开发环境t3code 的本地开发环境我用的是 SQLite 数据库也就是 Prisma schema 里的 provider 暂时设为sqlite。好处是零配置一个文件就能跑起来。代价是很多只支持 PostgreSQL 的特性不能用比如createMany在旧版 SQLite provider 下不支持我在写第 3 章的标签逻辑时就踩到了。种子数据我用了一个独立的 seed 脚本配合tsx来跑npx prisma db push npx tsx prisma/seed.tsdb push和db migrate的区别值得说一句db push适合快速同步 schema 到数据库适合开发期正式环境还是建议用migrate生成迁移文件否则生产环境的表结构变更没有记录出问题很难回滚。4.2 构建期类型检查与打包错误next build是每个 Next.js 项目的照妖镜。我的第一次构建失败原因是 ESLint 规则把一段包含any的代码拦住了。具体是 tRPC 的ctx.session?.user?.id在某个分支里被推断成了any我图省事没有显式标注类型结果 eslint 的no-explicit-any直接报错。修法也很简单在 zod input 里把tagId、keyword这些字段都显式声明类型确保buildWhereClause的参数是确定的。这里我学到的是T3 栈把类型安全做得很严是好事但你自己写的代码如果到处any等于把这条防线亲手拆了。4.3 一个 500 错误的完整排查链路上线第二天用户反馈在某个标签页下点击 snippet 详情页面直接 500。我的第一反应是查看部署平台的日志但生产日志只显示Error: PrismaClientKnownRequestError ...具体信息不全。排查过程是这样的第一步我在本地用同样的数据路径复现。先看标签页能正常打开说明list查询没问题再点详情本地居然没有复现。这就说明问题跟环境相关很可能出在数据库方言差异上。第二步我怀疑是 Prisma 的分页查询里用了skip和takeSQLite 和 PostgreSQL 在 offset 行为上没有差异所以排除。第三步我去看生产环境数据库的关联数据。发现标签页详情里某个 snippet 的language字段存了一段很长的文本——我在某个版本里把language 自动识别的测试数据写进去了而 Shiki 拿这个不存在的语言名去loadLanguage抛了异常。根因就是脏数据 高亮库的容错缺失。修复是我在 Shiki 调用前加了一层白名单校验语言名不在内置列表里就回退到plaintext。这个修复提醒我任何依赖外部配置的库都要对输入数据做校验不能假设数据永远干净。4.4 数据库从 SQLite 切到 PostgreSQL本地开发用 SQLite线上用 PostgreSQL这是不少小项目的常见配置但切换过程有个坑Prisma 的provider从sqlite改成postgresql之后之前生成的客户端是不认账的。你必须重新执行npx prisma generate npx prisma db pushgenerate是重新生成 Prisma Clientdb push是同步数据库结构。如果你在 schema 里用了 SQLite 特有的类型比如某些字段的db.Text切换后要检查兼容性。我实际遇到的问题是String类型在 SQLite 下默认没有长度限制声明在 PostgreSQL 下会映射成text这没问题但如果哪天某个字段声明了db.VarChar(255)而你的数据长度超过了 255写入就会报错。建议在切换之后把关键路径的增删改查都跑一遍冒烟测试。5. 项目做完之后我对 T3 栈适用边界的重新判断t3code 做完我的收获不只是这个工具本身更是对 T3 栈的适用边界有了切身体会。有些感受是看文档看不出来的。5.1 类型安全带来的重构安全感最直观的体验是重构效率。我中途把 snippet 列表的分页从前端传 page/pageSize改成了用 cursor 游标分页虽然字段变了但所有调用方在编译期就全部暴露了问题。改完一个 tRPC route所有用了旧字段的地方全部飘红照着改一遍就完事根本不需要在前后端之间反复确认。这种重构安全感是 T3 栈最值钱的部分。如果你维护过一个半年以上的全栈项目一定经历过改接口字段、前端忘了适配、上线后数据不显示这种事。T3 栈用类型系统把这个风险前置到了编译期省掉的是整个团队在联调时消耗的时间。5.2 它不适合的场景重客户端交互与实时推送但我也要说实话T3 栈不是万能药。如果你要做的是一个类似在线代码编辑器的产品前端需要大量本地状态、实时协作、WebSocket 推送那 Next.js 加 tRPC 这套组合并不会帮你太多——这些场景的核心在客户端服务端只是数据供给方类型安全带来的收益会被复杂的客户端状态管理稀释。另外如果你的团队已经有一套成熟的 REST API 和对应的 SDK 工具链那引入 tRPC 需要权衡它和 OpenAPI 生态不互通也没有现成的跨语言客户端。纯 TypeScript 全栈团队用 T3 栈很香但要接第三方、要对外开放 API还是建议单独维护一套接口层。5.3 t3code 的后续方向t3code 目前已经跑起来了我的使用频率明显高于之前的任何一款 snippet 工具。接下来的规划有两个方向一是给代码片段加 AI 摘要在保存时自动生成一段这段代码解决什么问题的描述解决我现在存了但忘了为什么存的问题二是做一个浏览器插件在网页上选中代码后右键直接存入 t3code配合已有的标签体系形成一个顺手的内容收集闭环。这两个功能都不大但能把 t3code 从手动搬运工变成半自动收割机。等这两个功能都上线之后我再回来分享一次实战经验。最后分享一条个人体会做工具类项目最怕的不是技术难而是需求发散。t3code 之所以能在两周内做出来恰恰是因为我一开始就把不做社区、不做评论、不做知识管理写在了项目 README 的顶部。每一次想加功能的时候先问一句这功能会不会让 t3code 偏离代码碎片收集器五个字的定位问完之后大部分需求自己就消失了。剩下的才是真正值得写进代码里的东西。