基于T3技术栈的全栈代码片段管理器:t3code设计与实现
t3code 这个项目名字第一次出现在我的笔记里时还只是个随手起的代号——t 取自 TypeScript后面的 3 代表当时参与设计的三个人code 就更直白了代码片段。但做着做着它最终演变成一个完整的全栈应用一个基于 T3 技术栈的在线代码片段管理器。你可以把它理解成私人版的“代码收藏夹”支持按语言分类、打标签、全文搜索也允许把片段一键设为公开后分享给团队。适合刚接触 T3 全栈的同学拿来当实战项目练手也适合想在公司内部搭一个轻量知识库的开发者直接参考。1. 项目定位与技术选型背后的思考1.1 t3code 到底是用来干什么的先说清楚t3code 本质上解决的是代码碎片沉淀问题。日常工作中大家总会遇到这种情况一个正则表达式查了三次才写对一段 PostgreSQL 的递归查询调试了两个小时一个组件的 scroll 监听写法特别典型但下次要用时这些内容散落在本地临时文件、聊天记录和浏览器书签里检索成本高得吓人。我最初想做 t3code就是希望能有一个统一的地方把这些“断断续续总结出来的代码经验”好好存下来。它不是一个重量级项目管理工具也不是类似于代码托管平台的复杂产品定位非常收敛一个面向个人和小团队的知识型工具。页面数量也不多一个列表页、一个详情页、一个新建/编辑页再加一套登录与权限逻辑。功能看起来单薄但正好覆盖了 Next.js、TypeScript、Tailwind CSS、tRPC 和 Prisma 的全链路每一个环节都能被真实业务场景驱动起来。这个选择很关键因为项目如果功能太简单技术栈会显得“杀鸡用牛刀”如果功能太复杂又容易把篇幅浪费在业务逻辑上反而冲淡了技术演示的价值。至于为什么叫 t3code除了“三个人”之外还因为底层采用的就是 T3 技术栈。T3 这个名字本来就是 Next.js、TypeScript、Tailwind CSS、tRPC 的组合缩写社区里也习惯用它来代指这套全栈方案。团队内部一旦给项目赋予了明确语义后面无论写路由、建数据库模型还是开 git branch都可以围绕这个名字展开沟通成本也低了不少。1.2 为什么选 T3 技术栈而不是其他组合有人会问一个代码片段管理工具用熟一点的方案不是更快吗比如 Spring Boot 写后端、Vue 写前端或者干脆点用 Next.js 的 API Routes 配前端页面为什么非要引入 tRPC 和 Prisma 这一整套东西我对比过几种实际方案列个表更直观方案组合优点痛点T3Next.js tRPC Prisma Tailwind类型自动贯穿前后端开发效率高部署成本低有学习曲线需要接受不少框架约定Next.js REST API 独立前端仓库前后端完全解耦适合多端接入要维护 DTO、接口文档跨仓库改类型非常痛苦Vue3 Express MongoDB轻量、熟悉、上手快类型安全靠额外方案补充前后端契约容易断层Java Spring Boot React团队已有 Java 基础时可复用基建太重简单场景反而被大量配置拖慢我最终选择 T3核心原因就是“端到端类型安全”。这句话听起来很高深实际体验却非常直白前端接口调用函数时入参、出参都是 TypeScript 类型推导出来的后端改了一个字段前端编译期就会立刻报错而不是等接口联调时才发现。过去前后端对接最耗时的部分就是“对着文档核对字段名”T3 把这一层省掉了。写入一次 zod schema类型就同时成为后端校验规则和前端类型来源这种“单一事实来源”对中小团队帮助特别大。但也要泼一盆冷水T3 不等于银弹。它更适合中小型业务、原型快速验证和单团队统一技术栈的场景。如果业务并发极高、团队规模很大或者有移动端、第三方开放平台等着接入tRPC 这类 RPC 风格接口反而会限制多协议扩展性。取舍的时候一定要想清楚边界不要因为“技术新”就盲目套用。1.3 它解决了哪些真实痛点第一是重复定义类型的问题。以前前后端各维护一套字段定义后端 model 改了前端 interface 可能还停留在旧版本甚至要等联调跑一遍才知道。t3code 中zod schema 定义一次业务 Router 就把它作为 input 类型前端 call 接口时可以直接获得类型提示不再需要手动维护 DTO。第二是接口文档滞后问题。tRPC 的 Router 本身就是可读的接口描述IDE 里点开函数签名就能看到入参出参。对于三五个人的开发小组与其维护 Confluence 上的接口文档不如让代码成为活的文档。第三是请求层样板代码太多的问题。直接用 fetch/axios 时要处理 loading、错误、取消、缓存稍微复杂一点还要自己封装 hooks。tRPC 配合 React Query 的封装使用useQuery、useMutation就能把请求状态管理起来减少了大量重复的请求处理代码。这个体感在写业务页面时特别明显。2. 核心细节解析与实操要点2.1 数据模型别小看一个片段背后的关系数据模型是整个项目的底座我没想得太复杂但也没有真的只建一张表。t3code 里一共三个核心模型User、Snip、Tag。User 由 NextAuth 的标准模型扩展而来Snip 是代码片段本体Tag 则承担分类聚合的责任。model Snip { id String id default(cuid()) title String content String db.Text language String default(plaintext) isPublic Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里有几个设计细节值得拿出来说。主键用cuid()而不是自增数字核心原因有两个一是避免片段 ID 被顺序遍历二是多环境同步数据时比如本地开发导入导出字符串 ID 冲突概率远低于自增数字。我见过不少项目为了图方便用自增 id最后做数据合并时踩坑所以从一开始就把这个成本规避掉。content 字段我特意标注了db.Text。Prisma 默认的字符串在很多数据库上会映射成varchar(191)长度一超就报错。代码片段这种东西短的几行长的上百行误用默认类型很容易在写入长 SQL 时莫名失败这也是新手最容易忽略的点。Tag 的设计用了多对多关系。有人会问为什么不直接在 Snip 上存一个字符串数组查询时用array_contains不也方便吗我的理由是关系模型能够让标签维度的统计和筛选更干净。一个标签页想要展示“这个标签下有多少片段”一条groupBy就能完成。如果存字符串数组这类聚合查询会变成全表扫描维护成本和查询成本都更高。至于 Prisma 自动生成的中间表_SnipToTag在没有额外字段需求的情况下完全够用不需要手动建模。2.2 tRPC 路由把业务逻辑收敛成树状结构t3code 的业务逻辑没有散落在页面里而是统一收敛到src/server/api/routers/snip.ts再注册进 rootRouter。这样整个应用的第一入口看起来就是一颗清晰的调用树前端无论从哪个页面发起请求最终都会落回这棵树里。import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; const createSnipSchema z.object({ title: z.string().min(1).max(80), content: z.string().min(1), language: z.enum([typescript, sql, bash, python, plaintext]), tags: z.array(z.string()).max(10).optional(), isPublic: z.boolean(), }); export const snipRouter createTRPCRouter({ create: protectedProcedure .input(createSnipSchema) .mutation(async ({ ctx, input }) { const snip await ctx.db.snip.create({ data: { title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: input.tags?.length ? { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, })) } : undefined, }, }); return snip; }), });我重点讲两个容易被忽略的点。第一个是protectedProcedure。这个中间件的作用是如果用户未登录直接抛UNAUTHORIZED前端 tRPC 客户端会自动拿到一个可读的错误对象。比自己在每个接口里写if (!ctx.session) throw ...要干净得多。第二个是connectOrCreate。创建片段时同时传入标签名数组Prisma 会先尝试连接同名 Tag不存在就自动创建省掉了一次先查再拼装的代码这个写法在知识点聚合类需求里非常常用。zod 在这里不仅是类型声明更是数据守门员。title 长度、content 是否为空、language 的可选值都在 schema 层就提前限定。后端不再需要一堆 if 判断往下走脏数据进不到数据库前端拿到错误提示时也能精确到字段。对于 t3code 这种以内容提交为核心的应用输入校验的收益特别明显。2.3 认证与会话T3 默认踩坑最少的路认证方案我直接选了 NextAuth现在也叫 Auth.js配合 Prisma Adapter。create-t3-app 初始化时勾选 NextAuth会同步生成一套标准的 Session/Account/User 模型省去了自己设计用户表的步骤。登录方式选择了 GitHub OAuth是因为团队本来就都在用 GitHub免密流程成熟也不需要再单独维护一套账号密码体系。生产环境配置需要关注两个关键环境变量DATABASE_URLpostgresql://user:passlocalhost:5432/t3code NEXTAUTH_SECRET你的随机密钥 NEXTAUTH_URLhttps://t3code.example.comNEXTAUTH_SECRET是用来给 session cookie 签名的密钥一定不要硬编码到代码里生成方式可以用openssl rand -base64 32。我见到不少人本地跑得好好的部署上去却一直跳回登录页最后发现是没配NEXTAUTH_SECRET。还有一点容易忽略NEXTAUTH_URL在 Vercel 部署时可以直接省略但在自托管时一定要填否则 OAuth 回调地址会是空值。权限控制不能只依赖登录中间件。私有片段的功能意味着即使我登录了也不能看别人的私有片段。所以在 tRPC 的查询里必须叠加数据范围条件。比如获取列表时where条件里必须带上authorId: ctx.session.user.id而不是只查isPublic: true。tRPC 本身只是一个传输层它不是权限层真正的权限校验还是要写在业务查询里这是很多人容易忽略的安全底线。2.4 前端使用与状态管理tRPC 不是万能胶t3code 的页面交互不算复杂但有一个问题相当典型如何组织服务端状态和本地 UI 状态。我的结论是中等规模页面尽量减少全局状态库把服务端状态完全交给 tRPC客户端瞬时状态只用useState就够了。use client; import { api } from ~/trpc/react; const utils api.useUtils(); const { data: snips, isLoading } api.snip.list.useQuery({ page: 1 }); const createSnip api.snip.create.useMutation({ onSuccess: async () { await utils.snip.list.invalidate(); }, });新手最容易踩的坑是调用useMutation创建数据后发现列表并没有自动更新。原因在于 tRPC 底层使用 React Query 做缓存缓存 key 由路径和参数组成新建片段后缓存的snip.list还是旧数据必须主动invalidate让它失效重取。这个模式我用多了之后已经养成了习惯写 mutation 的onSuccess时第一时间考虑需要刷新哪些 query。还有一个细节是 React Server Components 和客户端组件之间的边界。不要在 Server Component 里直接调用useQuerytRPC 的 hooks 只能在客户端组件中使用。我通常把页面外壳保持为服务端渲染把搜索框、标签选择器这类交互组件单独拆出来标记use client这样既保留了 SSR 的性能收益又能把交互逻辑隔离干净。3. 实操过程与核心环节实现3.1 初始化项目create-t3-app 一分钟起盘初始化项目没有用手写配置直接使用官方脚手架五分钟就能得到一个能跑的全栈骨架。npx create-t3-applatest t3code交互式选项里我把 TypeScript、NextAuth、Prisma、Tailwind CSS、tRPC 全部勾上ESLint 和 Prettier 也建议保留。生成后的目录结构其实不多真正需要关心的核心文件只有这几个src/server/db.ts # Prisma Client 实例 src/server/api/root.ts # 汇总所有子 Router src/server/api/trpc.ts # createTRPCContext、createTRPCRouter 等基础工厂 src/trpc/server.ts # 服务端调用 tRPC 的封装 src/trpc/react.ts # 客户端 hooks 的封装先跑一遍npm run dev确认默认的示例页面正常加载然后再删掉 demo 代码。我个人的习惯是先用脚手架默认的示例把链路跑通再开始改造直接上来就删删除删很容易漏掉 Provider、Router 注册等串联点排查问题时反而更麻烦。Node 版本也注意一下建议 18.17 及以上太老的版本会遇到依赖安装失败或启动报错的问题。3.2 设计数据库表与执行迁移在设计阶段先写好 Prisma schema然后执行迁移生成表结构。整个过程非常线性改 schema、写迁移、启动数据库、校验数据。model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] snips Snip[] sessions Session[] } model Tag { id String id default(cuid()) name String unique snips Snip[] }npx prisma migrate dev --name initmigrate dev很适合开发环境自动对比 schema 变化生成 SQL 迁移文件执行后还会重新生成 Prisma Client。部署到生产时记得改用npx prisma migrate deploy避免在服务器上意外触发交互式迁移流程。数据验证用npx prisma studio它会打开一个本地可视化界面直接查看和编辑表数据。我通常用它来确认迁移后字段是否如预期尤其是外键关系是否建立成功。数据库连接串如果密码里带特殊字符比如、#需要使用 URL 编码否则连接会一直失败而报错信息可能非常隐晦。3.3 业务核心片段的增删改查与公开私有创建接口在上文已经给出这里重点说查询列表。列表页如果只做“全量返回”开发期没问题但数据一到几百条就会发现接口响应越来越大、页面越来越卡。所以我用游标分页来做列表。list: protectedProcedure .input( z.object({ cursor: z.string().nullish(), take: z.number().max(50).default(10), }) ) .query(async ({ ctx, input }) { const items await ctx.db.snip.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { createdAt: desc }, take: input.take 1, cursor: input.cursor ? { id: input.cursor } : undefined, include: { tags: { select: { name: true } } }, }); let nextCursor: typeof input.cursor | undefined; if (items.length input.take) { const extra items.pop(); nextCursor extra?.id; } return { items, nextCursor }; });这段代码解决的是一个特别具体的分页问题游标分页需要知道“是否还有下一页”。实现办法是查询时多取一条take 1如果结果数大于请求数说明还有更多数据把多出来的那条作为 nextCursor 返回同时用pop()把它从本次结果里移除。这个“多取一判一”的模式比什么都简单也不会因为中间有新数据插入而漏行稳定性比传统 offset 分页高不少。更新和删除接口需要注意越权问题。最稳妥的做法是直接在where条件里加上authorId例如updateMany({ where: { id: input.id, authorId: ctx.session.user.id }, data: ... })。这样即使有人恶意传了别人的片段 ID数据库也查不到匹配记录。返回的记录数如果为 0说明要么 ID 不存在要么不是本人的内容直接抛NOT_FOUND即可。3.4 搜索与标签过滤先跑通再说模糊搜索列表页的“搜索”功能可能名不副实先别指望上 Elasticsearch。t3code 的第一步实现很朴素针对 title 和 language 字段做过滤标签采用关系表的部分匹配。Prisma 的 where 拼装可以用下边这种形式。const where: Prisma.SnipWhereInput { authorId: ctx.session.user.id, ...(q ? { title: { contains: q } } : {}), ...(language ? { language } : {}), ...(tagName ? { tags: { some: { name: tagName } } } : {}), };contains在 PostgreSQL 上会映射成 ILIKE默认大小写不敏感对英文搜索比较友好。但这只是起步版本它不会利用普通索引数据量小的时候没啥感觉到几千条以上就能观察到明显的延迟。如果要继续深入可以考虑 PostgreSQL 的pg_trgm扩展或者引入全文检索。我在项目里的取舍是先把功能跑通明确知道边界在哪儿等实际数据量到了阈值再升级不做提前过度设计。3.5 部署与运行配置本地开发数据库我直接用 Docker 起了一个 PostgreSQLdocker run --name t3code-postgres \ -e POSTGRES_PASSWORDlocalpass \ -e POSTGRES_DBt3code \ -p 5432:5432 \ -d postgres:16部署到 Vercel 时Prisma Client 的生成时序是关键。Vercel 的 build 阶段会自动执行prisma generate吗不一定。建议在package.json的 postinstall 或 build 脚本里显式加一步build: prisma generate next build如果跳过这步构建过程极容易报“PrismaClientInitializationError”或者找不到 generated types。另一个常见问题是运行时长限制Vercel Serverless Function 默认执行时间有限所以代码里一定别写长同步任务t3code 这种简单 CRUD 完全没问题。如果自托管则需要把 Next.js 输出模式改一下在next.config.js里配置module.exports { output: standalone };然后npm run build产物会生成自包含的 Node.js 服务直接node .next/standalone/server.js就能跑起来。要注意生产环境下 cookie 的 Secure 属性HTTPS 是必须的否则 session 在部分浏览器下无法写入用 nginx 反代时一定把X-Forwarded-Proto报文头设置好。4. 常见问题与排查技巧实录4.1 tRPC 类型不同步到了前端这个问题出现率极高。现象是后端改了返回字段前端调用处仍然显示旧类型甚至直接报TS2339: Property xxx does not exist。本质是 TypeScript 的增量编译缓存没刷新。解决流程按顺序尝试重启next dev开发服务器清空.next目录和node_modules/.cache退到底检查 TypeScript 版本是否一致。我多次遇到这类问题都是“改后端、切前端、发现类型没变”冷静一清理缓存就恢复。遇到这种问题不要急着改业务代码先怀疑缓存。4.2 Prisma 报 “Environment variable not found: DATABASE_URL”看起来是环境变量配置问题实际原因通常有三类。第一.env文件不在根目录。create-t3-app 默认加载根目录.env如果你把它放到了src或者prisma目录Prisma CLI 是读不到的。第二部署环境中没有配置对应的环境变量本地跑得好好的一部署就报错绝大多数是少了这一步。第三DATABASE_URL里的密码或者主机名写错了比如生产数据库要求 SSL而连接串漏了?sslmoderequire。排查时可以先在项目根目录执行npx prisma generate如果它能成功说明环境变量加载没问题如果报同样的错误就直接检查文件路径和变量名拼写。4.3 Hydration 不匹配页面渲染出的内容对不上这个问题的典型场景是主题切换。比如根据用户本地存储的偏好在首次渲染时就显示深色模式但服务端渲染的 HTML 里永远是浅色于是 React 在客户端 hydration 时就会报警告“Text content does not match server-rendered HTML”。根治手段是把依赖浏览器环境的判断放进useEffect里执行这样首次服务端渲染和客户端渲染的结果保持一致等到组件挂载后再更新 UI。对于纯展示型、无交互逻辑的小组件也可以考虑suppressHydrationWarning但这属于治标不治本不要大量使用。4.4 请求 404 或 500404 大概率是路由没有串联。检查app/api/trpc/[trpc]/route.ts是否存在rootRouter 里是否注册了新写的 router。500 则需要看服务端日志tRPC 的错误堆栈默认不会直接展示给浏览器建议在createTRPCContext里包一层全局 error handler把error.message和error.stack打印到控制台。排查接口问题时我习惯于先绕过浏览器直接用 curl 发一个 POST 请求curl -X POST \ http://localhost:3000/api/trpc/snip.list?batch1 \ -H content-type: application/json \ -d {0:{json:null}}这样可以快速区分问题出在前端调用还是后端处理。如果 curl 返回了正常数据那就是前端 hooks 使用方式有问题如果返回错误就直接根据响应体定位服务端逻辑。4.5 Tailwind 动态 class 不生效Tailwind 的 JIT 编译只会扫描源码中出现的完整类名字符串。如果写成className{bg-${color}-500}生产构建时这个类名会被 purge页面渲染出来就是一个没有背景色的元素而且本地开发可能一切正常打包后才发现。标准解法是把类名维护成完整的映射表而不是动态拼接const colorClassMap: Recordstring, string { red: bg-red-500, blue: bg-blue-500, };然后在组件里通过colorClassMap[color]取值。类名在源码中以完整字符串存在Tailwind 就能扫描到并保留下来。用 safelist 也能解决但能不用就不用保持源码整洁更重要。5. 经验精华与扩展方向5.1 类型安全的价值要在维护期才能感受到写第一版时zod schema、Prisma model、tRPC 类型确实会让人觉得“多了一层”但真正到了第三周、第四周回头改需求时收益一下子就出来了。改数据结构后所有调用方在编译期亮红灯哪些页面受影响一目了然不用靠记忆和全局搜索。t3code 开发到后期我几乎把“改数据”这件事完全交给类型系统守护心里踏实很多。还有一个体会是把输入校验尽量放在 zod schema 里面不要堆在业务代码中做防御式 if。后者会让业务逻辑被分支淹没前者则让约束跟接口定义放在一起天然可维护。5.2 我最后悔的三件事第一件事是一开始就加了团队分享功能开发优先级错了。对 MVP 来说应该先把私有片段存、取、搜这个核心闭环做好分享功能后面再加完全不影响主体架构。第二件事是低估了代码高亮的复杂度。自己封装代码编辑区和高亮逻辑花了一周最后换成成熟方案半天就结束浪费得最不值。如果再做类似项目会把第三方库的选型决策前置。第三件事是没第一时间做内容全文检索。后期想补时发现直接用contains扫Text字段非常慢才认真考虑倒排索引。小项目可能无所谓但提前把搜索边界想清楚能省一大轮返工。5.3 可以扩展的方向t3code 做完整条主链路后还可以衍生出不少实用功能接入 AI 自动打标签把片段发送给模型返回建议标签减少人工整理成本。做浏览器扩展选中网页里的代码右键一键保存到 t3code把收集成本降到最低。与 Git Hooks 联动在 commit 或 push 时自动提取项目内新增的典型代码片段沉淀团队公共知识。升级全文检索Postgres 的tsvector可以支持中文分词或者引入 Meilisearch 这类轻量检索引擎把搜索做到毫秒级。扩展方向有很多但不一定每个都要做按自己的实际需要选一两个就好。持续做一个小项目比一次想做大而全的系统更容易坚持。做到这里t3code 已经从当初随手起的代号变成了我日常离不开的工具。我现在几乎每天都会往里存几个片段周末顺手整理一遍标签。最后分享一个个人调试习惯项目里所有对外暴露的接口一定先把 zod schema 写完整再写 handler后端少点防御式 if让校验层挡住脏数据代码会干净很多。项目本身不需要多大把核心链路跑通再一点点加功能这样的工程生命周期往往比一开始想象的要长得多。