用T3 Stack打造团队代码片段管理工具t3code的实战全解析

发布时间:2026/10/26 23:22:59
用T3 Stack打造团队代码片段管理工具t3code的实战全解析

最近在整理团队内部沉淀的代码素材时,发现我们有一个很明显的需求:散落在各种文档、聊天记录、甚至个人本地文件里的可复用代码片段太多,真到用的时候又找不到。搜的搜,翻的翻,最后往往还是凭记忆重新写了一遍。这让我萌生了一个念头,干脆自己动手做一个轻量级的代码片段管理中心,代号就叫 t3code。

这个名字里的“T3”,指的是目前全栈圈子里很火的 T3 Stack(Next.js + TypeScript + TailwindCSS + tRPC,搭配 Prisma 和 NextAuth)。t3code 就是用这套技术栈做的一个团队代码片段管理工具,核心功能一句话就能讲清楚:把大家在日常开发中反复用到的函数、组件、SQL、配置模板统一收进来,支持标签分类、全文搜索、一键复制,并且每个片段都可以关联作者和引用次数。虽然它看起来不像一个“大项目”,但麻雀虽小五脏俱全,认证、数据库、API 层、前端交互全都有,非常适合用来梳理一套完整的全栈实践。

这篇文章我会从项目定位、数据建模、核心实现、问题排查四个角度讲清楚 t3code 是怎么一步步做出来的。如果你正准备用 T3 Stack 做一个类似的内部工具,或者单纯想看一个真实的全栈项目是怎么组织的,这篇文章应该能帮你省掉不少弯路。

1. 项目定位与选型思路

1.1 为什么要做 t3code

先说背景。我所在的团队大概有十二三个后端和前端同学,日常开发里会产生大量短小但高频的代码片段:比如统一的日期格式化函数、文件上传组件、分页查询的 Prisma 封装、Dockerfile 模板、Git 提交信息规范,这些东西理论上都可以复用,但实际复用率特别低。

低的原因不是大家不愿意用,而是没有地方能快速找到。中大型团队可能会上 Storybook、统一组件库或者 Wiki,但小团队往往处于“有沉淀,没平台”的尴尬阶段。我查过市面上的几个方案,收费的商业工具门槛高,开源的要么太重,要么部署复杂,很多还停留在“工具”阶段,没有针对团队协作做优化。

t3code 的定位就是填补这个空白:一个部署简单、上手快、只做“代码片段管理”这一件事的小工具。它不追求大而全,不做项目管理,不做团队 IM,就是单纯把“找代码片段”这件事做到极致。用户可以在里面创建片段、打标签、搜索、复制,管理员可以统一维护标签体系和公共模板。

1.2 为什么是 T3 Stack 而不是别的

有段时间我一直在 Next.js 和 NestJS 之间犹豫。NestJS 的模块化设计很适合大型后端项目,但团队成员大多不是专职 Node 开发者,很多人平时写 Java 或者写前端,突然引入一个带依赖注入、装饰器、模块系统的后端框架,学习成本并不低。

T3 Stack 吸引我的点主要还是类型安全的全链路。它用 TypeScript 把“前后端 API 调用”这一层彻底磨平了。传统开发模式里,前端写一个接口请求,后端写一个接口定义,双方靠文档和 Postman 联调,一旦参数改了文档没同步,排查起来非常痛苦。而 t3code 这类小工具,业务逻辑不复杂,真正的成本恰恰消耗在“联调”和“改动同步”上。

用 T3 Stack 后,tRPC 让前端调用后端函数就像调用本地函数一样,参数类型、返回类型、错误信息全部自动推导。改一个 schema,另一端的 TypeScript 立刻报错,根本不会出现“线上跑着旧协议”的情况。对于十人左右的团队来说,这种“少沟通、不扯皮”的开发体验比任何框架特性都值钱。

1.3 功能边界怎么划

做一个项目最大的风险不是功能太少,而是功能太多。t3code 第一版我只保留了四个核心模块:

  • 片段管理:创建、编辑、删除片段,支持 Markdown 语法。
  • 标签体系:片段可以关联多个标签,标签统一维护。
  • 搜索筛选:支持关键词模糊搜索和标签过滤。
  • 复制集成:每个片段展示区都有复制按钮,点击即复制,并记录复制次数。

我没有做团队权限系统,也没有做评论和点赞。原因是这种内部工具如果第一版就塞满协作功能,光权限模型和消息通知就够写几周了,反而弱化了核心的“片段管理”体验。权限我用了最朴素的分层:登录用户都能增删改自己的片段,管理员可以管理标签和删除任何人的片段。等用的人多了、反馈多了,再逐步加功能也不迟。

2. 数据模型与核心设计

2.1 数据模型怎么建最省心

数据模型是 t3code 最早定下来的部分,因为一旦建错,后面所有 API 和页面都要跟着返工。我最终用了三张主表加一张关系表:User、Snippet、Tag,以及多对多关系表SnippetTag。

model User { id String @id @default(cuid()) name String? email String? @unique snippets Snippet[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String description String? language String content String @db.Text visibility Visibility @default(PRIVATE) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags SnippetTag[] copyCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) @@index([language]) } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] createdAt DateTime @default(now()) } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) snippetId String tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) tagId String @@id([snippetId, tagId]) @@index([tagId]) } enum Visibility { PRIVATE PUBLIC }

这里有两个我反复权衡过的点。第一个是语言字段language,一开始我打算做成字符串随便填,后来想想会乱,不如直接在枚举里定死。为了方便扩展,最终没有写死枚举,而是在前端用动态下拉框,可选值从配置表读取,这样以后加一种语言不用改数据库。

第二个是visibility字段。最初我犹豫要不要搞私有片段和公共片段,后来发现这个字段太需要了。团队里有些片段确实不适合公开,比如带有内部 IP 地址的数据库配置片段。所以我把它做成了默认私有,新建时可以手动改成公共。

2.2 认证与权限的细节处理

认证我用的 NextAuth,接入 GitHub Provider。为什么选 GitHub 而不是邮箱密码?因为团队内部使用,大家本来就有 GitHub 账号,一次性授权登录最省事。邮箱密码方案还得处理验证邮件、忘记密码、密码强度这些琐碎问题,属于给自己挖坑。

NextAuth 配置本身没什么难度,真正要注意的是 Session 的获取方式。T3 Stack 默认会把 Session 注入到 tRPC 上下文里,所以在整个应用的任何地方都能方便地拿到当前登录用户。但实际开发时要特别小心一个误区:在页面里用getSession()获取用户信息,而不是在服务端组件里直接读 session。服务端渲染时如果读 session 的时机不对,很容易出现首屏渲染时用户还没登录、刷新后又恢复的闪跳。

权限控制我放在了两个层面。页面层面用中间件保护路由,未登录用户访问/app下页面会跳转到登录页;API 层面在每个 tRPC 路由里做二次校验,避免有人绕过页面直接调用接口。我在创建、编辑、删除 snippet 的三个 mutation 里都加了ctx.session判空,这是最基本的防呆操作。

const updateSnippet = protectedProcedure .input( z.object({ id: z.string(), title: z.string().min(1).max(100), content: z.string().min(1) }) ) .mutation(async ({ ctx, input }) => { const snippet = await ctx.prisma.snippet.findUnique({ where: { id: input.id } }); if (!snippet) { throw new TRPCError({ code: "NOT_FOUND", message: "片段不存在" }); } if (snippet.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "无权修改他人的片段" }); } return ctx.prisma.snippet.update({ where: { id: input.id }, data: { title: input.title, content: input.content } }); });

权限校验看起来简单,但特别容易漏掉“对象级权限”。能登录不代表能操作所有数据,我在第二版 review 时发现一开始只在路由里判断了登录状态,没判断数据归属权,结果任何登录用户都能改别人的片段。这个问题如果上线后才发现,数据会乱得一塌糊涂。

2.3 tRPC 路由怎么组织

tRPC 的路由设计一开始就要想好边界,不然后面会变成一个大杂烩。我按照业务模块拆成了三个 router:snippetRouter、tagRouter、userRouter,根路由里再合并。

import { createTRPCRouter } from "~/server/api/trpc"; import { snippetRouter } from "~/server/api/routers/snippet"; import { tagRouter } from "~/server/api/routers/tag"; import { userRouter } from "~/server/api/routers/user"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, user: userRouter });

这种拆分的好处是路由职责非常清楚,新同学看代码的时候只看文件名就知道该改哪里。我之前见过有人把所有 API 都堆在一个rootRouter里,看起来是省了文件,但改一个功能要反复滚动屏幕,Review 时也没法按模块分人看。

snippetRouter 内部我用createTRPCRouter组织了几类 operation:getAll、getById、getMine、create、update、delete、incrementCopyCount。前几个是查询,后几个是变更。一个直觉习惯是先写查询还是先写变更?我的经验是先写查询,因为查询能帮你把“数据长什么样”想清楚,变更只是对结果的修改而已。

另一个关键点是 tRPC 的 input 校验。每个 mutation 都要用 zod 定义输入参数,比如创建 snippet 时的 title 要有最小长度限制,content 不能为空,language 必须合法。这些校验不是为了刁难用户,而是为了减少数据库层的脏数据。我在第一版没做 zod 校验,直接往 Prisma 里塞数据,结果出现了好几个 title 为空、content 为 null 的脏记录。后来全量清洗了一遍数据,深刻体会到“input 校验是最便宜的防错手段”。

3. 从零到可用:关键实现拆解

3.1 初始化项目和基础配置

t3code 初始化用的是官方脚手架create-t3-app,命令很简单:

pnpm create t3-app@latest

创建时我会勾选 Next.js、TailwindCSS、Prisma、tRPC、NextAuth,去掉的是 ESLint 之外的额外脚手架。Tailwind 我保留了,因为在做列表页的卡片布局和搜索框样式时,Tailwind 的响应式写起来确实爽,不用来回切换 CSS 文件。

构建工具我选的 pnpm 而不是 npm,原因有两个:一是安装速度快很多,二是 pnpm 的依赖隔离机制能避免不少“幽灵依赖”问题。T3 Stack 本身对 pnpm 支持得很好,官方文档里默认示例就是 pnpm。

初始化完成后,第一件事我建议先跑一遍 lint 和 build,确认基础设施没问题,再开始写业务代码。很多人会跳过这一步直接改代码,结果后面报错时不知道是自己写的问题还是脚手架的问题,排查起来很浪费时间。

3.2 数据模型落地与迁移

模型文件写好后,需要执行迁移命令生成数据库表:

npx prisma migrate dev --name init_snippet_tag

我用的数据库是 PostgreSQL,因为团队长期在用,运维也熟悉。如果你只是本地测试,SQLite 完全够用,注意 T3 Stack 对 SQLite 的支持也很好,几乎不需要额外配置。

迁移过程中最容易遇到的就是多对多关系表SnippetTag的处理。Prisma 其实可以直接在Snippet和Tag模型上声明互相引用,然后自动生成隐式中间表。但我选择显式声明SnippetTag模型,原因是我在后续可能要在这个中间表上扩展字段,比如给某个片段打标签时记录一下谁打的、什么时间打的。隐式中间表不支持这种扩展,显式模型则完全没这个限制。

迁移完成后,我还会顺手写seed脚本填充一些假数据。开发阶段最怕空空荡荡的列表页,完全没有数据会让样式走样。我的 seed 脚本会创建两个测试用户、二十个不同语言的片段、十个标签,保证一打开页面就能看到完整的效果。

3.3 snippet 相关 router 的实现

核心的getAll查询我用了无限滚动方案。T3 Stack 默认使用 react-query,给无限滚动提供了很好的支持。查询逻辑如下:

getAll: protectedProcedure .input( z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(10), search: z.string().optional(), tagId: z.string().optional(), language: z.string().optional() }) ) .query(async ({ ctx, input }) => { const { cursor, limit, search, tagId, language } = input; return ctx.prisma.snippet.findMany({ where: { visibility: "PUBLIC", ...(tagId ? { tags: { some: { tagId } } } : {}), ...(language ? { language } : {}), ...(search ? { OR: [ { title: { contains: search, mode: "insensitive" } }, { description: { contains: search, mode: "insensitive" } }, { content: { contains: search, mode: "insensitive" } } ] } : {}) }, include: { author: { select: { id: true, name: true, image: true } }, tags: { include: { tag: true } } }, orderBy: [{ updatedAt: "desc" }], cursor: cursor ? { id: cursor } : undefined, take: limit + 1 }); });

讲到这段代码,我特别想说一下cursor翻页的细节。Prisma 的 cursor 翻页不是用来替代 offset 的银弹,它适合大数据量场景,但小数据量时未必更简单。t3code 里的片段总量预期不会超过几千条,其实 offset 翻页就够了。我之所以用 cursor,是想提前练一下这种模式,避免以后遇到大数据业务时手忙脚乱。

关键逻辑是take: limit + 1。我会多取一条,用来判断“后面还有没有更多数据”。如果返回的条数等于 limit+1,说明还有下一页,取第一页的 limit 条渲染,把多出来的那一条的 id 作为下一次请求的 cursor。这个模式在 react-query 的getNextPageParam里配合得非常顺:

getNextPageParam: (lastPage) => { if (lastPage.snippets.length < lastPage.limit) return undefined; return lastPage.nextCursor; }

这里有个容易踩的坑:nextCursor必须来自当前页最后一条的 id,而不是第一页的。如果拿错了 cursor,翻页会出现数据重复或者跳页。我在初版就犯过这个错误,查了半天发现 cursor 位置没对。

3.4 页面和交互的实现

页面布局我用了两栏结构:左侧是标签栏和筛选器,右侧是片段列表。左侧栏宽度固定 220 像素,右侧自适应。这个布局不新鲜,但胜在简单实用,不用引第三方组件库就能实现。

片段卡片是页面的核心展示单元。每张卡片包含标题、描述、语言标签、作者头像、创建时间、复制按钮和“复制次数”。卡片本来想用overflow-hidden截断多余内容,后来改成点击卡片后弹出全屏浏览模式,这样长代码片段不会被截断成半截,体验更好。

复制功能是一个看起来简单、做起来细节很多的点。第一版我用了navigator.clipboard.writeText,直接在最前面写死,结果在 HTTP 环境下根本不起作用。后来才发现 clipboard API 要求安全上下文,localhost 可以,HTTP 部署环境不行。最后我加了一个降级策略:优先用navigator.clipboard,失败则回退到document.execCommand('copy')加临时textarea的方式。

async function copyContent(text: string) { if (navigator.clipboard) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement("textarea"); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); }

复制成功后,我调用incrementCopyCountmutation,把计数加一。这里用/api/trpc的 POST 请求即可,不用刷新页面。很多小细节做到位了,用户才会觉得“这工具真顺手”。

标签筛选我用的是 URL 查询参数,而不是 React 内部 state。这样做的原因是让筛选状态可以分享,比如 A 同学筛选了“前端”标签,把链接发给 B 同学,B 打开也能看到同样的筛选结果。很多内部工具不做这种处理,分享链接时状态就丢了,体验差了一大截。

3.5 搜索功能怎么做得顺滑

搜索是 t3code 的主路径,用户打开页面第一件事大概率就是搜东西。所以搜索框的体验直接影响整个工具的口碑。

我的做法是在服务端做模糊搜索,前端做防抖。防抖延迟设为 300 毫秒,既不会因为每次按键都发请求把数据库打爆,也不会让用户觉得搜索“迟钝”。搜索框输入变化时,触发setSearchInput,300 毫秒后如果输入内容没有变化,就触发useQuery的 refetch。

useEffect(() => { const timer = setTimeout(() => { setSearchInput(input); }, 300); return () => clearTimeout(timer); }, [input]);

这里有个小技巧:搜索状态要跟分页状态配合好。每次搜索关键词变化时,必须把当前页码重置为 1,否则会出现“第二页里搜到了三条结果,但列表还是从第六项开始显示”的诡异现象。我是在useEffect里监听 searchInput 变化,同时调setPagination({ page: 1, cursor: null })。

另外,由于查询逻辑服务端做了visibility: "PUBLIC"过滤,使用者在搜索时永远不会看到别人的私有片段。这保证了隐私又不需要前端额外传参数,一石二鸟。

4. 实操中踩过的坑与排查方法

4.1 tRPC 的输入校验与 TypeScript 类型报错

tRPC 用起来有多爽,类型报错时就有多让人抓狂。我遇到最多的一个问题是在 mutation 里传入的参数类型和 zod 校验不一致。比如 zod 里定义title: z.string().min(1).max(100),前端用autocompletedTitle字段传给它,类型立刻报错。

这种报错的本质是 tRPC 通过 TypeScript 对输入类型做了严格检查。解决办法倒不复杂:确保前端调用时传的是 zod 定义的那一组字段,多一个不行,少一个也不行。刚开始很不习惯这种“犟脾气”,但用了两周后,我发现它帮我避免的线上 bug 远比它带来的报错要多。

另一个比较隐蔽的坑是HTTPError。在 Next.js 应用里,tRPC 的响应错误会包装成 HTTP 错误,浏览器控制台显示的 message 有时候会是Unexpected token '<'...,这是因为错误响应被返回成了 HTML 格式而不是 JSON。出现这种问题,大概率是 API 路由没有正确挂载 tRPC 中间件。排查思路是打开 Network 面板,直接看响应体的内容格式。

4.2 Prisma 多对多关系的迁移坑

Prisma 的多对多关系是 t3code 里最绕的地方。我前面提过显式声明SnippetTag的原因,这里再补充一个实际迁移中会遇到的问题:很多人在第一次写完模型文件后,直接用prisma db push而不是prisma migrate dev。db push适合开发阶段快速同步,但它不会生成迁移记录。如果团队其他成员同步代码时不执行同一条迁移命令,就会出现“我的本地有这张表,你那边没有”的混乱局面。

我的建议是从项目第一天就用migrate dev,并且把生成的 SQL 迁移文件提交到 Git。这样每个成员拉代码后执行prisma migrate deploy,数据库结构一定是一致的。t3code 项目规模不大,但这种习惯值得养好,因为以后一定会遇到多人协作的时刻。

还有一个隐藏很深的坑:更新模型后执行迁移,Prisma 可能提示你要重置数据库。如果没加--create-only参数,它会弹窗问你是否需要重置。这个弹窗的意思是“你改了表结构,但表里已有数据,重置的话会清空所有数据”。开发阶段我直接选--force重置,但一旦上了测试验收阶段,就不能随便重置了。建议在测试环境保留一条脏数据样本,用来验证迁移脚本在真实数据上的兼容性。

4.3 搜索防抖与 useMemo 的使用误区

前端搜索防抖看似简单,实际上还要处理好“空搜索”的情况。我在第一版时,searchInput 默认是空字符串,但空字符串会被传给后端做contains: ""查询,结果就是搜索逻辑把所有数据都过滤了一遍,本质上没过滤,但是白白消耗了一次查询。后来我在查询参数里显式判断:如果 search 为空字符串,就不带 search 字段,让后端走全量查询路径。

另一个 React 层面的坑是我一度把搜索结果的过滤做在了前端:用useMemo对全量片段做 filter。当时觉得反正数据少,前端过滤快,不用每次都请求后端。后来数据量涨到几百条后,页面明显开始卡顿,尤其是输入中文时,每次按键都触发几百个片段的重渲染。教训是:像搜索这种“需要查数据库”的操作,必须通过 API 做,不要指望前端伪过滤能撑住量级。

4.4 环境变量和部署的常见问题

T3 Stack 的几个环境变量对不上,应用基本起不来。常见的是DATABASE_URL指向的数据库端口错误,或者NEXTAUTH_SECRET没配。还有NEXTAUTH_URL,在开发阶段很容易被忽略,但在部署到非 localhost 域名时,如果没配置会一直登录失败。

我踩过的最惨的一个坑是部署到 Vercel 后图片懒加载失效。排查后发现是 Next.js 的next/image组件在未配置remotePatterns时,不允许加载 GitHub 头像的外部图片地址。解决起来很简单,在next.config.js里加上 ChatGPT 头像域名的白名单就行。

images: { remotePatterns: [ { protocol: "https", hostname: "avatars.githubusercontent.com" } ] }

这类问题虽然小,但很折磨人,因为本地开发完全正常,一部署就坏。我的经验是部署前先在本地跑一次生产构建:pnpm build,它能检查出不少环境问题,比上线后手忙脚乱强得多。

4.5 安全与权限的细节

权限是 t3code 这类内部工具最容易放松的地方。我前面讲了对象级权限,这里再补充几个细节。

删除接口不光要校验作者,还要做级联处理。我从 Prisma 模型层面就设了onDelete: Cascade,所以删除 snippet 时,关联的SnippetTag记录会一并删除,不会留下孤儿数据。这个设计如果不在模型层做,就得在业务代码里手动遍历删除 tag 关系,很容易漏。

还有个容易被忽略的点是标签名称唯一性。Tag.name我设了@unique,但业务上不能依赖数据库报错,我在 mutation 里先查重,重复标签直接复用已有记录,不新建。不然每次创建片段时如果标签大小写不一致,会出现 “JavaScript” 和 “javascript” 两个标签,搞乱统计。

最后一点是关于 NextAuth 的 session 过期策略。默认的 session 有效期比较长(一个月),对于内部工具问题不大。但如果你要做得严谨一些,建议把 session 过期时间调短,并在每次 tRPC 调用时验证 session 是否过期。这个我用jwt.maxAge配置,设定为 24 小时。


在实际把 t3code 跑起来的过程中,我最大的感受是:T3 Stack 给一套全栈应用带来的“流畅感”远比宣传语里说的要实在。类型安全这东西,没用过的人觉得只是少写几个类型定义,用过了才发现它把前后端的沟通成本整体拉低了。代码片段管理这个场景虽然简单,但正因为业务足够聚焦,你才能把所有注意力放在打磨数据模型、搜索体验和权限校验上,做完之后有一种“每一块代码都长在我认知里”的踏实感。

如果你现在正准备做一个团队内部工具,或者想找一个小而美的项目来练手 T3 Stack,我建议把 t3code 的玩法抄一遍:不用做大,把一个核心场景做到极致。等到真有人开始天天用你做的这个工具时,你会发现,日常开发里最值得做的事,往往不是追赶新框架,而是把旧问题解决得比别人更顺手。