1. t3code立项:一个被“复制粘贴”逼出来的内部工具
如果你跟我一样,经历过“从旧项目里拷贝一份tsconfig和tailwind配置,再删几十个用不上的文件,最后还要把路由改一遍”这种循环,你大概能理解为什么我会做t3code。说出来有点不好意思,这玩意儿最初不是奔着开源去做的东西,纯粹是为了治我自己的“手脚不协调”——每次新建一个演示项目,都要花40分钟搭骨架,真正写业务的时间反而被挤没了。
t3code的定位很朴素:它是一个轻量的代码片段管理与项目模板速生成工具。说白了,给团队内部用,解决三件事:第一,把散落在微信聊天、个人笔记、GitHub Gist里的常用代码统一收起来;第二,把“创建新项目”时需要的那套标准骨架沉淀成带变量的模板,点一下就能生成;第三,给经常写示例代码的同事一个不用开VS Code、浏览器里就能查代码的入口。
为什么我不直接用一个现成的在线代码托管平台?说实话,GitHub Gist、CodePen这类公共平台做演示很方便,但问题是代码片段经常带内网域名、私有接口地址、甚至是调试用的临时密钥,往公共站点一贴就是事故。自建一个局域网内访问的片段库,权限可控、数据在自己手里,这才是t3code没有半路夭折的关键原因。
工具最终落地在T3 Stack上,也就是Next.js + TypeScript + Tailwind CSS + tRPC + Prisma这一套组合。选择这个技术栈,不是跟风,而是因为它极其贴合“内部工具”这个场景——开发速度足够快,类型安全覆盖从前端到数据库的全链路,后期维护的人不需要看懂太多花哨的架构。下面我会把整个项目的设计思路、踩坑经历和实现细节展开聊一聊,给正准备做类似工具的读者一个完整的参考。
2. 技术栈选型:为什么偏偏是T3 Stack,而不是纯Next.js或Express
2.1 T3 Stack在内部工具场景的优势
先交代一下背景:t3code的需求变更非常频繁。同事今天说“片段里要能存JSON”,明天又说“标签能不能支持子分类”,后天可能还要加上“模板变量自动替换”。这种场景下,最怕的就是前后端接口各写一套类型定义,字段改一个名字,两端同步花半小时。
T3 Stack的核心魅力恰恰在于tRPC。它让前后端像调用本地函数一样调用API,类型定义直接从服务端推断到客户端。我改一段数据库字段,前端编译器立刻能告诉我哪里报错。这种体验在内部工具开发里太重要了,因为内部工具的迭代频率远比面向用户的C端产品高,我没精力在调接口上浪费一个下午。
当然,也有人会说,只用Next.js的API Route也能做到类型共享,无非是自己写一个ApiResponse<T>的泛型,然后在两边引用同一个types.ts。这个说法没错,但实际做下来你会发现,手写类型体操的代码量一点不少,而且一旦某个接口返回结构变得复杂,泛型嵌套瞬间就成了灾难。tRPC的procedure机制直接把响应类型挂在路由定义上,客户端怎么用、参数是什么、返回是什么,全是编译期确定的,跑都跑不起来的错误在保存文件那一刻就爆出来了。
2.2 各成员在项目里扮演的角色
具体到t3code里,这套技术栈分工是这样的:
| 技术 | 在t3code中的职责 |
|---|---|
| Next.js(App Router) | 负责页面渲染、路由、服务端渲染片段预览页 |
| TypeScript | 全链路类型约束,保证代码片段元数据不会带错字段 |
| Tailwind CSS | 快速搭建后台管理界面,深浅色主题切换成本低 |
| tRPC | 片段CRUD、标签管理、模板生成等所有前后端通信 |
| Prisma | 操作SQLite(开发)/ PostgreSQL(生产)的数据模型 |
| Zod | 和tRPC内置联动,做服务端入参校验 |
这个组合里我特别注意了两个点。第一,没有上NextAuth。因为t3code最初只在公司内网跑,用户量就十几个人,上完整OAuth流程反而增加维护成本。我用的方案是内网IP白名单 + 一个简单的访问口令,这个后面会细说。第二,数据库从SQLite起步,部署到线上再切PostgreSQL。Prisma在这方面的切换成本非常低,schema基本不用动,改一行provider就能迁移数据。
2.3 和“纯前端方案”的对比
其实t3code最早考虑过一个更轻的方案:全部用本地存储,比如把片段数据存在IndexedDB,模板生成逻辑写成纯函数。这样做的好处是零服务器成本,一个静态页就能跑。但放弃它的原因很简单——团队需要共享。同事之间要互查代码片段,模板也要统一维护,纯浏览器方案解决不了“多人实时看到同一份数据”的问题。所以,最终决定要有一个真后端,哪怕最简单的那种。
既然决定了要有后端,摆在面前的就是Express/Fastify + React SPA、或者Next.js全栈两条路。前者我熟,但问题在于两套工程、两套部署、两套类型定义。t3code只是个内部工具,撑不起这个复杂度。于是我选了后者,把后端藏进Next.js路由里,部署也简单,一个Vercel应用搞定。
3. 从create-t3-app到可运行骨架:初始化阶段的实操记录
3.1 项目初始化命令和执行过程
t3code的初始化走的是官方脚手架,命令非常简单:
npm create t3-app@latest t3code执行过程中需要选择启用哪些模块,我勾了Next.js(App Router)、TypeScript、Tailwind CSS、tRPC、Prisma,没勾NextAuth。另外,ESLint和Prettier我选了启用,内部工具代码质量靠自觉不现实,还是交给机器管。
初始化完成后,先跑起来看看默认页面:
cd t3code npm run dev这里有个小提醒:create-t3-app的默认页面带了大量示例代码,比如_components里的一堆组件、api/trpc的示例路由。我建议先别急着删,而是提交一次“初始状态”,因为后续改造时,示例里暴露出来的很多写法(比如服务端调用db的方法、客户端api对象的使用方式)都是可以直接查的活文档。
3.2 目录结构:改造后最终长什么样
经过一段时间调整,t3code的src目录结构稳定为这样:
src/ ├── app/ │ ├── (main)/ │ │ ├── snippets/ │ │ │ ├── [id]/page.tsx │ │ │ └── page.tsx │ │ ├── templates/ │ │ │ ├── [id]/page.tsx │ │ │ └── page.tsx │ │ ├── share/[token]/page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/route.ts │ └── layout.tsx ├── components/ │ ├── snippet/ │ │ ├── SnippetCard.tsx │ │ ├── SnippetForm.tsx │ │ └── CodeBlock.tsx │ └── ui/ │ ├── Button.tsx │ ├── Input.tsx │ ├── TagInput.tsx │ └── Modal.tsx ├── server/ │ ├── api/ │ │ ├── root.ts │ │ └── routers/ │ │ ├── snippet.ts │ │ ├── tag.ts │ │ └── template.ts │ └── db.ts ├── trpc/ │ ├── server.ts │ ├── shared.ts │ └── react.tsx └── lib/ └── highlight.ts这个结构的核心思路是:页面集中在App Router里,路由层只负责取数据,业务逻辑全收敛在tRPC Router中。组件按模块拆目录,ui目录放的是纯展示类组件,这样任何一个组件有改动,不会牵连到业务组件。
3.3 数据库模型:片段、标签、模板的schema设计
Prisma模型是t3code的地基。我一开始的schema长这样:
model Snippet { id String @id @default(cuid()) title String description String? content String @db.Text language String @default("typescript") visibility Visibility @default(PRIVATE) tags Tag[] authorId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] } enum Visibility { PRIVATE TEAM PUBLIC }实际开发中,这个模型进行了两轮调整。第一轮:language字段从String改成了受控枚举,因为片段语言种类其实很少,没必要让用户手输,下拉选择就够了。第二轮:加了Template模型,用于存项目模板,字段包括模板名、文件树JSON、变量定义JSON、生成逻辑标识。
model Template { id String @id @default(cuid()) name String description String? filesJson String @db.Text variablesJson String @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里有个值得展开说的地方:文件树为什么存JSON而不是单独建两张关联表?我的取舍很明确,模板文件结构天然是递归的,用关系表表达会很别扭,而且要支持“文件夹套文件夹”得多写一堆父子表操作。JSON存文件树,读取时解析,生成时遍历,十几行代码搞定。代价是没法在数据库层做“文件夹重命名”这类操作,但对t3code这种内部工具而言,模板数量撑死两三百个,全量读出来内存里改反而简单。
4. 核心功能实现:片段CRUD、标签检索、模板生成全链路
4.1 tRPC路由的编写套路
t3code的所有后端能力都放在server/api/routers/下。下面以片段创建为例,看一整套tRPC路由是怎么写的:
// server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "@/trpc/server"; import { Visibility } from "@prisma/client"; export const snippetRouter = createTRPCRouter({ create: publicProcedure .input( z.object({ title: z.string().min(1, "标题不能为空").max(100), content: z.string().min(1, "代码内容不能为空"), description: z.string().max(500).optional(), language: z.enum(["typescript", "javascript", "python", "css", "html", "sql"]), visibility: z.enum([Visibility.PRIVATE, Visibility.TEAM, Visibility.PUBLIC]).default(Visibility.TEAM), tags: z.array(z.string().min(1).max(30)).default([]), }) ) .mutation(async ({ ctx, input }) => { const { tags, ...data } = input; return ctx.db.snippet.create({ data: { ...data, tags: { connectOrCreate: tags.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), });写这个路由时我犯了一个典型错误:一开始没把tags单独拿出来,直接写进data里展开,结果Prisma直接报错——因为tags这个字段在Snippet模型里是Tag[]关系,必须用关联操作。后来改成connectOrCreate,既不会重复建标签,又能在创建片段时自动搞定标签关联。
客户端调用更简单,react包生成的api对象提供了完整的类型提示:
const utils = api.useUtils(); const createMutation = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); }, }); // 触发创建 createMutation.mutate({ title: "useDebounce 防抖 hook", content: "export function useDebounce...", language: "typescript", tags: ["react", "hooks"], });服务端校验靠Zod,这个属于tRPC自带的福利。Zod里写清楚约束,传参不合法时客户端收到的错误消息也是可读的,不需要额外写try/catch。不过有一点要注意:Zod错误默认返回的message是英文的,像Title must be at least 1 characters这种。为了给同事友好的提示,我给字段加了自定义message字段,中文提示就直接带上了。
4.2 标签检索和全文搜索:从简单Contains到轻量方案
标签检索是片段库的刚需。最无脑的写法是:
list: publicProcedure .input( z.object({ tag: z.string().optional(), keyword: z.string().optional(), language: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.keyword ? { title: { contains: input.keyword } } : {}), ...(input.language ? { language: input.language } : {}), }, include: { tags: true }, orderBy: { updatedAt: "desc" }, }); });这就是t3code初版搜索的全貌。它能用,但有个明显短板:contains关键字在PostgreSQL里实际是LIKE %keyword%,数据量一上来,索引基本失效。我在t3code里做了个折中——搜索范围限定在标题和描述这两个短字段,不搜content正文。原因很简单,代码正文搜索涉及分词、倒排索引,属于专业搜索引擎的工作。内部片段库撑死几千条数据,标题contains已经能满足百分之八十的需求。真到了需要搜正文那天,再引入pg_trgm或者独立的搜索服务也不迟。
另加了一个小优化:标签字段和语言字段在Prisma里建了索引,解决了筛选场景的慢查询问题。
4.3 代码高亮:服务端渲染还是客户端高亮?
代码展示是片段工具的门面。t3code的CodeBlock组件最初用的是客户端react-syntax-highlighter,反反复试验了好几个方案,最终锁定shiki。
Shiki的特别之处在于,它是在服务端把代码转成带<span class="token...">的静态HTML,客户端零负担,首屏速度极快。对t3code这种以浏览为主的工具来说,用户体验比编辑器级实时高亮更重要。一个片段页只渲染一次代码,没必要在浏览器里跑一遍整轨高亮引擎。
实现上,我在lib/highlight.ts里维护一个简易缓存:
import { codeToHtml } from "shiki"; const cache = new Map<string, string>(); export async function highlightCode(content: string, language: string) { const key = `${language}:${content}`; const cached = cache.get(key); if (cached) return cached; const html = await codeToHtml(content, { lang: language, theme: "github-dark", }); if (cache.size > 200) cache.clear(); cache.set(key, html); return html; }缓存这块要留个心眼:代码片段更新时,key会变,所以缓存不会错乱。但内存缓存只适合单体部署,如果t3code以后扩到多实例,就得换成Redis或者干脆用数据库字段存高亮后的HTML。对现状来说,内存缓存已经足够。
4.4 分享链接:最容易被低估的需求
片段默认是TEAM可见,但团队经常需要把自己的代码片段发到外部协作群,这就需要一个“不登录也能看”的只读链接。t3code的做法是生成一个无规律的token,作为share/[token]/page.tsx的一部分:
// router 里生成 share: publicProcedure .input(z.object({ snippetId: z.string() })) .mutation(async ({ ctx, input }) => { const token = randomBytes(16).toString("hex"); await ctx.db.snippet.update({ where: { id: input.snippetId }, data: { shareToken: token }, }); return `${process.env.BASE_URL}/share/${token}`; }),页面端就更有意思了。share/[token]这个页面我用了generateMetadata做动态SEO,让分享出去的链接带标题和描述,粘贴到IM工具里会出现卡片预览,而不是光秃秃一个URL。这个小细节同事反馈很好,因为以前发代码片段就是贴文本,现在链接一点进去就是排版良好的代码块,观感完全不同。
5. 开发路上踩过的坑:完整排查链路还原
5.1 困扰一整天的“tRPC客户端类型不更新”
t3code开发到中期,出现过一个非常迷惑的问题:服务端路由新增了一个template.generate方法,客户端api.template对象里死活没有这个属性。编译不报错,运行时却拿到undefined。
排查链路是这样的:
第一步,怀疑是tRPC类型生成没触发。tRPC的类型是TypeScript基于AppRouter泛型推断出来的,理论上改服务端代码,客户端引用处会立即感知。于是先重启了npm run dev,问题依旧。
第二步,怀疑是路径别名的问题。但检查tsconfig.json里的@/trpc/*别名,没毛病。
第三步,深入到tRPC的作用域限制。这才反应过来——tRPC客户端暴露的是createTRPCReact的api对象,而React Server Component和Client Component如果用错引用路径,类型会退化。在t3code里,App Router的page.tsx如果是服务端组件,应该用server()调用Router,不应直接挂api对象上的hook。我犯的错是在一个客户端组件里引用了@/trpc/react,但这个文件里创建的api对象是在服务端组件里被同时引用,导致TypeScript在两种“上下文”间解析出了不一致。
最终修复很简单:把src/trpc/react.tsx里创建的api对象拆了出来,保证客户端组件只通过@/trpc/react进hook,服务端组件只通过@/trpc/server调方法,两边物理隔离。
5.2 Prisma多对多关联字段更新时的“去重陷阱”
另一个印象深刻的问题是标签更新。给片段编辑标签时,我一开始写的是:
update: publicProcedure .input(...) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.update({ where: { id: input.id }, data: { tags: { set: [], connectOrCreate: input.tags.map(...), }, }, }); }),这个set: []看起来是“清空重连”,但执行时直接报外键约束错误。查了Prisma文档才明白,set: []会把原先的关联全部断开,但它不是事务性的——如果旧关联里某个标签还挂在其他记录上,connectOrCreate时遇到重复name会产生冲突。
正确处理是用deleteMany先删关联表记录,再执行connectOrCreate。但我后来嫌麻烦,干脆换了个思路:标签表不建关联,而是让Snippet表直接存tags Json数组。说到底,内部工具不需要对标签做跨片段的反向查询,Json数组配合Prisma的contains过滤足够用,代价换来的是增删改查逻辑简化一半。有时候,放下一百个“正规设计”,反而能让小工具更皮实。
5.3 Vercel部署后新片段创建报500
本地一切正常,推到Vercel之后,创建片段这个接口直接500。打开日志,错误信息是PrismaClientInitializationError,再往深处看是:PrismaClient is unable to run in this environment。
排查后发现,Vercel的Serverless环境是只读文件系统,而Prisma在初始化时要写engine到临时目录,默认配置下会失败。解决方案是给Prisma加运行时配置:
# vercel.json { "build": { "env": { "PRISMA_ENGINES_MIRROR": "https://binaries.prisma.sh" } } }加上之后build阶段就把engine下载到正式位置,运行期不再有文件写入需求。第二个跟数据库有关的是连接池问题。PostgreSQL在Vercel的Serverless函数中保持长连接不现实,连接数会被函数并发数吃光。最终我用的是Prisma的pooled连接串模式(配合PgBouncer代理),同时给每个请求设置了10秒的连接超时,刷新PostgreSQL的max_connections设置,双管齐下算是稳住了。
这一步排查的价值在于,本地环境和Serverless环境的差异点,几乎都在“文件系统可写性”、“连接保持”、“环境变量注入时机”这三个地方,各位如果部署别的项目卡壳,可以按这个顺序排查。
6. 上线两周后的实测结果:性能、反馈与下一步规划
t3code在公司内部跑了两周,数据上有个小规模但靠谱的结论。目前库里存了800多个片段、近百个模板,列表页接口P95响应时间稳定在220ms左右,分享页因为用了服务端渲染和Shiki高亮,首屏加载在普通4G网络下大约1.1秒。这个成绩对内部工具完全够了。
性能上还有个小经验:列表页一开始是直接findMany全量返回,客户端再用JS过滤。数据量到300条时还能忍受,到800条时明显卡顿。我改成服务端分页+标签筛选,每个请求只返回24条,配合useInfiniteQuery做滚动加载,手感好得多。tRPC配无限查询非常方便,getNextPageParam里返回cursor,标准套路,不需要额外维护状态。
同事反馈里最有价值的一条是——模板生成的变量替换功能帮了大忙。之前新开一个内部微服务项目,要手动改项目名、包名、端口、数据库连接串,至少十分钟。现在t3code的模板生成器读取variablesJson,在页面上展示表单,填写完点生成,后端按文件树逐级创建文件夹和文件,输出一个zip包。这个功能从“顺手做的”变成了全项目使用频率最高的功能。
顺带整理一下t3code目前的局限,方便大家避雷。第一,搜索仍然只覆盖标题和标签,搜正文搜不到;第二,没有导入导出批量操作,迁移数据时得手动写脚本;第三,访问控制还停留在IP白名单加口令的级别,严格意义上不适合多租户环境。这些坑我都清楚,但以内部工具的价值来衡量,每一项的优先级都排在“新增模板类型”和“优化生成逻辑”后面。
最后分享一个我比较得意的小技巧:模板生成这个模块,我用了Node的archiver库做zip压缩,但Vercel Serverless对响应体大小有4.5MB的限制。一开始模板文件多了就超限,后来改用流式响应,archiver直接pipe到response对象,客户端用fetch + blob接收,就没有这个问题了。关键代码其实就几行:
import archiver from "archiver"; const archive = archiver("zip", { zlib: { level: 9 } }); archive.pipe(res); // 按文件树逐层 append files.forEach((f) => archive.append(f.content, { name: f.path })); await archive.finalize();比起把整个zip先读到内存再返回,这个改动让模板体积上限提升了不止一个量级。
做t3code最大的体会是,内部工具不在于功能多炫,而在于“随需而变”的能力。T3 Stack让每一次改动都集中在尽可能少的文件里,类型安全又兜住了手滑的风险。这个项目目前还在持续迭代,我对它的期待是:在团队内部变成一个可以稳定用三年、不用经常伺候的“数字工具箱”。做到那一天,t3code就不光是一个代码片段管理器,更是团队工程习惯的沉淀了。