在开发者圈子里T3 技术栈这几年出镜率一直很高而我手里这个叫 t3code 的项目就是把这些东西真正揉在一起做成的一个能用的产品。简单说它是一个基于 T3 技术栈TypeScript Next.js Tailwind CSS tRPC Prisma自建的代码片段管理工具用来替代散落在本地文件、聊天记录和各种临时笔记里的代码速查内容。这篇文章会把它的设计与实现拆开讲从为什么这么选型到数据模型怎么设计再到一步步落地的代码细节和踩坑记录适合想系统学习 T3 技术栈、或者正准备给自己的团队搭一套内部工具的朋友参考。1. t3code 项目定位为什么需要一个自建的代码片段管理器1.1 背景代码速查内容的分散问题先说痛点。做开发久了之后你会发现真正被反复使用的不是某个大型工程而是那些十几行甚至几行的小代码块一条正则表达式、一段 Dockerfile 写法、某个接口的权限校验代码、部署时常用的 Shell 命令。这些东西有一个共同特点就是平时记不住用的时候又必须精确。过去我是怎么管理的本地备忘录存一份公司 Wiki 粘一份微信收藏再留一份最后的结果是同一个规则出现三种不同版本谁也说不清哪份最新。商业的代码管理工具不是没有但要么太重要么数据不在自己手里对于一个小团队或个人开发者来说性价比不高。所以我决定自己写一个极简的片段管理器名字就叫 t3code既是项目代号也点明了它的底层T3 技术栈 代码片段管理。t3code 要解决的核心问题可以归纳成三件事快速记录、快速找到、轻量共享。记录的时候不需要打开完整的编辑器只需要标题、代码、语言和标签找的时候能按标签过滤、按关键词搜索共享的时候部署在一台小服务器上团队里谁都能看登录之后才能写。听起来很朴素但真正做起来涉及的细节并不少。1.2 需求拆解与功能边界动手写代码之前我先把需求拆了一遍同时把“坚决不做”的功能也列了出来。这一步非常关键因为内部工具最大的杀手从来不是功能不够而是功能过剩。核心功能只有四个片段列表支持分页、按标签过滤、按关键词搜索。片段详情展示代码内容、语言标签、创建时间和备注。新增/编辑/删除片段表单包含标题、代码、语言、备注、标签。标签管理标签自动去重展示侧边栏可以按标签聚合浏览。明确不做的功能包括多人实时协同、版本历史 diff、公开分享链接、移动端 App。这些功能不是说没有价值而是对一个自托管工具来说它们会把复杂度推高一个数量级。比如版本文档听起来简单但真要实现 diff 和历史回滚就要引入额外的存储方案和控制逻辑公开分享链接则意味着要处理权限边界和访问控制。与其做一个什么都沾一点但都不好用的东西不如把核心路径打磨顺。做完这个功能边界我再看 t3code 的形态一个页面在浏览器里跑后端只服务同一批使用者数据量撑死几十兆。这种情况下选型就变得很清晰了没必要上重型框架T3 技术栈几乎是量身定做。2. 技术选型拆解T3 技术栈为什么刚好合适2.1 T3 技术栈的组成与角色分工T3 技术栈不是某个库而是一套组合拳官方在 create-t3-app 里把它定义为 TypeScript Tailwind CSS tRPC Prisma宿主框架是 Next.js。这套组合里每个成员的定位都很明确成员作用类比Next.js App Router页面渲染 API 宿主整栋房子的框架TypeScript全栈类型约束电路开关哪里不对立刻跳闸Tailwind CSS页面样式装修材料即取即用tRPC前端函数直接调后端函数房子内部电话专线Prisma数据库访问与迁移水管系统接哪通哪选择这个组合最直接的理由是所有成员都是 TypeScript 原生或优先支持类型可以从前端一路贯穿到数据库。我不用维护一份手写 API 类型定义文件也不用在接口文档和代码之间反复同步。这一点在单体项目里可能体会不深但一旦进入迭代阶段改动一个数据字段会立刻在编辑器的类型提示里反映出来比在文档站里写“根据实际为准”要踏实得多。2.2 tRPC 与 REST/GraphQL 的选择逻辑有人在选型时可能会问为什么不直接用 REST 或者 GraphQL我当时的考虑是这样的t3code 是一个前后端同仓、调用方完全可控的内部工具。REST 的核心优势是通用性和无状态但代价是每个接口都要自己在客户端写 URL、序列化参数、处理状态码GraphQL 的好处是字段级选择和强类型但需要维护一套 schema 和解析器对一个连分页都只有一个 limit/offset 的小工具来说属于杀鸡用牛刀。tRPC 做了一个很聪明的取舍既然前后端跑在同一份代码仓库里那为什么不直接把函数调用作为通信原语前端写一行api.snippet.list.useQuery()后端写一个snippetRouter参数和返回值的类型就自动对齐了。期间不需要生成 client、不需要写 DTO、不需要处理手写请求适配层。这里要提醒一句tRPC 适合前后端同仓、调用方可控的项目。如果未来要把接口开放给第三方或者要做一个面向公众的后端服务建议在 tRPC 之外再包一层标准协议。t3code 一开始就把定位限定在内部共享所以 tRPC 的缺点在我们场景里几乎不构成影响。3. 数据模型与核心 API 设计3.1 Prisma Schema 的设计与取舍t3code 的数据模型非常直接片段Snippet、标签Tag、片段和标签的多对多关联SnippetTag。这里有两个细节值得说。一是标签为什么不用字符串数组字段而要用正规的多对多表。如果只考虑“保存”这个动作一个数组字段确实更省事但后续一旦要统计“每个标签下的片段数”、或者把一个标签改名数组方案就难看了。多对多表虽然多一张关联表但换来的是标签可以直接复用 ID 关联不会出现“同名不同义”的数据脏点。二是数据库引擎我选了 SQLite。很多人做新项目一上来就上 PostgreSQL其实对于 t3code 这种数据量SQLite 一个文件就能跑备份就是拷贝一份文件部署时也不用单独搭数据库服务。等以后数据量真的上来Prisma 的 datasource 只需要改一行 provider 和连接串迁移成本被 Prisma 消化掉大半。这也是工具类项目比较务实的做法。generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model Tag { id Int id default(autoincrement()) name String unique snippets SnippetTag[] createdAt DateTime default(now()) } model Snippet { id Int id default(autoincrement()) title String code String language String default(plaintext) notes String? createdAt DateTime default(now()) updatedAt DateTime updatedAt tags SnippetTag[] } model SnippetTag { snippetId Int tagId Int snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) id([snippetId, tagId]) }这段 schema 看起来简单但它覆盖了几个关键约束SnippetTag 使用复合主键避免同一对关系重复插入onDelete: Cascade保证删除片段时关联记录自动清掉Tag.name 的unique约束让 upsert 有了操作基础。3.2 tRPC Router 的边界划分接口层我用一个snippetRouter和tagRouter分别承载互不掺和。这符合 tRPC 的设计哲学每个 router 是一个独立的领域逻辑入口。snippet router 里放了五个 procedurelist、getById、create、update、delete。list 接收一个 filter 对象包含tag和search两个可选字段用来拼查询条件create 接收标题、代码、语言、备注和标签数组事务性地创建片段后再创建或复用标签。这里有一个容易被忽略的点标签的入库不能用简单create而是用upsert因为用户输入“utils”和“Utils”时必须有一个明确的规则。我的做法是先在服务端把标签都规范成小写再用upsert保证同名字只能有一条记录。代码写出来大概是这样import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const snippetRouter createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: { ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.search ? { OR: [ { title: { contains: input.search } }, { code: { contains: input.search } }, ], } : {}), }, include: { tags: { include: { tag: true } } }, orderBy: { updatedAt: desc }, }); }), getById: publicProcedure .input(z.object({ id: z.number() })) .query(async ({ ctx, input }) { return ctx.db.snippet.findUnique({ where: { id: input.id }, include: { tags: { include: { tag: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), code: z.string().min(1), language: z.string().default(plaintext), notes: z.string().optional(), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const normalizedTags input.tags.map((t) t.trim().toLowerCase()).filter(Boolean); const snippet await ctx.db.snippet.create({ data: { title: input.title, code: input.code, language: input.language, notes: input.notes, }, }); for (const name of normalizedTags) { const tag await ctx.db.tag.upsert({ where: { name }, create: { name }, update: {}, }); await ctx.db.snippetTag.create({ data: { snippetId: snippet.id, tagId: tag.id }, }); } return snippet; }), });3.3 输入校验与错误处理细节tRPC 和 zod 是深度集成的所有 input 都必须经过 zod schema 校验这一步不是可选项而是默认行为。比如title和code都设置了min(1)空字符串在进入业务逻辑之前就会被拦下不会带着空标题去访问数据库。language设置了default(plaintext)即使用户不传也不会让 Prisma 拿 undefined 去写非空字段。错误处理上我不建议把异常细节直接返回给前端。用户看到“PrismaClientKnownRequestError”没有意义更好的做法是让 tRPC 在开发环境打印堆栈生产环境统一映射成“保存失败请稍后重试”。t3code 里我用了一个很克制的中间件只有写操作进入受保护逻辑读操作全部走公开 procedure这样后端不会在鉴权上过度设计。4. 实操落地从零搭建一个 t3code4.1 初始化 T3 项目与基础配置如果用官方脚手架起步非常快。我推荐用 pnpm它在依赖安装速度和磁盘占用上比 npm 有明显优势pnpm create t3-applatest t3code交互式命令行会问是否启用各项能力我的选择是TypeScript 开启、ESLint 开启、Tailwind CSS 开启、App Router 开启、tRPC 开启、Prisma 开启、NextAuth.js 暂时关闭。NextAuth 当时是被我刻意关掉的。原因不是登录不重要而是 t3code 第一版的瓶颈在核心 CRUD先跑通再考虑权限是更务实的路径。不要一开始就把登录、session、token 刷新这些逻辑混进来否则你会在问题排查时分不清是 tRPC 的问题还是认证的问题。项目生成后第一件事是配置.env里的DATABASE_URL。SQLite 的写法很简单DATABASE_URLfile:./db.sqlite然后执行pnpm prisma migrate dev --name init初始化数据库结构。这个命令会同时完成三件事生成迁移文件、执行迁移、重新生成 Prisma Client。之后每当 schema 变动都走这个流程保持迁移链完整。4.2 数据初始化与种子数据工具类项目最尴尬的时刻是部署完后界面上空空如也用户不知道能干嘛。所以我写了prisma/seed.ts先塞几条有代表性的片段数据一条 TypeScript 的随机颜色工具函数、一条 Dockerfile 模板、一条常用 Git 命令速查。种子脚本里创建标签和片段的顺序需要注意我使用了两步操作而不是嵌套创建先 upsert 标签再创建片段最后建立关联。这样可以避免嵌套创建在多对多关系里语义不清晰的问题。import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { const tag await prisma.tag.upsert({ where: { name: docker }, create: { name: docker }, update: {}, }); const snippet await prisma.snippet.create({ data: { title: Docker Node 容器模板, language: dockerfile, code: FROM node:20-alpine\nWORKDIR /app\nCOPY package.json ./\nRUN npm install\nCOPY . .\nCMD [npm, run, start], notes: 适合小工具的容器基础模板, }, }); await prisma.snippetTag.create({ data: { snippetId: snippet.id, tagId: tag.id }, }); console.log(Seed 完成: ${snippet.title}); } main() .catch(console.error) .finally(() prisma.$disconnect());package.json 里需要加一行配置让 Prisma 知道用哪个命令执行种子数据prisma: { seed: tsx prisma/seed.ts }4.3 tRPC Router 与前端页面的串联t3code 的前端调后端核心就是 tRPC 的 hooks。以列表页为例页面挂载时调用api.snippet.list.useQuery()拿到数据之后直接渲染新增一条片段之后手动调用utils.snippet.list.invalidate()让 tRPC 自动刷新对应查询缓存。这里要特别提醒一个版本差异tRPC v10 里用于操作缓存的方法是useContext()到了 v11 改成了useUtils()。两个 API 的职责相同但如果你照着旧教程写 v11 代码会在类型检查时报错。如果项目是用 create-t3-app 最新版初始化的请一定用useUtils()。列表页的组件代码大致如下use client; import { useState } from react; import { api } from ~/trpc/react; export function SnippetList() { const utils api.useUtils(); const [tag, setTag] useState(); const { data: snippets, isLoading } api.snippet.list.useQuery({ tag: tag || undefined, }); const createSnippet api.snippet.create.useMutation({ onSuccess: () { utils.snippet.list.invalidate(); }, }); if (isLoading) return div加载中.../div; return ( div classNamegrid gap-4 {snippets?.map((snippet) ( div key{snippet.id} classNamerounded border p-4 h2{snippet.title}/h2 pre classNameoverflow-x-auto text-sm{snippet.code}/pre div classNameflex gap-2 {snippet.tags.map(({ tag }) ( span key{tag.id} classNamerounded bg-gray-100 px-2 py-0.5 text-xs #{tag.name} /span ))} /div /div ))} /div ); }你会注意到我从头到尾没有手写过一次 fetch、没有拼接 URL、没有处理 JSON 序列化。tRPC 把数据链路包装成了普通函数调用这让组件的代码可读性高了不少也大幅减少了联调阶段的低级错误。4.4 部署要点与数据持久化t3code 部署非常简单但我踩过一个不小的坑这里专门写一下。Next.js 的默认输出形态是标准 server 模式如果直接做容器化部署需要完整的 node_modules镜像体积很大。更好的做法是在next.config.ts里开启 standalone 输出const nextConfig { output: standalone, }; export default nextConfig;构建之后.next/standalone目录下会生成一个只包含运行时必要依赖的服务端代码目录。配合 Dockerfile 可以把镜像控制在比较小的体积而且不要忘记把prisma目录和生成的 Prisma Client 一起复制进去。否则运行时会报一个非常经典的错误Query engine library for current platform linux-musl-openssl-3.0.x missing。SQLite 的持久化是另一个细节。数据库文件不能被容器重建时清掉所以要挂载一个卷把数据库文件放在卷里。我的 Dockerfile 里是用环境变量DATABASE_URLfile:/data/t3code.db然后把/data声明为 VOLUME。这样即使容器重建原来的片段数据也不会丢。5. 常见问题与排查技巧实录5.1 开发与部署阶段最容易踩的 5 个坑第一个坑是 Next.js 15 的异步请求 API。如果你用 App Router 并且直接在 page 组件里读取params或searchParams在 15 的版本里这些值是 Promise必须await之后才能用。很多项目初始化完跑起来没事一旦给详情页写动态路由就编译报错或者运行时输出奇怪的对象字面量原因就在这里。第二个坑是 Prisma 的种子脚本跑不起来。原因多半是tsx没有被引入或者 package.json 里的 prisma.seed 路径写错。排错时可以先用npx tsx prisma/seed.ts直接执行确认脚本本身没有问题再检查配置。第三个坑是 tRPC 版本差异。上面已经提到useContext和useUtils的改名这不仅是例子也是我跟社区里很多人真实遇到过的问题。另外 tRPC v11 中中间件写法也有微调如果你从旧项目拷贝代码务必先确认trpc/*的版本再动手。第四个坑是 Docker 内的 Prisma 引擎缺失。这个报错信息写得很清楚但解决路径比较绕在构建阶段执行pnpm prisma generate还不够需要确保证生成的引擎二进制面向你实际运行的基础镜像平台。最简单的办法是不要用node:alpine搭配 slim 镜像跑 Prisma除非你确认 openssl 版本能对得上否则直接用node:20-slim或 Debian 系镜像省心得多。第五个坑是 Tailwind 样式不生效。常见原因是 content 配置没有包含正确路径。create-t3-app 默认生成的配置是可靠的但如果从老项目迁移记得检查tailwind.config.ts里的content是否覆盖src/**/*.tsx和src/**/*.ts。5.2 问题排查顺序与速查表工具类项目的问题通常集中在几个固定层面我总结了一套排查顺序先看前端有没有请求发出去再看 tRPC 返回的错误类型再看 Prisma 日志最后看数据库内容。这个顺序能快速定位 90% 的问题。症状常见原因处理方式页面能开但列表请求报 500tRPC context 里 db 未正确注入检查 src/server/api/trpc.ts 的 context 创建逻辑写入操作报标题不能为空zod 校验未通过看前端是否传了空字符串用 zod error 定位字段标签保存后列表里不显示关联表 SnippetTag 没有创建检查 create 逻辑里是否循环 upsert 标签容器重启后数据丢失SQLite 文件没有被持久化把数据库文件挂到 volume 或 bind mount构建后接口全部 500Prisma Client 未生成构建流程里执行 prisma generate这个表格不复杂但对新手来说问题出现时能快速锁定方向比从零开始看日志要高效得多。尤其“构建后接口全部 500”这一条我见到的次数最多十有八九是 Dockerfile 里漏掉了COPY --frombuilder /app/node_modules/.prisma ./node_modules/.prisma。t3code 做完之后我最深的体会是这类自建工具的价值不在于功能有多复杂而在于把一条数据从数据库到页面端到端的链路想清楚。T3 技术栈恰恰把这条链路压缩到了最短。整个项目从初始化到可部署核心代码也就是一个 schema、两个 router 和几个页面组件。后续如果再扩展我准备把 NextAuth 接上把写操作保护起来再顺手给代码块加一个复制按钮。对一个内部代码速查工具来说到这一步就已经是很好用的状态了。