1. 从一个小痛点说起为什么会冒出 t3codet3code 这个名字看着像随手敲出来的其实是 Triple-Stack Code 的缩写——我用 T3 技术栈Next.js TypeScript Tailwind CSS tRPC Prisma做了一个给自己用的代码片段管理工具。起因特别朴素每天在项目之间切来切去总要反复找之前写过的那个工具函数、翻聊天记录里某段配置、从旧仓库里捞一份 SQL 模板。这些碎片散落在不同的地方等我终于找到的时候思路早就断了。我不是没试过现成的方案GitHub Gist 适合分享但打开太慢Notion 适合写长文但不适合快速记录碎片代码本地笔记软件的代码高亮和复制体验又总差一口气。折腾一圈下来我想要的其实特别简单——一个能存片段、能按标签找、能一键复制、数据在我自己手里的私有工具。与其继续拼凑不如用我平时最熟的技术栈直接做一个于是 t3code 就这么开工了。这篇内容不是什么通用教程而是一个完整项目的复盘从技术选型、数据模型到具体落地再到踩坑记录。如果你是做全栈的或者正准备用一个真实项目把 TypeScript 的类型安全优势吃透这里面的实践可以直接抄。我先把当初范围控制的思路讲清楚。1.1 我试过的管理方式为什么都不够用先说说项目范围因为自建工具最大的风险不是做不出来是做出来没人用。我给自己定了三条硬约束只解决存、找、复制不做协作不做复杂权限。为什么这么定代码片段管理这个场景看着简单但一旦想加团队分享、评论、版本对比工作量立刻翻好几倍而个人场景下这些功能基本是摆设。在动手之前我认真列过现有方案的问题。GitHub Gist 的体验其实不错但它的强项是外链分享日常高频的增删改反而别扭而且私有 Gist 在管理上不够直观。Lepton、massCode 这类本地工具也有不少人在用可它们的片段数据格式是固定的后续想自己做搜索、做标签联动就受制于人了。我的结论是这个工具的价值不在功能多而在于顺手。所谓顺手就是创建片段时不用纠结存到哪个文件夹搜索时一个快捷键呼出复制时保持原始缩进和语法高亮。所以 MVP 就定了几个核心动作登录、创建片段、打标签、搜索、复制。这些做扎实了再谈别的。1.2 T3 全栈为什么适合这种工具选型阶段我其实纠结过几套方案比如 Next.js 配 REST API 再加 React Query或者 Next.js 配 GraphQL。但最后还是一头扎进了 T3 技术栈原因可以用一个很直白的类比解释T3 让我从数据库到前端 UI 用同一种语言、同一套类型描述数据等于一条流水线上全用同批工人交接误差天然就小。具体来说tRPC 替代 REST 或 GraphQL让我在前后端共享 TypeScript 类型。后端 router 里定义了某个查询返回什么类型前端调用处直接就能拿到推导结果连接口文档都不用维护。Prisma 负责数据库层模型一改TypeScript 类型跟着变migration 也能自动生成。Tailwind 则解决了样式问题写这种工具类应用不需要设计系统utility class 足够快。这个选择对 t3code 这种 CRUD 为主的项目尤其划算业务不复杂但类型链路很长从 Prisma model 到 tRPC 输入输出再到前端表单、列表渲染如果每一层都手动写类型定义改一次字段就要动五六个文件。现在改动模型结构之后编译器会直接告诉我哪里对不上了省下的时间非常可观。2. 核心架构与数据设计2.1 目录结构怎么拆项目初始化之后create-t3-app 给的目录结构已经很清晰了我根据自己的需求做了一点调整。最终结构大致是这样的src/ ├── app/ │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/route.ts # tRPC HTTP 入口 │ ├── (auth)/ │ │ ├── login/page.tsx │ │ └── register/page.tsx │ ├── snippets/ │ │ ├── new/page.tsx │ │ └── [id]/page.tsx │ ├── layout.tsx │ └── page.tsx # 首页片段列表 搜索 ├── server/ │ ├── api/ │ │ ├── root.ts # 根 router │ │ └── routers/ │ │ ├── snippet.ts # 片段相关操作 │ │ └── tag.ts # 标签相关操作 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma Client 单例 ├── trpc/ │ ├── server.ts # 服务端 tRPC 初始化 │ └── client.ts # 客户端 tRPC 初始化 └── styles/这个结构最重要的原则是服务端代码不进入客户端打包。src/server 里的 db、auth、业务逻辑不会泄漏到浏览器端这是 Next.js App Router 下的默认边界。tRPC 在这套结构里充当一个类型安全的桥服务端的 router 通过 app/api/trpc/[trpc]/route.ts 暴露前端的客户端组件通过 src/trpc/client.ts 调用。我还单独拆了一个 tag router因为标签和片段是多对多关系后续要做标签自动补全、统计每个标签下的片段数量独立 router 会比混在一起清晰得多。2.2 Prisma 数据模型怎么设计片段管理工具的核心数据模型其实不复杂但有几个点值得展开。我设计的模型如下model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? snippets Snippet[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Snippet { id String id default(cuid()) title String content String language String default(plaintext) description String? isPublic Boolean default(false) author User relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags SnippetTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId, updatedAt]) } model Tag { id String id default(cuid()) name String userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) snippets SnippetTag[] createdAt DateTime default(now()) unique([userId, name]) } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) id([snippetId, tagId]) }几个设计决策的解释用 SnippetTag 中间表而不是在 Snippet 上直接存 tagId 数组是为了后续能按标签聚合查询。Prisma 不支持数组字段的关系查询中间表是关系型数据库的标准做法。Tag 表加了 userId 并做了联合唯一约束这样每个用户的标签互不干扰同一个用户下不会出现两个同名标签。authorId 上加了外键级联删除用户注销时相关片段一起清理避免遗留孤儿数据。index([authorId, updatedAt]) 这个组合索引很重要。片段列表最常见的排序是按更新时间倒序而且限定当前用户没有这个索引的话数据量上来之后查询会明显变慢。对于 content 字段我一开始用的是普通 String。后来发现代码片段动辄几百行而且包含各种特殊字符没有遇到 Prisma String 长度限制的问题但如果用 SQLite 开发、PostgreSQL 部署要留意不同数据库对 TEXT 类型的处理差异。我最终统一在 PostgreSQL 上跑省了没必要的兼容成本。2.3 tRPC router 设计router 是整个后端的入口。我按资源拆了几个 router以 snippet router 为例核心接口包括创建、更新、删除、列表查询、详情查询。这段代码基本展示了 t3code 的 tRPC 写法import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/trpc/server; import { prisma } from ~/server/db; const snippetInput z.object({ title: z.string().min(1, 标题不能为空).max(100), content: z.string().min(1, 内容不能为空), language: z.string().default(plaintext), description: z.string().max(500).optional(), tagNames: z.array(z.string()).max(10).default([]), }); export const snippetRouter createTRPCRouter({ list: protectedProcedure .input( z.object({ query: z.string().optional(), tagNames: z.array(z.string()).optional(), language: z.string().optional(), page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(20), }), ) .query(async ({ ctx, input }) { const where { authorId: ctx.session.user.id, ...(input.query ? { OR: [ { title: { contains: input.query, mode: insensitive } }, { content: { contains: input.query, mode: insensitive } }, ], } : {}), }; const [items, total] await Promise.all([ prisma.snippet.findMany({ where, include: { tags: { include: { tag: true } } }, orderBy: { updatedAt: desc }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }), prisma.snippet.count({ where }), ]); return { items, total }; }), create: protectedProcedure .input(snippetInput) .mutation(async ({ ctx, input }) { const { tagNames, ...snippetData } input; return prisma.snippet.create({ data: { ...snippetData, authorId: ctx.session.user.id, tags: { create: tagNames.map((name) ({ tag: { connectOrCreate: { where: { userId_name: { userId: ctx.session.user.id, name } }, create: { name, userId: ctx.session.user.id }, }, }, })), }, }, }); }), });这里最值得说的是 connectOrCreate 的用法。创建片段时用户直接填标签名而这些标签可能已经存在也可能不存在。如果先查一遍再创建代码会多出好几个 if 分支。Prisma 的 connectOrCreate 配合联合唯一约束刚好能让这个操作在一句里完成而且并发情况下不会产生重复标签。protectedProcedure 是 create-t3-app 里预置的必须登录流程内部会去 session 里拿用户信息。因为我在 initialize 时把 session 挂到了 ctx 上所以 router 里可以直接用 ctx.session.user.id不需要每个 procedure 都重复写鉴权逻辑。这一点看似基础但很容易被忽略——如果某个 mutation 漏掉 protectedProcedure 而用了 publicProcedure接口就等于裸奔了。2.4 前端取数类型不写第二遍tRPC 带给我最直观的收益在前端。列表页和创建页都需要调用 snippet router我完全没有手动定义接口返回类型直接靠 tRPC 推导use client; import { api } from ~/trpc/react; import type { RouterOutputs } from ~/trpc/shared; type SnippetList RouterOutputs[snippet][list][items]; export function SnippetList({ initialData }: { initialData?: SnippetList }) { const { data, isPending } api.snippet.list.useQuery( { page: 1, pageSize: 20 }, { initialData }, ); if (isPending) return SnippetSkeleton /; return ( ul {data.items.map((item) ( li key{item.id} SnippetCard snippet{item} / /li ))} /ul ); }useQuery 的第一个参数对应后端 list procedure 的输入 schema返回值自动匹配输出类型。如果我改动了 Prisma model 里的字段或者改了返回结构前端这里立刻会红。这种错误在编译期暴露比运行后等接口报错再排查要舒服得多。另外一个值得学习的是 api.useUtils() 的用法。创建或删除片段之后要让列表刷新最效率低的方式是手动 router.push 或者强制刷新页面。tRPC 提供了 invalidationconst utils api.useUtils(); const createMutation api.snippet.create.useMutation({ onSuccess: async () { await utils.snippet.list.invalidate(); }, });invalidate 会把该 query 标记为失效并在后台重新拉取。配合 React Query 的缓存机制列表更新几乎是即时的而且不会有闪烁。这种体验是手写 fetch 手动 setState 很难做到的。3. 从零到跑通t3code 的实操记录3.1 初始化工程这一节我按当时的实际操作记录来写方便你直接复现。npm create t3-applatest t3code命令运行后会进入交互式配置。我逐个说明选项的选择选项我的选择理由Next.js是作为 React 框架兼做 BFF 层TypeScript是T3 的核心必须Tailwind CSS是快速写界面不引入设计系统负担tRPC是前后端类型闭环的关键Prisma是数据库模型与类型绑定NextAuth.js是登录鉴权自带 session 管理ESLint / Prettier是从第一天就规范化这里要说一个容易踩的点下一代的 create-t3-app 默认使用 App Router如果你之前习惯了 Pages RoutertRPC 的初始化文件结构会不同。App Router 下客户端 API 是 createTRPCReact 创建的服务端入口在 app/api/trpc/[trpc]/route.ts。我在实际开发时在官方模板基础上做的小改动主要是把 root.ts 里的业务 router 拆成了独立文件不然随着功能增加单一文件会越来越长。3.2 数据库连接和 Prisma 初始化数据库我用 Docker 起的 PostgreSQL。本地开发用 Docker 的好处是环境隔离重新拉一遍也快。docker-compose 配置大致是services: db: image: postgres:16 restart: unless-stopped ports: - 5432:5432 environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: t3code POSTGRES_DB: t3code volumes: - ./data:/var/lib/postgresql/data.env 文件里核心配置是DATABASE_URLpostgresql://t3code:t3codelocalhost:5432/t3code?schemapublic NEXTAUTH_SECRET自己生成一串随机值别用默认值 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET 我建议用 openssl rand -base64 32 生成而不是随手打一串键盘。因为 NextAuth 用这个密钥给 session cookie 签名密钥强度不够的话是安全隐患。本地开发时 NEXTAUTH_URL 可以留 localhost但生产部署时一定要改成真实域名否则第三方登录回调会一直失败。配置好之后执行npx prisma migrate dev --name init npx prisma generatemigrate dev 会创建数据库表结构并生成 migration 文件generate 则更新 Prisma Client 的类型。实际操作的时候我发现顺序很重要必须先写好 schema 再跑 migrate如果先 generate 就会产生Prisma Client 与 schema 不一致的报错需要用 npx prisma generate --force 才能覆盖。3.3 标签和片段关联的实现细节创建片段的 mutation 里有一个可能报错的点connectOrCreate 的条件字段必须和某个 unique 约束完全匹配。我在 Tag 模型上定义了 unique([userId, name])所以 where 里要写 userId_name 这个联合字段名这是 Prisma 自动生成的复合唯一约束名称。如果没定义联合唯一connectOrCreate 会直接报错因为它无从判断该标签是否存在。另一个细节是删除片段时的标签清理。因为 SnippetTag 的 onDelete: Cascade删除 snippet 会自动清理中间表记录但是 Tag 本身不会自动删除。这就可能导致库里残留一些没有任何片段引用的标签。我目前保留了这些空标签因为用户在创建新片段时还可能重新用到而且标签列表页也能让用户自己删除。如果希望自动清理就需要在删除 snippet 的事务里额外查一遍哪些标签引用数为 0 了再做 deleteMany这个我放在后续优化里。3.4 前端页面接入示例首页列表是我最先写的页面。在 App Router 下服务器端可以先查一次数据传给客户端组件避免首次加载白屏。但要注意tRPC 的 useQuery 初始化数据格式必须和后端返回结构完全一致否则 React Query 会警告结构不匹配。客户端请求代码如下const query api.snippet.list.useQuery( { page: 1, pageSize: 20 }, { enabled: true, retry: 1, }, );创建页的表单我用了 react-hook-form配合 zod 做输入校验。需要注意 react-hook-form 的 resolver 是 zodResolver类型上必须和 mutation 的 input schema 对齐。实际操作时我遇到过字段名不匹配的问题——表单里把 language 写成了 lang后端 zod schema 校验直接 400。这种错误在后端日志里是Invalid input: expected string但排查起来还是会费一会儿。复制功能是代码片段工具的核心体验。我用的是 navigator.clipboard 的异步 API并且在高亮代码块上做了点击复制按钮。最初我图省事用 document.execCommand(copy)但在 Safari 下有兼容性问题而且已被废弃。改到 Clipboard API 之后用户体验流畅很多。3.5 搜索与多标签筛选搜索我一开始只做了 title 和 content 的 contains但中文场景下 contains 用的是子串匹配不是分词匹配。比如搜回调匹配不到等待回调函数这种带空格的表达而搜一个完整词又容易漏。后来我在 PostgreSQL 里启用了 pg_trgm 扩展配合 GIN 索引做相似度查询效果提升明显。启用扩展需要在 Prisma migration 里手动加 SQLCREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX snippet_content_trgm_idx ON Snippet USING GIN (content gin_trgm_ops);注意 Prisma 的 schema 文件不支持声明扩展和原生索引操作所以这种变更要自己创建一个 SQL migration 文件再用 prisma migrate dev 应用。我最初把这条 SQL 直接写在 schema 里结果 migrate 直接报 syntax error后来才反应过来 Prisma schema 不是完整的 DDL。多标签筛选我是在服务端拼 where 条件的先查出用户选中的标签对应的 tagId再找出同时包含所有这些标签的 snippet id 集合。这里有一个需要注意的点SQL 里用 IN 查的是命中任一标签不是命中全部标签想实现同时包含可以用 group by having count(distinct tagId) 标签数量。这个细节在自测时很容易忽略。4. 遇到的那些坑4.1 NextAuth 在服务端组件里取会话的问题App Router 下的服务端组件里最开始的写法是直接调用 getServerSession但我发现 session 拿不到打印出来是 null。原因是 NextAuth 配置没有被正确传进去。create-t3-app 生成的项目里专门封装了一个 getServerAuthSession内部处理了 authOptions 的读取export const getServerAuthSession () getServerSession(authOptions);服务端组件里直接用这个封装函数而不是自己调 next-auth/react 或者裸的 getServerSession。这个坑特别容易踩因为你在网上搜 NextAuth 教程很多还是旧的 API 文档直接照着写就会遇到 session 永远是 null 的情况。另一个问题是中间件的路由保护。我一开始在 middleware.ts 里希望拦截未登录用户访问 /snippets 下的页面并在中间件里调 session 检查。但 NextAuth 的 session 校验依赖数据库和 cookie中间件运行在 Edge 环境不能直接用 Prisma。官方推荐的方案是用 getToken 检查 JWT而不是拿完整 session。我最后没有写中间件拦截而是在每个页面和每个 protectedProcedure 里做校验效果一样而且不容易出边缘运行时的问题。4.2 Prisma 连接和 Fast Refresh开发过程中让我最头疼的是 Prisma Client 实例化。create-t3-app 模板默认在 src/server/db.ts 里做 globalThis 缓存import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [error, warn] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma prisma;如果不用 globalThis 缓存在 Next.js 开发模式下每次热更新都会创建新的 PrismaClient很快会触发 PostgreSQL 的连接数上限报Too many clients错误。这个模板代码不是可有可无的装饰是必须保留的部分。4.3 升级 tRPC 版本时被卡住的经验t3code 开发到中途tRPC v11 发布了。v11 最大的变化是把 trpc/react 和 trpc/client 合并并且对 React Query 的 peer dependency 版本要求变高。我当时脑子一热直接升级结果启动后页面报了一堆类型错误包括 createTRPCReact 签名变化和 api.useUtils 返回值类型不对等。如果项目已经稳定不是特别需要新特性的话建议锁死大版本而不是频繁升级。在 package.json 里用 ^ 符号也要当心哪怕小版本升级都可能带来破坏性变更。tRPC 这类类型敏感的库升级前一定要读 release note而不是只看 Changelog。最后我花了小半天把 tRPC 从 v10 迁到 v11收益只是 API 调用少了一点样板代码代价是费了不少排查时间。这个教训我以后会牢记工具类依赖的升级要放在功能冻结窗口去做而不是开发中途顺手升级。4.4 部署时环境变量和回调地址的坑部署到线上之后遇到两个问题。第一个是 DATABASE_URL线上 PostgreSQL 通常要求 SSL 连接连接串要带上 sslmoderequire。如果漏了Prisma 会报 connection terminated unexpectedly。这个问题在本地很难复现因为本机 Docker 默认不需要 TLS。第二个是 GitHub OAuth 回调。NextAuth 在 env 里读 NEXTAUTH_URL 来判断回调地址生产环境如果没有正确设置GitHub 会一直报 redirect_uri 不匹配。我在 Vercel 上部署时一开始只改了 AUTH_GITHUB_ID 和 AUTH_GITHUB_SECRET忘了设置 NEXTAUTH_URL结果本地登录正常、线上登录必然跳错。排查了很久才发现问题不在 GitHub 配置里而在环境变量少了这个值。这个经验就是Deploy 之前把所有带 URL 的变量检查一遍。4.5 中文搜索不生效的排查前面提过 pg_trgm 的启用这里把排查过程展开。最初我用 contains 查询英文片段的搜索基本能用中文却时好时坏。原因在于 contains 做的是子串匹配中文文本没有空格分词搜防抖能命中防抖函数但搜函数防抖就失败因为目标文本里没有完全相邻的函数防抖。pg_trgm 通过把文本切分为连续 3 字符的 trigram能支持模糊匹配但对中文效果其实也只是比 contains 好一点因为中文字符组合太稀疏。真正要做得好的中文搜索需要分词器比如 PostgreSQL 自带的 zhparser 扩展但安装依赖系统环境部署到 Vercel 时不一定允许。我最后的取舍是保留 pg_trgm 索引把搜索的核心逻辑放在标题匹配上内容搜索放宽到包含即可。这个方案对工具型应用来说已经够用了不要为了追求完美分词把项目复杂度拖上去。5. 沉淀下来的技术经验5.1 架构上的几个决定回头看是否合理回头复盘最重要的架构决定是把类型安全作为第一优先级。这个选择让整个项目的调试成本大幅降低尤其是当数据模型改字段名、改关联关系时编译器会一次性列出所有需要改的地方不用靠回忆和搜索。这个体验在普通 REST API 项目里是没有的。第二个合理的决定是 MVP 范围控制。代码片段管理工具很容易被做成带云同步的笔记工具一旦加同步、协作、分享复杂度就完全不一样了。我把范围死死摁在个人使用上两个月内就从需求到上线跑通了完整链路。这个项目现在每天都在用我自己的常用片段都迁到了 t3code 里。5.2 后续迭代方向t3code 目前已经能满足我的基本需求了但迭代空间还不少。下一个让我想做的事情是浏览器扩展选中一段代码后右键直接存到 t3code这样就省去切到网页、新建片段、粘贴、填标题这一串动作。这个交互在现在的页面流程里是最重的。另外想做一个纯本地模式。现在的部署依赖 PostgreSQL 和 NextAuth个人如果要离线使用成本偏高。我之前研究过 SQLite 适配Prisma 对 SQLite 支持也不错但代码里有几处 PostgreSQL 特有的查询pg_trgm、聚合函数迁移过去需要做条件判断。这块我可以接受因为不是核心功能。5.3 如果重做一次我会改什么如果要重新做一遍 t3code有两件事我会从一开始就调整。第一是给所有 mutation 加上统一的错误处理现在错误信息直接抛给了前端用户看到的是 tRPC 的英文报错体验一般。应该在后端把错误分类参数错误、未登录、未知异常然后映射成友好提示。第二是测试。我做完核心功能之后才补了一些模型层测试发现补测试比写实现还费劲。如果从一开始就给 snippet router 的查询和 mutation 写单元测试后面改动时信心会大很多。tRPC 的测试其实不难直接用 caller 调用 router 方法就能测用不到 HTTP 层。这是 tRPC 架构带来的一个隐藏好处我打算下个迭代把它补上。做 t3code 这件事给我最大的感受是工具类项目最怕的不是技术难度而是需求跑偏。技术选型只要顺着类型安全、快速迭代这个目标走T3 技术栈几乎是当下最好的选择。现在每次写完代码按一下快捷键就能把片段存进自己写的工具里然后继续安心写下一个功能——这种吃自己的狗粮的循环还挺让人上头的。
阅读完成 · 觉得有帮助?