t3code 这个项目,是我花了三个周末从零到一捣鼓出来的全栈应用。它本质上是一个面向程序员个人使用的代码片段管理工具,把你散落在微信收藏、本地 Markdown、GitHub Gist 和浏览器书签里的零碎代码,统一收进一个自带标签、全文检索和个人账号体系的地方。之所以起名 t3code,主要是因为在技术选型上走了 T3 栈——Next.js、TypeScript、Tailwind CSS 和 tRPC,顺手把"三层结构(收集、整理、检索)"也塞进了这个名字里。这篇文章不打算写成一册教程,我更想以一个做完整项目的过来人身份,聊聊这个项目从命名到落地过程中那些真正决定成败的细节。
如果你正准备用现代全栈技术栈做一个自己的练手项目,这个项目的情况应该对你有参考价值。下面这些内容既有代码片段,也有我踩过之后才想明白的弯路,希望能帮你缩短从"会写 demo"到"能交付一个完整产品"之间的距离。
1. 项目定位:t3code 到底在解决什么问题
1.1 为什么我非要做这样一个项目
很多人的代码片段管理方式其实是失控的。我自己之前就处在一种典型的混乱状态:看到一段好用的工具函数顺手丢进微信文件传输助手,过两周要找的时候只能靠翻聊天记录;GitHub Gist 里存了一堆,但标签体系约等于没有;本地仓库的 docs 目录下还有几份零散的手抄笔记。这种碎片化的存放方式带来的最大问题不是"找不到",而是"不敢删"——因为不确定某段代码还有没有用,于是越攒越多,最终变成无人维护的数字垃圾。
t3code 想解决的正是这个问题。我把它的核心价值收敛成三件事:收集(能快速把一段代码存进来,支持多语言)、整理(用标签和收藏集建立自己的分类体系)、检索(按语言、标签、全文关键词三种维度快速定位)。这三点听起来简单,但真正在产品层面把它们做顺,牵扯到的技术细节比想象中多得多。比如录入时的表单校验、标签去重、代码高亮渲染、检索命中位置的上下文展示,每一环都需要专门设计。
1.2 技术选型:为什么是 T3 三件套而不是传统的 REST 方案
如果放在五年前,做一个这种规模的全栈应用,标准答案是 React SPA 加一个 Node/Java 后端,前后端各写一套类型定义,联调的时候靠 Postman 手动维护文档。现在再这么做,效率上基本属于自虐。T3 栈的核心主张是"端到端类型安全":你在前端调用一个后端的查询函数,参数和返回值的类型是直接由后端代码推导出来的,中间不经过手写接口文档这个环节。后端某个字段一旦改名,前端编译期立刻报错,而不是上线后才被用户发现。
这个优势对 t3code 这种"一个人写、功能简单、但迭代极快"的小项目几乎是致命的吸引力。我不需要一个独立的 API 网关层,不需要为每个接口写 OpenAPI 定义,tRPC 直接让前端像调用本地函数一样调用后端逻辑。同时 Next.js 的 App Router 天然支持服务端渲染,检索页的首屏内容不必等客户端 JS 跑完才出现,体验提升非常明显。至于 Tailwind,纯粹是效率考量——让我不离开 HTML 就能完成样式调整,写代码片段编辑页那种表单密集界面时格外顺手。
当然,T3 栈不是没有代价。最明显的限制是它假设整个项目都用 TypeScript,一旦团队里有成员不熟悉类型系统,协作成本会直线上升。但对于 t3code 这种单人维护的项目,这个假设不构成问题,反而成了生产力放大器。
2. 核心架构与数据模型设计
2.1 领域模型怎么抽象
动手写代码前,我花了一个下午把数据模型来回推演。代码片段管理这个领域其实不复杂,核心实体就四个:用户、片段、标签、收藏集。但每个实体之间的关系怎么建,直接决定后续功能好不好加。
我的最终设计是这样:
- User 用户表:只存账号信息和创建时间,不掺业务字段,避免后续加功能时用户表变成垃圾场。
- Snippet 片段表:核心业务表,存代码内容、语言、标题、描述,以及一段由程序自动生成的"纯文本内容"字段(专门用于检索)。
- Tag 标签表:与 Snippet 多对多关联,一个片段可以打多个标签,同一个标签也能挂在多个片段下面。
- Collection 收藏集:相当于自定义分组,用户可以把不同标签的片段归到同一个学习主题下,比如"面试准备"或"后端基建"。
这里有一个容易踩坑的地方:很多人会把"语言"直接做成字符串字段,然后让前端下拉框硬编码选项。我建议用一个独立枚举加映射表来处理,因为语言的展示名、Monaco Editor 的 language id、代码高亮库的 class 名三者并不总是一致。比如 JavaScript 在 Monaco 里叫 javascript,在部分高亮库里的别名又可能带 jsx 变体;Go 语言的展示名、编辑器 id 和高亮名倒是统一,但这种事情不能靠记忆赌。把映射表放到后端集中管理,前端永远只传一个枚举值,可以省掉一整类 if-else 嵌套。
2.2 Prisma Schema 逐个字段说清楚
数据模型定完后就落成 Prisma Schema。我用的是 PostgreSQL 数据库,本地开发时通过 Docker 起一个实例,线上部署则用托管数据库。核心表长这样:
model User { id String @id @default(cuid()) email String @unique name String? snippets Snippet[] createdAt DateTime @default(now()) } model Snippet { id String @id @default(cuid()) title String language String code String @db.Text plainText String @db.Text description String? userId String user User @relation(fields: [userId], references: [id]) tags Tag[] collections Collection[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String userId String user User @relation(fields: [userId], references: [id]) snippets Snippet[] @@unique([userId, name]) } model Collection { id String @id @default(cuid()) name String userId String user User @relation(fields: [userId], references: [id]) snippets Snippet[] @@unique([userId, name]) }几个字段必须单独说明。plainText 是我额外冗余出来的一列:把代码里的注释、字符串等可读文本抽出来单独存一份,检索时只查这个字段,避免在 PostgreSQL 里对 code 大字段做模糊匹配导致索引失效。这个设计在数据量几百条的阶段看不出差别,但一旦超过一万条,普通 LIKE 查询会明显变慢,提前冗余是性价比很高的取舍。
另一个细节是 Tag、Collection 的 userId 加联合唯一约束。如果不加这个约束,不同用户会看到彼此创建的标签,而且用户 A 创建的"面试题"标签和用户 B 创建的同名标签在去重逻辑上会互相干扰。加上 @@unique 之后,系统层面就保证了"每个用户的标签名唯一",业务代码里只需要捕获数据库返回的 P2002 错误码再提示用户即可。
2.3 认证方案:我为什么放弃了自研登录
认证这块我做过两个版本的方案。第一版是自研:邮箱加密码,密码用 bcrypt 哈希,session 存数据库。这个方案在本地跑通很容易,但很快就发现三个痛点:一是密码找回、邮箱验证这类"合法但繁重"的功能全部要自己写;二是 Next.js Server Component 里读取 session 的逻辑要自己处理,代码很快变脏;三是用户信任度低,很多人看到陌生网站的注册表单就直接关掉了。
第二版我换成了 NextAuth 的 Credentials 与 GitHub OAuth 双方案。GitHub 登录对程序员用户几乎是零摩擦力,邮箱密码作为兜底。NextAuth 的 App Router 集成虽然初期配置有点绕(要理解 JWT 回调、session 回调这些概念),但换来的是稳定的 session 管理和现成的安全策略,整体工期反而缩短了。这里我的建议是:小项目认证别自研,除非你的需求里明确包含"账号封禁、多设备管理、细粒度权限"这类企业级能力,否则 NextAuth 加上数据库 session 足矣。
3. 实操过程:从脚手架到可以跑通
3.1 项目初始化与环境准备
创建项目我直接用官方脚手架,没有手动拼配置:
pnpm create t3-app@latest t3code交互式问答里需要勾选:Next.js、TypeScript、Tailwind、tRPC、Prisma、NextAuth。这一步几乎没有技术含量,但有一个值得说的细节:一定要选 pnpm 而不是 npm。T3 栈的依赖关系很密集,pnpm 的硬链接机制能显著减少磁盘占用和安装时间,而且后续如果要把项目拆成 monorepo,pnpm workspace 是唯一让我觉得不痛苦的选择。
脚手架完成后,第一件事不是写业务代码,而是把环境变量和数据库先跑起来:
docker run --name t3-postgres -e POSTGRES_PASSWORD=devpass -p 5432:5432 -d postgres:16 cp .env.example .env # 修改 DATABASE_URL 为 postgresql://postgres:devpass@localhost:5432/t3code pnpm prisma migrate dev --name init接着在 NextAuth 配置里填入 GitHub OAuth App 的 clientId 和 clientSecret,本地开发时把回调地址配置成 http://localhost:3000/api/auth/callback/github。到这里基础环境就绪,可以启动pnpm dev开始开发。
3.2 第一个 tRPC Router:从数据库到前端零断链
t3code 的第一个核心接口是"创建片段"。在 tRPC 里,这个接口长这样:
// src/server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), language: z.enum(["javascript", "typescript", "python", "go", "rust", "sql", "markdown"]), code: z.string().min(1), description: z.string().max(500).optional(), tagNames: z.array(z.string().max(20)).default([]), }) ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; const plainText = stripCodeToPlainText(input.code); return ctx.db.snippet.create({ data: { ...input, plainText, userId, tags: { connectOrCreate: input.tagNames.map((name) => ({ where: { userId_name: { userId, name } }, create: { name, userId }, })), }, }, include: { tags: true }, }); }), });这段代码有几处值得展开。首先是 zod 的 input 校验——它在前端和后端同时生效,前端调用时如果有参数错误,tRPC 会在调用层直接给出类型层面的提示,而不是等到运行时才发现。其次是 connectOrCreate,它一次性处理了标签的"存在则关联、不存在则创建"逻辑,省掉了我在第一版里写的一堆 findFirst 判断。
前端调用这个接口时,代码简洁到不像话:
const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { router.refresh(); resetForm(); }, });api对象是 tRPC 自动生成的类型安全客户端,调用 create 方法时,IDE 会直接提示需要的参数类型,返回值的类型也由后端推导。这种开发体验与传统 REST 相比,省掉的不只是几行代码,而是整个上下文切换的认知成本——不用再打开接口文档查字段名,不用再对着 Postman 的响应猜类型,编译器就是最好的文档。
3.3 Server Component 与客户端组件的边界
t3code 的列表页我优先用 Server Component 渲染,直接在后端获取 tRPC 数据再传给子组件。比如首页的"最近片段"模块:
// src/app/(dashboard)/page.tsx import { api } from "~/trpc/server"; export default async function DashboardPage() { const recent = await api.snippet.recent({ take: 10 }); return <SnippetList snippets={recent} />; }这里有个容易犯迷糊的点:tRPC 的 server 客户端和前端 hooks 是两套东西,前者适合在 Server Component 里直接 await,后者适合在客户端组件里通过 useMutation、useQuery 使用。我在第一版里混用了这两套 API,结果出现"客户端组件里调用了 server 端函数"的诡异报错,排查了很久才发现是导入路径错位。提前在项目里约定一条规则可以避免这类问题:凡是需要用户交互后触发的数据变更,一律走客户端 hooks;凡是首屏渲染需要的数据,一律在 Server Component 里获取并传入。
3.4 检索功能:全文检索的轻量实现
检索是 t3code 的核心功能之一。我在第一版用了 PostgreSQL 自带的能力:对 plainText 字段建立 trigram 索引,然后通过 ILIKE 查询实现模糊匹配,没有引入 Elasticsearch 这类重型组件。
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_snippet_plain_text ON "Snippet" USING gin (plainText gin_trgm_ops);Prisma 不支持直接表达这种索引,所以我用自定义 migration 来加。查询端则通过 raw SQL 执行:
const results = await ctx.db.$queryRaw` SELECT * FROM "Snippet" WHERE "userId" = ${userId} AND "plainText" ILIKE ${"%" + keyword + "%"} ORDER BY "updatedAt" DESC LIMIT 20; `;实测下来,在几万条数据范围内 ILIKE 配合 trigram 索引的响应时间在几十毫秒量级,完全够用。等到数据规模真的到了百万级,再考虑提取关键词建倒排索引,这属于典型的"不过早优化"——先把核心路径跑通,等业务验证了需求再投入重量级方案。
4. 常见问题与排查技巧实录
4.1 Prisma 与 Next.js 开发模式的缓存冲突
开发过程中遇到最诡异的一个问题是:修改 Prisma Schema 并跑完prisma migrate dev之后,Next.js 开发服务器偶尔会报"Table does not exist"错误,重启后恢复正常。原因是 Next.js 的 Fast Refresh 缓存了旧的 Prisma Client 实例,而 Prisma 重新生成后的客户端结构已经变化,两者不同步。
我的排查思路是先做最小化验证:先跑pnpm prisma generate,再删掉.next目录后重启 dev server,问题消失,说明就是缓存问题。后面干脆在package.json里加了一个dev:reset脚本:
{ "scripts": { "dev:reset": "rm -rf .next && prisma generate && next dev" } }此后每次改完 schema 或遇到莫名其妙的数据库报错,先跑这个脚本,十分钟内解决。这个经历也让我养成了一个习惯:任何"重启就好"的诡异问题,背后往往是某个环节的缓存没有清干净,与其反复重启浪费时间,不如把清缓存固化到脚本里。
4.2 类型收窄:tRPC 返回类型的隐性坑
tRPC 最大的卖点是类型安全,但类型安全不等于类型正确。我在写标签自动补全功能时遇到一个问题:api.tag.all.useQuery()返回的数组元素在 IDE 提示里是Tag[],但当我试图访问tag.snippets.length时 TypeScript 报错说该属性不存在。
原因是 Prisma 的默认查询不会自动 include 关联表,而 tRPC 返回类型又严格基于 Prisma 的实际返回结构推导。解决方案是在 router 里显式声明 include:
all: protectedProcedure.query(({ ctx }) => { return ctx.db.tag.findMany({ where: { userId: ctx.session.user.id }, include: { snippets: true }, }); });这个坑给到我的教训是:在使用 tRPC 时,不要假设后端返回的字段一定包含关联数据,要以 Prisma 查询语句里的 include 为唯一事实来源。类型系统只是把你的声明抄了一遍,它不会替你做业务上的关联判断。
4.3 部署到 Vercel 时数据库连不上的排查记录
t3code 的部署我选择 Vercel,服务端无状态,数据库用托管 PostgreSQL。第一次部署后发现生产环境所有数据接口全部超时,而本地一切正常。逐步排查后定位到两个问题。
第一个是连接池耗尽。Vercel 的 Serverless 环境每个请求可能启动一个新的 Lambda 实例,如果每个实例都创建一个新的数据库连接,几百个并发请求就能把 PostgreSQL 连接数打满。解决方法是启用 Prisma 的连接池配置,并使用 PgBouncer 兼容的连接字符串。托管数据库服务商通常直接提供这组参数,只需要在环境变量里覆盖 DATABASE_URL 即可。
第二个问题是忽略了 SSL。部分托管数据库要求连接必须使用 SSL,本地开发时数据库默认关闭 SSL 可以正常连接,但生产环境强制开启,导致 Prisma 连接被拒绝。在连接字符串后追加?sslmode=require就能解决。这类环境差异问题很隐蔽,建议部署前先看一遍数据库服务商提供的连接参数文档,而不是直接复用本地的DATABASE_URL。
4.4 客户端状态管理里最意外的问题:滥用 useQuery
客户端 hooks 用起来太顺手,导致我一度在编辑页里也直接用useQuery加载片段内容。结果发现每次修改表单里的某个字段,整个组件因为 query key 的变化而重新拉取数据,已填内容被服务端数据覆盖。后来改成组件挂载时只拉一次数据,本地维护一份表单状态,问题消失。
这个问题的根源是过度依赖服务端状态。对于表单编辑这类场景,服务端数据只应该作为初始值,之后的状态应该完全由本地 useState 管理,直到用户主动提交。想清楚"哪些数据属于服务端、哪些属于本地瞬时状态",写 React 应用的体验会提升一大截。这也是我在这个项目里收获最大的认知转变:框架给的 hooks 只是工具,怎么用取决于你对状态边界的理解。
5. 给同样想做练手项目的人一些实在话
整个 t3code 从立项到上线,我的实际工时大约是三个周末加若干工作日晚间,整体节奏比预想中顺畅。如果让我重来一次,我会在数据模型设计阶段多花半小时把所有查询场景列出来,而不是边写代码边补索引;我会在第一版就引入数据库迁移的自动化流程,而不是人工执行 migrate 命令导致一次线上字段漏改。
最后再分享一个小技巧:开发这种个人项目时,把"未来可能的功能"砍掉一大半,只留下核心闭环。我最初规划过代码执行沙箱、分享链接、浏览器插件,最后上线时一个都没做。功能的完整度永远没有核心路径的流畅度重要,先把"存进来、找得到"这两个动作做到极致,比堆十个半成品功能更有意义。
如果你也在折腾类似的个人全栈项目,建议把 t3code 的命名思路借鉴一下:先想清楚技术栈,再想清楚业务的三层结构,最后让名字同时承载这两层含义。这种命名方式会让项目在维护了半年之后,依然能让你一眼想起当初的设计初衷,而不是看着一个随手起的文件名发呆。项目本身还在迭代,接下来我计划把代码执行沙箱加回去,但这篇就先写到这里,等实现完再回来补充新坑。