作为一个写了快十年业务代码的人,我最受不了的其实不是复杂需求,而是每次想找一段以前写过的代码,都得翻遍本地文件、GitHub Gist、还有聊天记录的收藏夹。t3code就是我为了解决这个毛病,用T3 Stack从零搭出来的一个代码片段管理工具。T3 Stack指的是Next.js + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth.js这一套全家桶,而t3code的核心功能很纯粹:快速记录一段代码、按语言筛选、全文搜索、以及给每个片段加一条权限边界。如果你正在研究T3 Stack的全链路写法,或者正准备找一个能落地的全栈项目练手,这篇复盘应该能帮你省不少时间。
1. 项目从哪来:一个代码收藏强迫症的自救方案
1.1 散落的代码到底有多难找
写业务代码久了,每个人都会积累大量片段:工具函数、正则表达式、特定组件写法、SQL查询模板,甚至还有一堆改了三版才定稿的配置项。这些片段通常散落在好几个地方——IDE自带的snippet、公司Wiki、个人博客、聊天记录,有的还躺在旧电脑里没同步过。我统计过自己一个月内找代码的频次,平均每天至少七次,时间成本从几十秒到十几分钟不等。真正让人崩溃的是,你明知道这段代码写过,但就是想不起到底存哪了。
这种状态持续到某次项目重构,我需要找一个两年前写过的金额格式化函数。先翻了VS Code的snippet,没有;再搜微信收藏,一片混乱;最后在GitHub某个私有仓库里找到了——光是定位这一步就花了半个小时。当时我就决定,必须给自己做一个集中管理代码片段的小工具。t3code这个项目名,其实就是取T3 Stack的"T3"加上"code(代码片段)"的组合,目标是让收藏代码像发微博一样简单,让找代码像用搜索引擎一样快。
1.2 需求拆解:我对t3code的四个硬性要求
因为这毕竟是个人的效率工具,我一开始就不打算做一个功能庞杂的产品,而是把需求收敛成四个硬性要求:
- 记录成本要低:从打开页面到存下一个片段,最多不能超过三次点击。如果保存要填一堆表单,我用两次就会放弃。
- 检索要快:可以先按语言过滤,再在标题和代码内容里做模糊搜索。能不能全文检索是核心体验分水岭。
- 语法高亮要顺眼:作为一个每天跟代码打交道的人,看纯黑色代码块是一种折磨。高亮风格最好接近VS Code。
- 数据要私有可控:至少能做到"只有登录用户本人能看、能改、能删",不能像某些公开paste服务一样,所有人都能扫到你的私有片段。
这四个需求听起来简单,但放到技术选型里就会牵扯出一堆问题。比如"只有登录用户本人可见"意味着必须做认证系统,"全文搜索"意味着数据库查询不能只做等值匹配。我评估了几个方案,最后发现T3 Stack几乎是为这种个人全栈项目量身定做的。
1.3 为什么偏偏选T3 Stack
做选型时,我认真对比过三套方案。
第一套是传统的React + Express + REST API。这套最熟,但痛点也很明显:前端定义一个接口类型,后端又要重复定义一遍,改字段时经常两边不同步,等运行时报错才发现。对于个人项目来说,维护这种隐形的契约关系很消耗精力。
第二套是GraphQL。类型安全解决了,但引入了schema定义、resolver、codegen等一系列额外概念,对一个小工具来说太重了。杀鸡用牛刀,维护成本甚至比REST还高。
第三套就是T3 Stack。核心思路是端到端类型安全:用tRPC把前端调用和后端实现直接连起来,函数的入参和返回值类型会自动从后端推导到前端。改一个后端字段,前端编译立刻报错,根本不用等联调。
| 对比项 | React + Express REST | GraphQL | T3 Stack |
|---|---|---|---|
| 类型安全 | 需手写重复类型 | 需要schema和codegen | 自动推导,全链路覆盖 |
| 学习成本 | 低 | 高 | 中等 |
| 维护成本 | 前后端契约靠自觉 | 额外概念多 | 编译器盯着你 |
| 适合场景 | 团队传统项目 | 多客户端复杂查询 | 个人全栈/快速原型 |
一句话总结:tRPC把前后端的数据契约变成了编译期的检查,这对一个没有专职接口文档的私人项目来说太重要了。再加上create-t3-app这个官方脚手架,几分钟就能把项目骨架拉起来,我几乎没有犹豫就选了这条路。
2. 脚手架实战:create-t3-app的安装与项目结构
2.1 初始化命令与交互选项
我用的是pnpm,初始化命令是:
pnpm create t3-app@latest命令跑起来之后,CLI会问你项目名,我输入t3code。接下来是一串交互式选项,包括要不要启用Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint、Prettier这些模块。我当时的选择是全部启用。
为什么全选?我的想法很直接:既然这个项目的目标就是体验T3 Stack全链路,那先让每个模块都跑起来,看看它们怎么咬合,比一开始就精简配置更有参考价值。实际用下来,每个模块都有它存在的意义,没有一个让人觉得"纯属凑数"。比如ESLint和Prettier,虽然平时觉得烦,但在一个人写代码的时候确实是保证质量下限的底线。
有一点需要提醒,初始化结束后,CLI会自动运行prisma db push并尝试连接数据库。如果你还没准备好PostgreSQL,这一步会卡住。我当时的处理是先随便填一个数据库URL让流程继续,之后再去单独配置。
2.2 Prisma数据模型:从Snippet实体开始
作为整个项目的数据核心,Snippet模型我第一版设计成这样:
model Snippet { id String @id @default(cuid()) title String content String @db.Text language String @default("plaintext") tags String[] @default([]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation("Snippets", fields: [authorId], references: [id], onDelete: Cascade) authorId String }字段有几个地方值得细说。
language字段为什么用String而不是enum?因为编程语言实在太多了,而且新的语言还在不断冒出来,用enum意味着每次遇到新语言都要改数据库再迁移一次,很烦。用字符串虽然少了点数据库层面的约束,但配合前端一个select下拉框,已经能覆盖绝大多数场景,还能随时加自定义值。
tags字段为什么用String[]而不是单独的标签表?初版需求里,我只需要给片段打标签,不需要维护标签的颜色、描述、关联文章这些额外属性。PostgreSQL原生支持数组类型,Prisma映射得也干净。如果以后要做"按标签统计数量"或者"标签管理页面",再来拆表也不迟。在项目初期,把不必要的关系表砍掉,能让CRUD简单一大截。
2.3 目录结构:tRPC的代码到底放在哪
create-t3-app生成的项目结构里,和tRPC相关的核心文件都在src/server目录下:
src/server/api/root.ts:组装所有子路由的总入口src/server/api/routers/:具体业务路由,比如snippet.tssrc/server/api/trpc.ts:tRPC实例、上下文、procedure定义src/server/db.ts:Prisma客户端实例
我第一次看这个结构时有个疑问:为什么tRPC的"接口层"不叫controllers或者api?后来理解了,tRPC的哲学里没有独立接口层,每个Router就是一个后端功能模块,它直接导出函数给前端调用。这种"后端函数即API"的思路,在个人全栈项目里特别舒服——不用为每个接口单独写路由路径、HTTP方法、请求参数解析,一切都交给tRPC框架处理。
前端调用侧也有讲究。App Router下,tRPC的客户端被分成两份:一份给Server Component用,路径是src/trpc/server.ts;一份给Client Component用,路径是src/trpc/react.tsx。两者都是基于同一个AppRouter类型生成的,所以不管在哪一层调用,类型检查都完整存在。
3. 核心功能实现:从表单到查询的完整链路
3.1 用tRPC写第一个snippet接口
业务代码从snippetRouter开始,我的第一版实现了五个方法:创建、列表、详情、更新、删除。核心代码长这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default("plaintext"), tags: z.array(z.string()).default([]), })) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), list: protectedProcedure .input(z.object({ language: z.string().optional(), query: z.string().optional(), })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id, ...(input.language ? { language: input.language } : {}), ...(input.query ? { OR: [ { title: { contains: input.query, mode: "insensitive" } }, { content: { contains: input.query, mode: "insensitive" } }, ], } : {}), }, orderBy: { createdAt: "desc" }, take: 50, }); }), });这里有两个关键点。
第一,protectedProcedure和publicProcedure的区别。protectedProcedure会自动检查当前请求有没有登录态的session,没有就直接抛401,不用我在每个方法里写if判断。这是T3 Stack官方模板在trpc.ts里封装好的能力,非常实用。
第二,zod校验器的摆放位置。tRPC的input校验是运行在服务端的,但它提供了完整的TypeScript类型推导。前端表单传过来一个多余字段或错误类型,还没进数据库就被拦下了,这在项目维护期能省下大量排查接口参数不一致的时间。
3.2 前端调用:react-query与useMutation的体感
t3code的前端表单页直接用tRPC提供的React Hooks。比如新增片段的提交函数,长这样:
const utils = trpc.useUtils(); const createSnippet = trpc.snippet.create.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); router.push("/"); }, }); function handleSubmit(data: SnippetFormValues) { createSnippet.mutate(data); }用起来的体感是:create.mutate(data)调用后,如果后端报错,前端这里会第一时间拿到结构化的错误信息;如果成功,手动invalidate一下列表查询,页面数据就自动刷新了。整个过程没有任何手写的fetch、axios、错误码映射,也没有联调接口的文档沟通成本。
useQuery侧更简单,列表页直接这样:
const { data, isLoading } = trpc.snippet.list.useQuery({ language, query });数据加载状态、缓存、重新获取全都由react-query管理。对于个人项目,少写几十行状态管理代码是实实在在的好处。
3.3 语法高亮:我最终选了Shiki
语法高亮组件我不止试了一个,简单列一下踩过的情况。
react-syntax-highlighter最容易上手,但它是基于highlight.js的,语言包体积不小,而且高亮样式偏"网页感",和VS Code的观感有明显差距。Monaco Editor功能最强,但它是给IDE用的,光JS体积就快1MB,加载一个只读的代码展示页过于奢侈。CodeMirror 6可定制性强,但要配置一堆扩展包,初版学习成本不低。
最后我选了Shiki。它的核心优势有三个:
- 基于TextMate语法文件,高亮结果和VS Code是同一套配色逻辑,视觉上零违和感。
- 可以只在服务端调用
codeToHtml,直接生成带高亮class的静态HTML字符串,浏览器端不再加载几十万行的语法解析器。 - 按需加载语言包,我常用的JavaScript、TypeScript、Python、SQL、JSON这些加起来体积很小。
t3code的详情页阅读量最大,我用的是服务端渲染方案。做法很简单,直接在服务端调用codeToHtml,然后把生成的HTML用dangerouslySetInnerHTML插入页面。这样高亮工作完全发生在构建期或服务端,客户端拿到的就是纯HTML,首屏性能很理想。
import { codeToHtml } from "shiki"; async function SnippetViewer({ code, language }: { code: string; language: string }) { const html = await codeToHtml(code, { lang: language, theme: "github-dark-default", }); return <div dangerouslySetInnerHTML={{ __html: html }} />; }需要注意的是,language的值必须能被Shiki识别。如果你的片段是纯文本或者Shiki没有对应的语言包,要加一个fallback,否则会直接渲染失败。
3.4 搜索和筛选:Prisma查询怎么写才够用
搜索功能最关键的是where条件。我的实现思路是:语言筛选用精确匹配,关键字搜索则同时匹配标题和内容。Prisma的contains默认是区分大小写的,所以加上mode: "insensitive"让搜索更宽容一些,用户输入tRPC和trpc都能命中。
这个方案在数据量不大的时候完全够用。我预判个人片段的量级在几千条以内,contains走数据库扫描没任何压力。但如果以后数据量到十万条级别,或者需要支持中文分词、按相关度排序,那就要认真考虑Postgres的tsvector全文搜索,或者上Meilisearch这类外部搜索引擎了。我的原则是:第一版不做过度设计,等真实数据把这个方案压垮了再升级——而且到时候迁移路径也清晰,Prisma的raw查询可以平滑过渡到tsvector。
列表页的交互上我加了一个小细节:语言筛选和后端查询联动,但搜索关键字用防抖。用户输入时等300毫秒再发请求,既不会每敲一个字母就请求一次,又不会觉得搜索卡顿。前端体验上,这个300毫秒的取舍很重要。
4. 认证和权限:让t3code记住是谁在登录
4.1 GitHub OAuth的接入细节
NextAuth.js是T3 Stack认证层的默认选项。我选择GitHub作为唯一登录方式,因为个人项目大头就是自己用,GitHub账号也不需要额外注册新东西。
接入流程分三步。
第一步,去GitHub Developer Settings里创建一个OAuth App。填Homepage URL和Authorization callback URL时,回调地址必须是https://你的域名/api/auth/callback/github,这是最容易填错的地方。我见过不少人把回调地址写成了首页根路径,结果登录后NextAuth一直在回调里打转。
第二步,把生成的Client ID和Client Secret放进.env:
GITHUB_ID=你的_Client_ID GITHUB_SECRET=你的_Client_Secret NEXTAUTH_SECRET=一串随机字符串 NEXTAUTH_URL=http://localhost:3000NEXTAUTH_SECRET必须设置,这个字段是给session cookie做签名的。如果你不设置,NextAuth会抛警告,生产环境会直接拒绝登录。生成随机串可以用openssl rand -base64 32。
第三步,在src/server/auth.ts里配置providers: [GitHubProvider]。模板默认已经写好了,只需要把环境变量接上。比较贴心的是,T3 Stack模板里的session策略默认是jwt,也就是说用户信息是编码在cookie里的,不需要额外建Session表,对个人项目来说省了一次数据库查询。
4.2 tRPC上下文:把session塞到每个请求里
认证和业务API之间能不能无缝衔接,取决于tRPC的上下文。在src/server/api/trpc.ts里,模板已经写好了一个createTRPCContext函数,它会从请求头里拿到NextAuth的session对象,并挂到上下文中。
让我把官方模板的逻辑简化说明一下:每次tRPC请求进来,都会先执行getServerSession拿到当前登录用户。然后业务代码里就可以通过ctx.session.user访问当前用户信息。protectedProcedure正是依赖这个数值来判断是否放行——如果session为空,直接抛401错误。
这里的闭环是:GitHub登录成功 → NextAuth生成JWT cookie → 浏览器后续请求自动携带cookie → tRPC上下文解析出session → 业务代码知道"当前是谁"。整条链路不需要手写token分发和刷新逻辑,对个人项目来说省了很大一块工作量。
4.3 删除接口的权限校验:别只在前端隐藏按钮
在t3code里,最让我警惕的安全点是资源归属校验。很多人做个人工具时会想:前端已经把删除按钮隐藏了,坏人应该点不到吧?这个想法在真实互联网环境下非常危险。因为API接口是公开的,别人完全可以直接向你的接口发请求,绕过前端界面。
我的做法是,在删除和更新操作里,where条件必须同时包含id和authorId:
delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const result = await ctx.db.snippet.deleteMany({ where: { id: input.id, authorId: ctx.session.user.id, }, }); if (result.count === 0) { throw new Error("片段不存在或无权操作"); } return { success: true }; }),用deleteMany而不是delete有个好处:当传入的id不属于当前用户时,删除影响行数为0,我们把它当成"不存在"处理即可,不会抛数据库层的外键或记录不存在错误。这种设计即使用户猜到了别人的片段ID,也无法越权操作。
这个习惯从t3code开始,现在已经变成我写所有全栈项目的基本要求:任何资源操作,无论查询、更新还是删除,只要资源有归属概念,都要在数据库层把归属条件一起带上,而不是相信前端传来的userId参数。
5. 部署与实测:Vercel + Neon PostgreSQL
5.1 数据库与环境变量准备
本地开发阶段,我用Docker跑了一个PostgreSQL实例。部署到线上时,数据库我选了Neon,原因是它提供免费的serverless PostgreSQL,并且支持分支数据库和自动休眠,个人项目的量级完全够用,也不用自己维护数据库服务器。
把线上数据库连接串准备好之后,有几件必须做的事:
- 在本地先执行
prisma migrate dev生成迁移文件,再执行prisma migrate deploy在线上库跑一遍迁移。 - 设置环境变量时,数据库连接串要加上
?sslmode=require,确保是加密连接。Vercel的Serverless函数和Neon之间走的是公网,不加密等于裸奔。 NEXTAUTH_URL要改成线上域名。我一开始忘了改,导致线上OAuth回调一直跳回localhost,登录永远失败。
环境变量清单大致如下:
| 变量 | 作用 | 必填 |
|---|---|---|
DATABASE_URL | Prisma连接生产PostgreSQL | 是 |
NEXTAUTH_URL | NextAuth回调的基准地址 | 是 |
NEXTAUTH_SECRET | session cookie签名密钥 | 是 |
GITHUB_ID/GITHUB_SECRET | GitHub OAuth应用凭证 | 是 |
5.2 构建与性能数据
部署完成后,我做了两轮实测。
第一轮是next build的产物分析。因为详情页的语法高亮是走服务端Shiki的,客户端JavaScript被控制在很小的范围,主要包来自React和tRPC客户端。整个首页的JavaScript资源压缩后大约90KB,对于这种交互型个人工具来说已经很轻了。
第二轮是用Lighthouse跑性能,结果分数在手机上依然很稳:Performance 98、Accessibility 100、Best Practices 100、SEO 100。T3 Stack默认带的Tailwind CSS在样式体积控制上立功不小,全站没有引入任何UI组件库,写了一部分自定义组件,整体样式文件不到20KB。
接口延迟方面,在冷启动状态下,tRPC列表接口大约耗时120ms,其中大部分花在Serverless函数的冷启动上。热启动后接口响应基本稳定在30ms以内。对个人工具来说完全够用,我甚至没做任何缓存优化。如果哪天觉得慢了,优先考虑在react-query里加staleTime,让列表数据一分钟内不重复请求。
5.3 部署中踩过的坑
最后集中说说部署时踩过的坑,希望能帮你绕开。
第一个坑是NEXTAUTH_URL忘记改,上面提过。症状是点击登录后跳转到GitHub,GitHub授权成功后又跳回localhost:3000,自然打不开。解决方式就是把它改成线上域名,并重启Vercel实例让环境变量生效。
第二个坑是Prisma Client版本不一致。本地用的Prisma版本和Vercel构建时安装的版本不一致,会导致线上运行时出现PrismaClientInitializationError,报错信息指向"generated client"版本冲突。解决方法是锁死版本,在package.json里用精确版本号,并让Vercel构建命令里包含prisma generate。用pnpm的话还要注意,如果锁文件里有多个Prisma版本,很容易出现这个情况,建议删除node_modules和锁文件后重新pnpm install。
第三个坑是Neon的连接池问题。Serverless环境下,如果每个请求都新建数据库连接,很快会打满Neon的连接数上限。Prisma官方推荐在Serverless环境启用连接池模式,我最终在DATABASE_URL里开启了PgBouncer兼容的连接串,同时在Prisma schema里设置了connection_limit = 10。调整之后,连续刷新几十次页面也没有再报连接数超限。
这些坑单看都不复杂,但每个都能让上线时间往后推半天。提前知道,至少能少折腾一会儿。
t3code这个项目给我的最大收获,不是做出了一个多好用的工具,而是让我重新理解了一遍T3 Stack每一块组件在真实项目里是怎么咬合的。如果你也想做一个类似的自用工具,我强烈建议第一版别贪多,先把"记录-检索-展示-权限"这条主链路跑通。等它真正用起来了,再去想AI自动补标签、浏览器插件快捷保存、标签管理这些锦上添花的功能也不迟。工具是给自己用的,好用比好看重要得多。