☰
基于T3 Stack与tRPC构建私有代码片段管理工具的实践
2026/10/27 16:24:40 网站建设 项目流程

"t3code" 这个名字听起来像某个技术圈子的暗号,其实它是我最近用 T3 Stack 做的一个个人项目代号——一个私有的代码片段管理工具。起因很实在:做前端开发这几年,我攒了一堆随手验证过的小片段,分布在本地文件、公司 Wiki、微信收藏、甚至聊天记录里,真到要用的时候不是找不到就是版本不对,所以干脆自己搞一个统一入口,把散落的内容收拢起来,支持打标签、全文搜索、一键复制,也算给自己一亩三分地做一次彻底清理。

但项目真正做下来,最有价值的反而不是收藏夹本身,而是把 T3 这套全栈 TypeScript 方案在真实场景里跑通的过程。如果你正准备上手 T3 技术栈,或者正在纠结内部工具要不要从 REST 切到 tRPC,这篇文章里的选型理由、代码骨架、部署细节和踩坑复盘,应该能帮你省下不少排查时间。我按"设计思路 -> 数据链路 -> 前端实现 -> 部署运维 -> 踩坑复盘"的顺序摊开讲,尽量做到看过就能抄作业。

1. 项目整体设计与思路拆解

1.1 为什么选了 T3 这套全家桶

先交代一下 t3code 的定位:它不是一个公共产品,而是解决我自己"代码碎片管理"问题的垂直工具。这类内部工具有一个共同特点:并发量低、功能迭代快、团队规模小甚至就我一个人。这种场景下,前后端分离 + 接口文档 + 联调流程的成本明显偏高,我更想要的是"改一个类型定义,全链路立刻感知"的开发体验,这一点恰好是 T3 整套设计哲学最擅长的地方。

对比一下主流方案会发现,T3 的选择非常激进:直接放弃传统 REST API,用 tRPC 在客户端调用服务端函数;数据访问层用 Prisma 而不是手写 SQL;样式直接用 Tailwind 原子类。给人的第一感觉可能是不适应,但用顺手之后,最大的收益是几乎不需要维护接口层代码,类型从数据库一路穿到 UI 组件,改个字段,编译期就能把错误暴露出来。选这套技术栈不是因为它潮,而是因为它让一个人维护全栈项目时的心智负担降到了最低。

当时我也短暂纠结过要不要用传统方案:后端用 Express 或 NestJS 出一套 REST 接口,前端用 Axios 接。但算了一笔账就放弃了——一个小工具要写 router、controller、dto、前端 api 封装、类型定义,至少小几百行重复代码。tRPC 能把这部分直接省略,换来的是可接受的运行时开销。收益和成本一比,答案很明显。

1.2 功能边界与数据流设计

t3code 的功能不算多,我把它拆成三大块:认证与用户体系、片段管理、标签与搜索。因为 App Router 的约定式路由和 tRPC 的 router 结构天然适合模块化拆分,所以每个功能块都对应一个独立的路由模块,后面加功能像搭积木一样,不会互相污染。

数据流走的是 T3 的标准链路:React 组件发起 tRPC 调用,请求被轻量级序列化后送到服务端 resolver,resolver 再调用 Prisma 操作 PostgreSQL,结果原路返回并由 React Query 接管缓存和状态更新。这个链路里几乎不存在手写的"请求 -> 响应 -> 转换 DTO"代码,所有数据形状都是 TypeScript 类型直接推导出来的。这也是我后来哪怕只是做一个几十行的小功能,也不想再回到 REST 的原因。

权限模型也做得比较保守:片段要么是私有的,要么是公开的,公开片段需要登录后才能查看,防止爬虫扫到敏感内容。标签采用多对多关系,搜索用 PostgreSQL 的 ILIKE 做基础实现,没有上全文索引,理由很简单——个人工具的搜索量级根本用不上复杂方案,过度设计才是最大的敌人。

2. 核心细节解析与实操要点

2.1 工程初始化与目录约定的关键选择

t3code 由 create-t3-app 脚手架生成,交互式选项里我勾选了 NextAuth、Prisma、Tailwind、tRPC,没选 Drizzle 是因为当时 Prisma 在 T3 生态里更成熟,资料多,踩坑也相对可控。初始化完成后的目录结构是这样的:

t3code/ ├─ app/ │ ├─ api/trpc/[trpc]/route.ts │ ├─ (auth)/login/page.tsx │ ├─ (main)/snippets/ │ ├─ layout.tsx │ └─ providers.tsx ├─ components/ ├─ prisma/schema.prisma ├─ server/ │ ├─ api/ │ │ ├─ root.ts │ │ ├─ routers/ │ │ │ ├─ auth.ts │ │ │ ├─ snippet.ts │ │ │ └─ tag.ts │ │ ├─ rpc.ts │ │ └─ trpc.ts ├─ src/(或项目根目录的 tsconfig 相关配置) └─ .env

注意 App Router 下有个容易混的地方:app/api/trpc/[trpc]/route.ts是 tRPC 的 HTTP 入口,逻辑非常固定,几乎不用改。真正需要自己设计的是server/api/routers目录下的模块边界。我习惯把"资源维度"作为切分依据,而不是按页面切,因为一个资源可能被多个页面共用,按页面切会导致 router 之间互相引用,类型推导变得混乱。

初始化的另一个关键是环境变量。.env里至少要配DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL三个变量。当时我没注意NEXTAUTH_SECRET在生产环境必须手动设置,结果部署到线上后一直报 JWT 解密失败,查了半天才发现是环境变量缺失,脚手架默认只在开发环境给你生成一个临时密钥。

2.2 tRPC 路由设计与类型安全机制

tRPC 的核心原理很朴素:把服务端函数像本地函数一样暴露给客户端调用,中间通过一个轻量 JSON 协议传输参数和返回结果。它最大的优势是类型安全不是靠运行时校验,而是靠 TypeScript 的类型推导——服务端 resolver 的返回类型定了,客户端拿到的数据形状就定了,中间没有 DTO、没有手动类型断言。

我在 t3code 里把 tRPC 分成三层设计:

  1. 底层server/api/trpc.ts定义createTRPCContext和publicProcedure、protectedProcedure。createTRPCContext里从 NextAuth 拿 session,塞进每个请求的上下文。
  2. 中间层server/api/rpc.ts负责服务端调用入口。
  3. 业务层server/api/routers/按业务模块暴露接口。

核心代码大致长这样:

// server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ list: protectedProcedure .query(async ({ ctx }) => { return ctx.db.snippet.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, include: { tags: true }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default("plaintext"), tagIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, ownerId: ctx.session.user.id, tags: input.tagIds ? { connect: input.tagIds.map((id) => ({ id })) } : undefined, }, }); }), });

这里的protectedProcedure是 T3 生态里一个很经典的封装:它在执行前先检查ctx.session是否存在,不存在就抛错,不需要在每个 resolver 里重复写鉴权判断。这个模式强烈建议推广到所有业务路由,尤其是带写操作的功能。

另一个要点是zod的输入校验。tRPC 和 zod 是深度集成的,每个 mutation 的输入都可以用 zod schema 声明,运行时做校验,编译期做类型推导。写过一段时间的同学应该能体会到,这等于把"参数校验"和"类型安全"一次性解决了,不再需要前后端各写一套校验逻辑。

2.3 Prisma 数据模型与迁移策略

t3code 的数据库只有四张表:User、Snippet、Tag、SnippetTag(多对多关系表)。Prisma schema 如下:

// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] tags Tag[] } model Snippet { id String @id @default(cuid()) title String content String @db.Text language String @default("plaintext") isPublic Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt owner User @relation(fields: [ownerId], references: [id], onDelete: CASCADE) ownerId String tags Tag[] @relation("SnippetTags") } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] @relation("SnippetTags") }

多对多关系是这里最需要留意的部分。Prisma 对隐式多对多会生成一张中间表,但如果中间表需要额外字段(比如"某个片段在某个标签下的排序权重"),就必须改为显式关系模型。我在 t3code 里用的是隐式多对多,因为暂时没有扩展需求,等真需要时再改也不迟——这符合 Prisma 官方推荐的演进步骤:先跑通功能,再按需演化。

迁移策略上,本地开发建议直接用prisma migrate dev,它会自动生成 SQL 迁移文件并同步到数据库。但有一点要提醒:如果你改 schema 只是临时加个字段测试,用prisma db push更轻量,它不产生迁移文件,只把 schema 同步到数据库。生产环境则要走正规prisma migrate deploy,确保迁移历史一致。我前期在本地频繁改表,把db push和migrate dev混用,结果导致迁移历史文件出现冲突,最后只能清空重建,教训很深刻。

2.4 Tailwind 主题设计与暗色模式

样式部分我用 Tailwind 搭配 next-themes 实现了明暗主题切换。Tailwind 3.3 之后的暗色模式有两个选择:media策略跟随系统,class策略手动切换。t3code 因为是开发者工具,用户习惯自己控制主题,所以选了class策略,并在tailwind.config.ts里把 darkMode 设为 class。

组件层面,我尽可能先把颜色抽成语义化命名,避免在 JSX 里堆满dark:bg-gray-800之类的重复类名。这个习惯在项目变复杂后收益明显,改动一下全局配色,只动配置文件就够了。另外,代码高亮用的 rehype-pretty-code 集成在 MDX 流程里,但 t3code 的片段内容并不是 Markdown 文档,而是纯文本,所以高亮直接交给 Prism 在组件里做,没有引入重量级渲染器。对于只想展示代码块的场景,轻量方案比全面方案合适得多。

3. 实操过程与核心环节实现

3.1 从零开始的项目初始化步骤

如果你也想复刻一个类似项目,初始化的完整操作是这样的:

# 1. 创建项目 pnpm create t3-app@latest t3code # 进入交互后勾选 NextAuth、Prisma、Tailwind、tRPC # 2. 安装依赖并启动开发数据库(这里用 Docker 起 PostgreSQL) docker run --name t3code-db -e POSTGRES_USER=t3code -e POSTGRES_PASSWORD=t3code -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16 # 3. 配置 .env 里的 DATABASE_URL # postgresql://t3code:t3code@localhost:5432/t3code # 4. 首次同步数据库 pnpm prisma migrate dev --name init # 5. 启动开发服务器 pnpm dev

这里有个值得说的细节:create-t3-app默认把 NextAuth 的依赖和示例代码都生成好了,但你仍然需要去server/api/trpc.ts里确认 session 的获取逻辑。默认生成的createTRPCContext是从getServerSession拿 session 的,这在服务端组件和路由处理器里没问题,但如果某个页面是纯客户端组件渲染,就要注意 session 可能为空,需要显式传 token 或改用其他获取方式。

3.2 CRUD 接口的完整实现链路

以前面列出的 snippet router 为基础,前端调用逻辑通过 tRPC 的 React Query 集成来写。t3code 的列表页大概长这样:

// app/(main)/snippets/page.tsx "use client"; import { api } from "~/trpc/react"; export default function SnippetListPage() { const utils = api.useUtils(); const { data: snippets, isLoading } = api.snippet.list.useQuery(); const deleteSnippet = api.snippet.delete.useMutation({ onSuccess: () => utils.snippet.list.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <div className="grid gap-4"> {snippets?.map((snippet) => ( <SnippetCard key={snippet.id} snippet={snippet} onDelete={() => deleteSnippet.mutate({ id: snippet.id })} /> ))} </div> ); }

注意api.useUtils()的用法,它是 tRPC v10 之后推荐的缓存管理方式,可以通过invalidate让某个查询重新拉取数据。以前很多人习惯在 mutation 成功后手动调router.refresh()或用useState强行刷新页面,但正确的做法是让数据层感知变化。这个认知转变对使用 T3 框架非常重要:React Query 的缓存、失效、重试机制本身就是"状态管理",没必要再用 Redux 或者自研 store。

删除接口的实现也很简单:

delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.deleteMany({ where: { id: input.id, ownerId: ctx.session.user.id, }, }); }),

这里我用的是deleteMany而不是delete,同时把ownerId放进过滤条件。这样即使请求被恶意构造,也只能删自己的片段,等于在数据库层面又加了一道权限保护。很多新手写删除接口时只传 id,那是非常危险的。

3.3 认证流程与用户体系接入

基于 T3 生态的推荐方案,用户体系直接用 NextAuth,GitHub Provider 作为登录方式。认证流程实现起来意外地短,核心配置如下:

// server/auth.ts import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], session: { strategy: "jwt" }, callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (session.user && token.id) { session.user.id = token.id as string; } return session; }, }, });

这里有个值得注意的选择:PrismaAdapter配合 JWT 策略。默认情况下,使用数据库适配器会倾向于把 session 存在数据库里,但在 serverless 环境,每次请求都查 session 表会额外引入一次数据库查询,性能上不划算。JWT 方案把 session 信息编码进 cookie 里,减少一次 IO,代价是登出状态有一定的缓存时间。对 t3code 这种对安全等级要求不极端的小工具,JWT 是更务实的选择。

认证接入后,前端可以很方便地判断用户状态。登录页直接调用signIn("github"),退出用signOut(),中间页面判空跳转即可。整个认证链路在 T3 里属于"脚手架级"体验,真正要花心思的反而是 session 类型扩展——注意在next-auth.d.ts里声明session.user.id,否则 TypeScript 会一直提示你访问了不存在的属性。

3.4 部署上线与环境配置

部署我用的是 Vercel,因为和 Next.js 的亲和度最高,一个小工具直接推 GitHub 仓库自动构建即可。部署前需要准备的配置项包括:

  • DATABASE_URL:指向生产数据库,建议用云厂商的 PostgreSQL,不要用 Vercel Postgres 或者其他和平台绑定的存储来做核心库,避免未来迁移成本。
  • NEXTAUTH_SECRET:必填,可以在本地用openssl rand -base64 32生成。
  • NEXTAUTH_URL:正式域名地址,比如https://t3code.vercel.app。
  • GITHUB_ID/GITHUB_SECRET:GitHub OAuth App 的回调地址要改成生产域名。

部署后最常踩的坑是 Prisma Client 没有正确生成。Vercel 构建时需要在postinstall或构建命令里加上prisma generate,否则线上环境找不到客户端。我一开始没配,结果本地好好的,线上报PrismaClient is not defined,排查十分钟才想到是构建阶段的问题。

另一个线上环境的重要技巧:如果数据库连接字符串用的是连接池形式的 URL,比如带?pgbouncer=true或加pool_timeout参数的,Prisma 在 serverless 环境对连接数很敏感,很容易触发连接数上限。解决方式是在简历中把 Prisma 的客户端配置改成?connection_limit=5,限制单实例的连接池大小,避免冷启动时创建过多连接。这个细节不放真实环境里跑一遍,靠看文档很难意识到。

4. 常见问题与排查技巧实录

4.1 类型推导失灵的三个典型场景

tRPC 用起来最顺畅的时刻是类型全对的时候,但类型一旦"静默失效",排查起来就很折磨。我遇到最多的三类情况:

  1. 跨度组件的类型不刷新。在 Next.js App Router 下,如果你在 Server Component 里调用了 tRPC 并传递数据给 Client Component,这时类型会通过 props 传递,如果中途做了JSON.stringify或as any,类型链就会断开,后续报错会让你误以为是组件问题。解决方式:尽量不让 tRPC 查询结果跨过组件边界,每个需要数据的地方自己调一次 query,让 React Query 去处理缓存。
  2. 输入类型不匹配。zod 的默认类型和 Prisma 的字段类型偶尔会出现推导差异,比如数字字段返回给前端后变成字符串。遇到这种情况,优先检查表单提交有没有做隐式转换,而不是怀疑 tRPC。
  3. 没有调用useQuery就访问 data。api.snippet.list.useQuery()返回的 data 可能在首帧是 undefined,有些人会在useEffect里直接访问,导致类型收窄失败。最佳实践是用isLoading或isSuccess做分支判断,让 TypeScript 在控制流分析里收窄类型。

4.2 Prisma 迁移冲突与连接问题

Prisma 的坑是 T3 项目里最脏最累的部分。最典型的场景是多人协作或本地多次改动 schema 后,migrations文件夹和数据库的迁移记录不一致,跑prisma migrate dev时会提示"检测到漂移",然后要求你重置数据库。这个重置行为会把所有数据清空,在开发环境无所谓,但对那些已经往数据库里塞了真实测试数据的人,非常容易造成心理阴影。

我的规避办法是:本地开发尽量用db push来快速同步 schema,只有在生产环境或者需要稳定迁移记录时才跑migrate。同时,每次改 schema 前先备份一次数据库,用一条pg_dump命令把数据拉下来,成本极低,收益极高。

连接问题主要在部署后出现。serverless 环境里每次函数冷启动都会创建新的 Prisma 客户端实例,如果直接用全局单例的写法,可能因为连接数堆积导致数据库请求延迟飙升。官方推荐的全局单例写法如下:

// server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这个写法在开发环境利用 global 缓存单例,在生产环境每次冷启动仍然会新建实例,但配合连接数限制后实测可控。

4.3 组件边界与"use client"误用问题

App Router 的组件边界是所有 Next.js 开发者绕不开的门槛。t3code 里我遇到过这样一段代码:

// 错误示范 import { SnippetCard } from "~/components/SnippetCard"; // 内部用了 useState import { api } from "~/trpc/server"; // 服务端调用 export default async function Page() { const snippets = await api.snippet.list(); return <SnippetCard snippets={snippets} />; // 传给客户端组件时类型兼容出问题 }

api从~/trpc/server导入,返回的是一个 Promise 的结果,传给客户端组件后,组件内部如果要操作数据(删除、更新),需要重新发起客户端查询,直接用服务端返回的数据会丢失响应式。后来我统一改成:页面组件一律用"use client"+useQuery,需要 SEO 的少数页面再用服务端组件包一层壳,避免在边界问题上反复折腾。

还有一个细节:所有与 tRPC 交互的组件都必须被TRPCProvider包裹,这是 t3code 里放在app/providers.tsx的核心根因。如果你在某个独立组件里忘了包裹就调用api.*,运行时会报No TRPCClient instance found,且这个错误不像类型错误那么好理解。

4.4 React Query 缓存与数据一致性问题

React Query 是 tRPC 前端状态管理的发动机,但大多数人只会用useQuery和useMutation,对缓存失效机制理解不足。t3code 里我只做了一个很小的功能——创建新片段后,列表页立即刷新。实现方式是在创建 mutation 的onSuccess回调里调用utils.snippet.list.invalidate()。这个操作会标记查询为过期,触发重新拉取。

但这里有个隐藏问题:如果列表查询带分页参数,invalidate只让当前页的查询失效,其他页可能仍然是旧数据。解决方案有两个:要么把缓存 key 设置成包含所有参数的形式,要么干脆用utils.snippet.list.refetch()强制全量重拉。个人工具的规模小,我选择了 refetch 全量,简单粗暴效。理解这套缓存语义,比背住几个 API 方法重要得多。

5. 复盘与后续扩展思路

5.1 最有价值的三条工程经验

  1. 单体优先不是口号,是真的省心。t3code 的核心链路从数据库到 UI 都是 TypeScript,我没写过一行接口文档,没做过一次联调,也没维护过 mock 数据。这种内聚性在一个人或小团队场景下,简直是效率倍增器。
  2. 类型安全不等于不需要运行时校验。虽然 tRPC 和 zod 的组合让类型推导极其强大,但外部输入、数据库返回值、第三方回调仍然存在运行时的不确定性。所以在所有 mutation 入口保留 zod 校验,在关键查询上保留isLoading分支,是我坚持不破坏的底线。
  3. 工具链的原生配置别乱改。create-t3-app 生成的默认配置(路径别名、tsconfig、eslint 规则)都是社区反复打磨过的,如果不是有明确理由,尽量不要做激进的替代。我有一次为了"更清晰"调整了 tsconfig 的 paths 配置,结果导致部分类型别名解析异常,浪费了半小时。

5.2 下一步想做的改进方向

t3code 目前能跑能用,但离"爽"还有距离。后续如果继续迭代,我优先考虑三个方向:

  • 把搜索从ILIKE升级到 PostgreSQL 全文搜索,加一个tsvector索引,让检索速度和相关性排序都上一个台阶。
  • 做浏览器插件或命令行工具,方便在任意编辑器里直接保存选中代码到 t3code,真正成为一个随时可用的第二大脑。
  • 补充更多安全措施,比如公开片段的访问频率限制、敏感模式自动剔除,以及审计日志。

不过这些都是"以后再说"的清单。这次开发最大的体会已经拿到手了:技术选型不需要追新,而是要找能让你用最少的代码解决真实问题的那套组合。T3 对很多人来说只是又一个框架缩写,但它在 t3code 里确实把我的开发节奏带快了不少。如果你也打算做内部工具或小体量产品,可以拿这篇文章当一份可行性参考,剩下的就是自己动手踩一踩那些只属于你的坑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询