t3code 这个名字第一次出现在我本地仓库里的时候,它其实只占了一行文件夹名。当时我正被自己散落在电脑各处的代码片段折磨得不轻:工作里随手写的一段正则、帮朋友调的一个 SQL、社区里复制进来的 Tailwind 小技巧,全都躺在微信收藏、本地 txt 和聊天记录里。找的时候翻半天,用的时候又总觉得“我记得写过但就是找不到”。后来我决定用 T3 Stack 把它做成一款叫 t3code 的在线代码片段管理工具,顺手解决“存、找、分享”三个痛点。这篇文章就把我做 t3code 的全过程、技术选型、踩坑和复盘都摊开讲讲,想自己搭类似工具或者第一次接触 T3 技术栈的朋友,可以直接照抄一部分思路。
1. 项目源起:为什么会有 t3code
1.1 程序员碎片知识的整理困境
你没法否认,写代码这件事的很大一部分成本不在“写”,而在“找”。找旧项目里的公式、找曾经跑通的最小 demo、找某篇博客里的一段用法,时间就这么被一点点吃掉。我试过传 GitHub Gist,但它只适合“公开分享”,不适合私人片段管理;也试过用 Notion 建表,但每次要新建一条记录时那个输入成本太折磨人,还没等填完标签我早就切回编辑器了。
t3code 的雏形需求其实很简单:打开网页能快速贴一段代码,自动识别语言,可加标签、可搜索、可复制,公开的片段能生成一个链接交给别人。它不做重任务管理,不做复杂工作流,核心就是一个快速收集 + 精准检索的容器。想清楚这个定位之后,整个项目才有能力克制:不堆功能,只把片段这件事做到爽。
1.2 t3code 想解决的核心问题
定位为“给技术人用的片段收纳盒”之后,我把需求拆成了四个核心问题:
- 快速收入:从浏览器打开到粘贴保存,两个动作内完成,不能让人等。
- 可靠检索:可以按语言、标签、关键词模糊搜索,还要能按时间排序。
- 顺手分享:公共片段生成只读链接,不能接受大段全文都暴露给搜索引擎。
- 多端可用:至少桌面浏览器体验要舒服,手机端可以只读,不勉强做重交互。
这个思路直接影响了我后面的设计:首页就是一个大的编辑器入口,左侧是片段列表,右侧是详情,整体走单页应用的交互路线,减少页面跳转。这也是为什么我选了 T3 Stack 而非传统 JSP 式多页,后面会具体展开。
1.3 为什么是 T3 Stack 而不是“自己拼一套”
T3 Stack 指的是 Next.js + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth 的组合,核心特点是全栈类型安全。与其把它当成一套官方框架,不如说它是一个被整合过的“现代全栈最佳实践集合”。
我选它有四个理由:第一,tRPC 能在不写 REST 路由、不定义额外 DTO 的情况下,让前端直接拿到带类型的服务端函数;第二,Prisma 的 schema 对数据模型调整非常顺手,适合快速迭代;第三,Next.js 的 API 路由和页面渲染耦合同一套生态,部署到 Vercel 几乎零成本;第四,这套组合在国内外的社区资料足够多,就算踩坑也不会孤军奋战。
当然,“全家桶”不是没有代价,比如 tRPC 在大型团队里的约束,比如 NextAuth 在配置第三方 OAuth 时的细碎问题。但 t3code 是一个小体量、强类型的工具型项目,用 T3 Stack 是“把力气花在刀刃上”,而不是盲目跟风。
2. 技术方案选型和初始化流程
2.1 create-t3-app 初始化与配置项解读
初始化用的是官方脚手架,没有任何讨巧的地方:
npx create-t3-app@latest t3code这个命令会交互式问你几个选项:是否需要 NextAuth、是否需要 Prisma、是否使用 tRPC、是否使用 Tailwind、要不要 ESLint。我个人建议首次搭建时把 NextAuth、Prisma、tRPC、Tailwind 全部勾上,因为后面加回来比删掉的成本要高很多。特别不建议一开始为了“轻量”把 Prisma 去掉,不然数据层用其他 ORM 反而破坏了类型安全闭环。
TypeScript 严格模式默认开启,这个建议保留。t3code 在开发过程中有很多类型报错看起来很烦,但正是这些报错提前拦住了运行时才能发现的数据结构问题。目录初始化完成后会生成一个以src/server和src/trpc为核心的服务端结构,这个布局是 T3 Stack 的默认约定,刚上手的人不要随便挪位置。
2.2 核心依赖的具体角色分配
在 t3code 里,每个依赖的角色是清清楚楚的:
- Next.js 13+:负责页面渲染、API 路由、静态优化。t3code 里的公开分享页用静态生成,用户后台走服务端渲染。
- TypeScript:全项目唯一可信的数据契约来源,前端 import 类型直接来自服务端 router。
- Prisma:负责 PostgreSQL 的表结构映射和迁移。我这里用的是 PostgreSQL,没有用 SQLite,因为搜索和并发都更稳。
- tRPC:让客户端像调用本地函数一样请求后端逻辑,省掉几十个手写 API 路径。
- NextAuth:登录态和 Session 管理,配合 GitHub OAuth 实现第三方登录。
- Tailwind CSS:所有界面样式,从零开始写了一套浅色/深色配色,没有额外引组件库。
这里有个容易忽略的点:不要为了“展示能力”而强行把每个依赖都用满。比如 t3code 并没有用 Next.js 的中间件做复杂鉴权,也没有把 tRPC 的 subscription 用上,因为项目根本不需要实时推送。合适才用,用了就要能解释为什么选它。
2.3 目录结构和模块划分方式
初始化结束后,我把源码整理成了这样:
src/ app/ # Next.js App Router 页面 (marketing)/ # 首页、关于页等公开页面 (dashboard)/ # 登录后的片段管理页 share/[id]/ # 公开分享只读页 components/ # 通用 UI 组件 server/ db/ # Prisma client 单例 auth/ # NextAuth 配置 api/ # tRPC router 聚合 api/routers/ # 按业务拆分的子路由 trpc/ server.ts # tRPC 服务端初始化 client.ts # tRPC 客户端 helpers为什么要在这个阶段就强调“按业务拆分 router”?因为 t3code 后面一定会加“收藏夹”“评论”之类的功能,如果不提前拆,所有查询逻辑堆在一个 router 里,几百行之后谁都懒得重构。拆了之后每个 router 只对一张表或一个业务域负责,阅读和维护的成本都低很多。
3. 数据模型与存储设计
3.1 核心数据表:User、Snippet、Tag
t3code 的数据模型其实不复杂,但有几个关键设计点值得展开。先看 Prisma schema 的核心部分:
model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] pinnedTags String[] createdAt DateTime @default(now()) } model Tag { id String @id @default(cuid()) name String creatorId String snippets SnippetTag[] createdAt DateTime @default(now()) @@unique([creatorId, name]) } model Snippet { id String @id @default(cuid()) title String description String? content String @db.Text language String isPublic Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tags SnippetTag[] viewCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId, isPublic]) @@index([language]) }这里最关键的是SnippetTag,它是 snippet 和 tag 的多对多关联表。当时我也纠结过要不要直接用“逗号分隔字符串”存标签,后来发现只要涉及“按标签过滤”就一定需要关系查,字符串方案很快就会写出LIKE '%tag%'这种性能杀手,所以多对多关系表是必须的。
3.2 联合查询和索引设计
Prisma 的关联查询很方便,我比较常用的是include和where联动:
const snippets = await db.snippet.findMany({ where: { authorId: session.user.id, isPublic: true, language: { in: selectedLanguages }, }, include: { tags: { include: { tag: true } }, }, orderBy: { updatedAt: desc }, });但索引设计是不能偷懒的。t3code 上线一周后我发现一个现象:按语言过滤时数据库查询变慢了,尤其是在片段数量超过两万条之后。后来看 Prisma 的日志才发现没有命中language索引。Prisma schema 里的@@index([authorId, isPublic])和@@index([language])是后面补上的,补完之后查询时间从 200ms 级降到了 20ms 级。
经验是:不要在数据量小的时候忽略索引,因为个人项目的测试数据通常只有几十条,等到用户数据涨到成百上千条时,慢查询才会真实暴露。建议在建表初期就把where条件里高频出现的字段都加上索引。
3.3 软删除要不要做
t3code 最开始没做软删除,就是直接delete物理删除片段。后来发现一个比较尴尬的场景:用户误删了一段很长的脚本,想找回却没有办法。所以我给 Snippet 加了deletedAt DateTime?字段,并做了一个定时清理任务,让删除操作实际只是打标记,列表查询里默认过滤deletedAt: null,真正确认清理 30 天前的数据再手动跑数据库脚本。
要是从一开始就设计一个deleted状态位,后面会省不少事。做得不够好的地方是迁移时已经积累了用户数据,所以需要额外写一个 backfill 任务把所有老数据的deletedAt置空。如果你是刚开始做类似的工具,建议第一天就留着这个字段,空着也不会有什么副作用。
4. API 层:tRPC Router 的拆分与落地
4.1 为什么选择 tRPC 而不是 GraphQL
在这类工具型项目里,GraphQL 带来的“灵活请求”价值其实不大,因为前端就那几个页面,字段组合固定。如果用 GraphQL,我不仅要定义 schema、写 resolver,还要处理客户端缓存,开发成本一下就上去了。
tRPC 的思路更直接:服务端定义函数,客户端直接调用,类型在编译期就自动贯通。比如保存一个 snippet,tRPC 能确保前端传入的字段类型和后端 Prisma 返回值在编译期就是一致的,不用等运行时才知道哪里错了。另一个好处是调试体验:浏览器 Network 面板里能看到 tRPC 的请求是普通 HTTP POST,数据结构和返回值一目了然,出错时服务端日志里也带着完整调用参数。
4.2 snippet router 的代码实现与参数校验
拆一个 snippet router 做示例。这是 t3code 里最核心的一段逻辑:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; const snippetInput = z.object({ title: z.string().min(1).max(80), description: z.string().max(200).optional(), content: z.string().min(1), language: z.string().min(1), tags: z.array(z.string()).max(10).optional(), isPublic: z.boolean().default(false), }); export const snippetRouter = createTRPCRouter({ all: protectedProcedure .input(z.object({ cursor: z.string().nullish(), language: z.string().nullish() })) .query(async ({ ctx, input }) => { return findSnippetsByCursor(ctx.db, ctx.session.user.id, input); }), get: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findFirst({ where: { id: input.id, isPublic: true }, include: { tags: true }, }); if (!snippet) throw new TRPCError({ code: "NOT_FOUND" }); return snippet; }), create: protectedProcedure .input(snippetInput) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, authorId: ctx.session.user.id, tags: input.tags ? { create: input.tags.map(tag => ({ tag: { connectOrCreate: { where: { creatorId_name: { creatorId: ctx.session.user.id, name: tag } }, create: { name: tag, creatorId: ctx.session.user.id } } } })) } : undefined, }, }); }), });这里有三件事必须强调。第一,zod的校验是 t3code 的“第一道门”,尤其content不能为空,因为空片段没有任何意义。第二,connectOrCreate是标签去重的关键,如果你直接create标签,用户第二次输入相同标签就会触发唯一约束报错。第三,公开查询get是publicProcedure,也就是说不需要登录也能看,这一步是刻意的,因为它对应“分享给朋友”的使用场景。安全问题在后面权限部分再展开。
4.3 服务端错误处理与统一提示
tRPC 的特点是客户端能拿到服务端的完整错误栈,但这也会带来一个问题:不能把底层错误直接抛给用户。t3code 的做法是在 router 顶层加一个 error middleware,把 Prisma 唯一约束错误、Zod 校验错误、未知错误分别映射成可读的中文提示。
export const errorFormatter = ({ shape, error }) => { if (error.code === "BAD_REQUEST" && Array.isArray(error.zodError?.issues)) { return { message: "参数不合法:请检查必填项和格式", ...shape }; } return shape; };实际跑下来,这个统一处理让我在前端几乎不用写一大堆重复的if (error.status === ...),所有组件只需要判断isError和展示error.message。对于有强迫症的人来说,这算是最好的“整洁”了。
5. 前端交互与体验优化
5.1 页面路由和布局设计
t3code 用 App Router 分成两组路由:一组是不需要登录的 landing 和分享页,另一组是登录后的 dashboard。dashboard 的整体布局是“左列表右编辑区”,跟 Notion 的侧栏模式类似。移动端则自动折叠成列表页,点进去才看到详情,这个策略很简单,但没有为了“响应式全功能”而牺牲桌面体验。
这里有个体验细节值得赞一下自己:登录后的首页会直接聚焦到代码编辑器,不需要任何额外点击。一个小技巧是用useEffect调inputRef.current?.focus(),同时也设置了autoFocus,双保险。别小看这个动作,它决定了用户每次打开 t3code 时的第一感觉是“快”还是“还得点一下”。
5.2 React Query 的缓存状态管理
T3 Stack 默认集成 React Query,它解决了 t3code 里最麻烦的“列表和详情同步失效”问题。保存一个 snippet 之后需要让列表刷新,用 React Query 的方式是:
const utils = trpc.useContext(); utils.snippet.all.invalidate();invalidate会让相关 query 自动重新拉取,不用手动去更新 store,不用写复杂的全局状态。t3code 也用过 Zustand 做 UI 状态(比如侧边栏开关),但业务数据全部交给 React Query 管理,这两者分工很清晰:UI 状态放客户端 store,服务端数据放 server-state。
如果你发现自己的页面经常出现“新增了数据但列表没显示”,大概率是缓存失效没做好。t3code 的规律是:所有 mutation 成功之后立刻invalidate()对应列表 query,这比重新refetch更高效,因为 React Query 会做细粒度的查询去重和请求合并。
5.3 代码高亮与平面编辑体验
代码高亮是这种工具的核心体验,t3code 用的是 Prism,因为它在客户端渲染足够快,不需要后端渲染。展示端和编辑端都统一成PrismLight加各类语言插件,而不是加载全量 Prism,避免首次打开 bundle 太大。
import Prism from "prismjs"; import "prismjs/components/prism-typescript"; import "prismjs/components/prism-python";高亮之外还有一个容易被忽略的点:复制按钮。片段详情页右上角固定一个 Copy 按钮,点击后写入系统的 Clipboard API,并显示 1.5 秒的“已复制”反馈。这里的坑是旧浏览器不支持navigator.clipboard,所以我还保留了document.execCommand('copy')作为回退方案。虽然这个 API 被标记废弃,但在企业浏览器环境里它反而是最稳的。
6. 认证与权限管理
6.1 NextAuth 接入 GitHub OAuth 的步骤和坑
GitHub OAuth 配置是我在一开始最容易卡住的环节。流程本身不难:先在 GitHub OAuth Apps 里创建一个应用,拿到 Client ID 和 Client Secret,再在 NextAuth 里配置 providers.GitHub。真正的坑在于NEXTAUTH_SECRET和NEXTAUTH_URL这两个环境变量。本地开发时NEXTAUTH_URL必须指向http://localhost:3000,否则登录回调会跳转到错误地址。
还有一个特别容易忽略的问题:在 GitHub 官网配置 callback URL 时,如果你用了 Vercel 域名,就要填https://你的域名/api/auth/callback/github,不能漏掉/api/auth/callback/github。我第一次配的时候就漏了后半截,导致点击登录后一直停在白色页面。
6.2 使用 Session 和路由保护常见做法
T3 Stack 初始化时默认把session注入到 context,所以 router 里可以直接拿到用户信息。保护数据需要用protectedProcedure来替代普通publicProcedure,它内部检查ctx.session,没有登录就直接抛出UNAUTHORIZED。
前端页面保护用的是 Next.js App Router 里的一小段逻辑,放在根布局的组件里:
const { data: session, status } = trpc.auth.getSession.useQuery(); if (status === "loading") return <Loading />; if (!session) return redirect("/api/auth/signin");这里有个细节:status === "loading"时不要直接渲染空白页,我放了一个简单的 skeleton,因为 NextAuth 的 session 是异步获取的,直接redirect会导致闪烁甚至中途报错。这个体验细节如果你是自己做项目可能不会注意,但用户很敏感。
6.3 作者权限校验逻辑
公开分享页所有人都能看,但“修改/删除/设为公开”这些操作必须只允许作者本人。t3code 的做法很简单:在updaterouter 里先根据 id 查 snippet,再判断snippet.authorId === session.user.id,不同则抛FORBIDDEN。
const snippet = await ctx.db.snippet.findUnique({ where: { id: input.id } }); if (!snippet) throw new TRPCError({ code: "NOT_FOUND" }); if (snippet.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); }这个判断看起来简单,但既然用了类型安全技术栈,就更应该把这个能力贯彻到权限上。不要在客户端隐藏按钮,因为 API 是可被任意请求访问的,前端隐藏只是 UI 层面的礼貌,后端强制校验才是安全底线。
7. 部署与性能实测
7.1 Vercel 部署和数据库迁移的配合
t3code 的前后端都部署在 Vercel,数据库用 PostgreSQL。部署成功不代表迁移成功,Ci 流程里要规划好prisma migrate deploy的时机。我的经验是:在 Vercel 的 Build Command 里先跑prisma generate,再把prisma migrate deploy放到部署脚本里执行,顺序不能反。prisma generate是为了让 tRPC router 能拿到最新的 Prisma 客户端类型,migrate deploy是为了把新表结构同步到生产库。
不过要提醒一句:尽量别在线上环境中自动执行migrate reset,那个会清空数据。真正安全的流程是在本地写好迁移文件、在 staging 环境测试过,再走到线上。t3code 因为是个人项目,省了 staging 这一步,代价是必须每次手动确认迁移文件内容,不敢乱跑。
7.2 静态页面与动态接口的混合优化
t3code 的 landing 页面和公开分享页是静态生成(SSG),用户 dashboard 走服务端渲染(SSR)。SSG 页面在构建时就渲染成 HTML,CDN 缓存后访问速度极快;SSR 页面保证每次请求都能拿到最新用户数据。
这个组合要特别留意一个点:公开分享页如果被搜索引擎收录,代码内容会完整暴露在公网,这是有意为之。如果你不希望别人把你的代码片段抓走,就需要给公开页加上noindex标签。t3code 分享功能本身就是为了传播,所以我没加,但如果你想做一个私有片段库,这个标签就非常关键。
7.3 实测性能指标和小优化
上线一周后我看了一下核心数据,公开页首屏加载(LCP)大概在 1.2s 左右,用户后台首次请求数据在 300ms 左右。最大的带宽消耗来自 Prism 的 CSS 和 JS,因为代码高亮的体积确实很惊人。后续做了一次优化:按语言动态 import,只用用户访问的语言插件,将 JS 体积降了约 40%。
另一项优化是给列表接口加了 cursor 分页,而不是传统的 page 分页。因为片段列表只要增量加载,用户往下滚动就取下一页,不需要跳到第 5 页。cursor 分页对深分页性能也更好,因为 MySQL/Postgres 的OFFSET越深越慢,而 cursor 走的是“上一页最后一条记录的 id”查询,索引命中率更高。
8. 常见问题与排查实录
8.1 一张表的避坑速查
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 登录后跳回首页但仍是未登录 | NEXTAUTH_URL环境变量没配或配错 | 检查 Vercel 和本地.env,填完整域名地址 |
| 保存 snippet 后标签重复 | 标签创建没走connectOrCreate | 改为先查后建,或直接用connectOrCreate |
| 列表查询变慢 | 缺少联合查询字段的数据库索引 | 在 Prisma schema 里加@@index,执行迁移 |
| 公开分享页打开 404 | 查询用了protectedProcedure | 分享页查询改成publicProcedure |
| 代码块没有高亮 | 只加载了 Prism 核心,没加载语言包 | 手动引入对应语言的 Prism component |
| 页面加载闪烁 | session 异步返回导致条件渲染不一致 | 渲染前先处理 loading 状态 |
| 部署后 API 全部 500 | 线上数据库没有执行迁移 | 部署命令里加prisma migrate deploy |
8.2 印象最深的一个线上问题
t3code 上线之后收到一个反馈:用户说在 Chrome 里复制代码按钮没反应。我本地测试一直正常,后来远程排查后发现是navigator.clipboard需要 secure context,也就是必须在https://环境下才能用。如果用户用http://访问,API 是不存在的,所以就会静默失败。
解决办法是加了 fallback:检测navigator.clipboard && window.isSecureContext,不满足就走document.execCommand。这个教训挺典型:本地开发环境和生产环境的 API 能力不一定一致,不能用“我本地测没问题”来覆盖所有边界情况。
8.3 后续还能扩展的方向
t3code 目前已经满足我的日常使用,但如果继续做下去,我会优先加两个功能:一是“收藏别人公开片段”的操作,这需要再建一张辅助表;二是“代码片段对比”,比如同一个场景的版本迭代,把两段代码并排展示差异。技术上都不难,关键还是看需求是否真实出现。
实际使用中另一个体会是:代码片段的价值不只是“存下来”,更是“能翻到”。t3code 让我把以前可能要记在脑子的东西全部“卸载”到可检索的工具里,这种安心感很难量化。我也会继续在这个项目上小步迭代,同时建议如果你想做一个自己的侧边项目,最好选择一个能真正解决自己日常烦恼的痛点来下手,这样你会更愿意把它打磨下去。