
上个月我把手头一个积压了很久的想法落地了做一个团队内部用的代码片段管理工具项目名就叫t3code。起因很朴素——组里每次做技术交接最常用的方式是翻聊天记录里的代码块或者有人临时贴一个能跑但不一定规范的片段到共享文档里。找一段几个月前写过的工具函数每次都要问一圈“谁还记得这段在哪个仓库”。痛到一定程度与其继续忍不如自己花两个晚上搭一个。t3code走的是当下很典型的全栈TypeScript路线技术栈落在Next.js tRPC Prisma Tailwind上也就是常说的T3 Stack。整篇文章我会把从需求拆分、项目初始化、数据模型设计、tRPC接口编写、前端页面组织到部署上线的完整过程捋一遍把我实际踩过的坑、做过的取舍和验证过的结论写清楚。如果你正准备用T3技术栈做一个内部工具或者想搞一个代码片段管理类的应用这篇应该能帮你省掉不少试错时间。1. 需求分析和选型t3code到底要解决什么问题1.1 拆需求时我列的四件事做内部工具最忌讳一上来就奔着“大而全”去。我动手前先把自己真实的使用场景写了下来我需要一个能快速保存和检索代码片段的地方支持按语言、标签过滤而不是靠文件夹堆。团队里会有多个人提交片段但绝大多数人只读不写所以权限模型不用特别复杂。每次保存片段时顺带记录上下文说明比如“这个工具函数用于把秒数格式化为时分秒”避免日后看到代码不知道当初为何这么写。部署成本要低最好一次就能跑起来不想维护独立的服务端和客户端两个项目。这些需求看起来简单但真正决定技术选型的是第一点检索和过滤。一旦涉及多条件筛选、关联标签、全文搜索数据库建模和接口设计就成了核心工作。市面上的在线代码托管服务大多偏开源社区风格对私有团队场景的支持很一般自建一个反而更贴合。1.2 为什么是T3技术栈而不是别的组合我最初也考虑过两个方案一个是Vue Express MySQL的传统前后端分离另一个是Next.js全栈。选择T3技术栈的核心理由其实就三条全栈TypeScript前后端共用同一套类型定义不用维护两份接口文档。tRPC让调用远程函数像调用本地函数一样类型推导是端到端的。Prisma做数据库迁移和类型生成非常顺手配合SQLite能在本地零配置跑起来上生产再切Postgres也很平滑。我没有选择传统分离架构的原因是这个项目本质上就是表单向服务端发请求再渲染列表大部分页面交互不复杂拆分前后端两个项目带来的部署、联调和类型同步成本远大于收益。对于团队内部工具这种规模全栈框架反而是最省心的。1.3 命名和定位t3code不是又一个pastebint3code这个名字有两层意思一是它跑在T3技术栈上算是向这套工具链致敬二是“t3”刚好和“team、track、type”押头韵暗示这个工具的核心是团队协作、代码追踪和类型安全。它的定位是“轻量级团队代码片段库”不是公开粘贴板——不做匿名分享、不做趋势页、不做评论社交就是让团队成员把散落在聊天记录和本地文件里的有效代码沉淀下来。2. 项目初始化与目录规划从create-t3-app到第一行业务代码2.1 初始化命令和依赖版本初始化用的官方脚手架命令很简单npx create-t3-applatest t3code交互式选项里我选择的是Next.js App Router、TypeScript严格模式、tRPC、Prisma、Tailwind CSS、NextAuth.js。没有选的是Drizzle——虽然Drizzle更新更快但Prisma的模型声明方式对团队里后端经验较少的人更友好迁移工具也更成熟。这里有一个需要注意的版本问题。create-t3-app生成的项目会把主要依赖锁在比较新的大版本上比如Next.js 14或15、React 18或19。如果团队历史项目还在用老版本不要盲目跟着脚手架升级否则切到别的项目时心智负担会很重。我在初始化后第一时间把所有依赖固定下来并提交了锁文件避免哪天有人在本地执行install时悄悄升级了某个包。2.2 目录结构按模块划分而不是按文件类型划分脚手架默认给的目录是routes、server、trpc这种横切分层。项目初期没问题但等Snippet相关接口多了以后找代码会变得很费劲。我调整成了按业务模块组织src/ app/ # Next.js页面和布局 (main)/ snippets/ tags/ server/ api/ snippets/ # snippet相关的trpc路由和处理器 tags/ db/ schema.prisma trpc/ router/ caller.ts这样做的好处是一次功能改动涉及的文件基本落在同一个目录里。比如给代码片段增加点赞功能就只需要动snippets这一个目录下的文件。坏处是每个模块需要自己维护好边界不能让路由处理器直接操作另一个模块的数据库模型。我在项目规范里约定跨模块数据访问必须通过对方提供的Procedure或工具函数禁止直接import对方的Prisma model代码。2.3 环境变量和本地数据库本地开发时数据库用的SQLiteDATABASE_URL直接指向项目下的一个本地文件DATABASE_URLfile:./dev.db之所以本地用SQLite而不是直接上Postgres是因为团队成员clone下来后只需要三条命令就能把整个环境跑起来npm install npx prisma migrate dev npm run dev不需要装数据库服务、不需要配账号密码。等真正要部署到服务器时再切到Postgres并重新执行一次迁移。这里有一个很容易被忽略的问题Prisma的schema里如果用了SQLite特有的类型或特性切到Postgres时可能要做调整。所以我在建模时尽量只用通用的类型String、Int、DateTime、Boolean规避内置的Json等特殊字段类型。SQLite和Postgres对Json的支持差异较大我宁可在代码层做序列化也不用数据库Json列。3. 数据模型设计Prisma Schema调整与迁移3.1 核心模型Snippet、Tag和关联关系代码片段管理应用最核心的模型自然是Snippet。我最初的Schema长这样model Snippet { id String id default(cuid()) title String description String? language String code String authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) id([snippetId, tagId]) }SnippetTag是显式多对多中间表。其实Prisma支持隐式多对多写法直接在Snippet里写tags Tag[]但显式中间表有两个好处未来可以在这个表上扩展字段比如标注tag的权重而且查询SQL更容易预测。对检索类的核心场景我更倾向于这种稍显啰嗦但可控的建模方式。关于主键的选择我用了ID字符串而不是自增整数。自增整数的好处是索引紧凑、查询快但代码片段这种数据会频繁被外部分享暴露自增ID等于让别人轻松枚举出你的全部片段。cuid()生成的非连续字符串主键更安全。代价是索引稍大但现代数据库对这个量级的数据完全无感。3.2 全文搜索的实现取舍代码片段库的核心体验依赖搜索。t3code里的搜索分两档第一档是按标题和描述做LIKE匹配第二档是按代码内容匹配。LIKE匹配对SQLite来说很简单但代码内容可能很长全表扫描的成本随着数据量增长会越来越难看。在项目初版阶段我先用了比较“土”的方案保存一条片段时同时把 title、description、language、tag名、代码内容拼成一段纯文本存到searchText字段查询时只对这个字段做LIKE。代码是这样的model Snippet { // ... searchText String default() }每次写入时用一个小函数生成function buildSearchText(snippet: { title: string; description?: string | null; language: string; tags: string[]; code: string }) { return [snippet.title, snippet.description ?? , snippet.language, snippet.tags.join( ), snippet.code].join( \n).toLowerCase(); }查询时await db.snippet.findMany({ where: { searchText: { contains: keyword.toLowerCase() }, }, });实际用下来几千条片段的检索速度完全在可接受范围内。如果未来数据量大到不行了再去接Meilisearch或者Postgres的全文本搜索也不迟——至少在那之前功能已经能跑不需要为可能不会出现的规模提前买单。这个思路对内部工具来说很关键性能和优雅之间先保可用性和可维护性。3.3 迁移过程中遇到的两个实际问题第一次写迁移文件时踩了两个坑。第一个坑是prisma migrate dev在执行时会提示数据丢失风险。原因是我在中间版本里调整了Tag模型的唯一约束导致Prisma检测到需要重建表。开发环境里无所谓直接确认重置即可但如果这个库已经有生产数据了就必须用prisma migrate dev --create-only生成SQL人工检查没有问题后再执行。第二个坑和默认值有关。给已有数据的表新增一个非空字段时Prisma要求字段必须有默认值否则迁移会失败。我给searchText加了default()这看起来很简单但千万记得在应用代码里不要依赖这个默认值——当你保存一条旧数据时searchText可能一直是空的直到下次更新这条记录才会被填入。所以我在创建和更新Snippet的两个Procedure里都统一调用了buildSearchText确保从源头就写全。4. tRPC Router设计从Type-Safe到API规范4.1 先定义调用方视角的ProcedureT3技术栈里tRPC承担的是API层。tRPC的思想是server端定义Procedureclient端直接调用不需要生成API文档。实际体验下来确实如此——前端代码里调用一个接口的类型检查和调用本地函数完全一样。但这也意味着Procedure的划分本身就是API设计偷懒不得。t3code里Snippet相关的Procedure我分了这几类snippet.getAll支持分页、关键词、语言、标签过滤。snippet.getById详情页用。snippet.create新增。snippet.update编辑。snippet.delete删除。tag.getAll标签列表用于下拉筛选。每个Procedure都用Zod定义输入。以getAll为例import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/trpc/trpc; export const snippetRouter createTRPCRouter({ getAll: protectedProcedure .input( z.object({ keyword: z.string().optional(), language: z.string().optional(), tag: z.string().optional(), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) { const where buildWhere(input); const [items, total] await Promise.all([ ctx.db.snippet.findMany({ where, orderBy: { updatedAt: desc }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, include: { tags: { include: { tag: true } } }, }), ctx.db.snippet.count({ where }), ]); return { items, total, page: input.page, pageSize: input.pageSize }; }), });你会发现我在输入层就限死了pageSize最大50。这是为了防止有人一次性拉走全表数据。内部工具虽然用户少但代码片段量很容易上千一个没限制的列表接口会让数据库和前端都很难受。4.2 protectedProcedure和鉴权上下文脚手架默认生成的是protectedProcedure它要求用户必须登录才能访问。判断逻辑在createContext里import { getServerSession } from next-auth; import { authOptions } from ~/server/auth; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db: prisma, session, ...opts, }; };然后在编写Procedure时可以直接通过ctx.session拿到当前用户信息创建Snippet时自动把authorId填入不需要前端传。这个设计很符合直觉服务端应该信任自己的会话判断而不是信任客户端提交的userId。实际使用中我遇到过一个比较隐蔽的问题在使用App Router时getServerSession只能在服务端组件或Route Handler里调用。tRPC的query运行在服务端所以没问题。但如果哪天你想在客户端组件里直接调用某个不依赖上下文的Procedure就不能依赖session自动获取。解决方案是api调用路径走独立Server Action或者Route Handler。我在项目里统一约定所有数据变更操作一律走tRPC服务端渲染页面初始数据用trpc server caller直接取不搞第二套。4.3 Zod校验带来的意外收益很多人用Zod只把它当成“从前端传来的数据格式校验”但我在t3code里把它用得更深了一层。比如create Snippet的Procedurecreate: protectedProcedure .input( z.object({ title: z.string().min(1).max(80), description: z.string().max(500).optional(), language: z.enum([javascript, typescript, python, go, rust, sql, other]), code: z.string().min(1), tagNames: z.array(z.string().min(1).max(30)).max(10).default([]), }) ) .mutation(async ({ ctx, input }) { // 这里可以放心地使用input因为所有字段已经被校验过了 });收益在于前端表单即使忘了校验服务端也能兜底。这对内部工具很关键——团队成员可能绕过UI直接用curl调接口做自动化操作如果服务端不校验脏数据直接进库后面的检索、展示全都会受影响。我把“服务端必须做校验”当成铁律不然Zod就是摆设。另一个收益是错误提示的统一。Zod校验失败时tRPC会返回一个规范的错误对象前端可以精确解析哪个字段错了并展示对应的错误提示。比自己写一堆if-else检查字段再返回“参数错误”这个模糊信息好得多。4.4 让类型check成为文档tRPC最爽的一点是前端调用时类型完全推导。拿列表页举例我的search组件里可以直接这么写import { api } from ~/trpc/react; const utils api.useUtils(); const { data, isLoading } api.snippet.getAll.useQuery({ keyword, language: language ?? undefined, tag: tag ?? undefined, page, pageSize: 12, }); const createMutation api.snippet.create.useMutation({ onSuccess: () { utils.snippet.getAll.invalidate(); }, });这里没有一个接口路径字符串也不用手动声明返回类型。改服务端Procedure签名后前端编译立刻报错根本不会出现改完接口忘了同步前端的低级错误。对一个会被多人维护的项目来说这种约束比任何开发规范都有效。5. 前端页面组织Server Components与交互边界的拿捏5.1 页面骨架列表页和详情页的分工t3code的前端分成三个主要页面列表页、详情页、创建/编辑页。列表页是服务端组件为主的页面。首次进入时我用server caller直接查询数据避免客户端加载白屏import { api as serverApi } from ~/trpc/server; export default async function SnippetListPage({ searchParams }: { searchParams: { [key: string]: string | undefined } }) { const initialData await serverApi.snippet.getAll({ page: parseInt(searchParams.page ?? 1), pageSize: 12, keyword: searchParams.keyword, language: searchParams.language, }); // 传递数据给客户端组件 }初始数据序列化传到客户端组件后客户端组件用useQuery的initialData来接管后续的分页、过滤和变更。这样可以兼顾首屏速度和交互流畅度。这里需要特别注意边界服务端组件里不能写useState和事件处理函数客户端组件里也不能直接访问tRPC的服务端caller除非手动传入。一开始我把筛选表单设在客户端组件里又尝试在服务端组件读取筛选结果来回倒腾了几次才理清状态应该放在URL上。最后方案是筛选条件全部同步到URL query参数服务端组件读取searchParams生成initialData客户端组件读取同一份searchParams初始化表单状态。事件驱动URL变化后Next.js会重新渲染服务端组件相当于用最朴素的“刷新页面”方式实现了筛选反而更加稳定。5.2 代码高亮Shiki和Prism的取舍代码片段工具的“脸面”是代码高亮。我先后试过Prism和Shiki。Prism胜在体积小、接入简单但行内高亮效果一般暗色主题也不够现代。Shiki的核心优势是使用TextMate语法和VS Code同源高亮结果非常准确。最终选了Shiki。在Next.js的App Router环境下Shiki需要服务端初始化因为它内部要加载语言包如果放到客户端包体会变得很大。我的做法是提供一个只在Server Component里调用的高亮工具函数import { codeToHtml } from shiki; export async function highlightCode(code: string, language: string) { return codeToHtml(code, { lang: language, theme: github-dark, }); }在展示详情页时直接把高亮后的HTML用dangerouslySetInnerHTML渲染出来。需要确保的是语言名的映射关系Shiki内部叫JavaScript为javascriptTypeScript是typescript这些都和t3code存储的language字段一致就不需要额外转换。5.3 复制按钮和“use client”代码块右上角的复制按钮看起来简单实际上踩了两个坑。第一个是必须在组件顶部标注use client这样按钮的onClick才能被Next.js识别为客户端交互。第二个是navigator.clipboard在非HTTPS环境下不可用。本地开发用的localhost是安全上下文没问题但部署到某些局域网环境时如果没有HTTPS点击复制会静默失败。我当时的处理方式很直接按钮点击时同时尝试navigator.clipboard.writeText和document.execCommand(copy)作为降级。虽然execCommand已被标记为废弃但它对非HTTPS环境支持更好作为降级方案完全OK。5.4 创建页的表单设计创建页的表单是纯客户端组件。标题、描述、语言选择、标签输入、代码内容——这些字段如果都写在服务端组件里等用户输入时还要重新渲染体验很差。表单设计上用react-hook-form做状态管理标签输入用了一个简单的TagInput组件输入文字后回车自动生成标签chip允许删除。提交时把所有字段一次性打包成api.snippet.create的入参。一个小技巧当创建成功后不要跳转到列表页再查一次而是直接把新返回的数据用router.push到详情页。这样用户能立刻看到自己刚保存的片段长什么样确认高亮和排版没跑偏体验会顺很多。6. 部署上线与生产环境填坑记录6.1 部署平台选择和构建配置t3code最终部署在Vercel上。原因很简单Next.js项目在Vercel上简直是原生的自动检测框架、自动构建、自动分配域名。需要手动配置的是环境变量DATABASE_URL指向线上Postgres数据库。我用了Vercel Postgres因为它和平台集成最顺不用自己维护连接池。NEXTAUTH_SECRET用openssl rand -base64 32生成然后填到环境变量里。NEXTAUTH_URL填生产域名地址。Vercel Postgres本身是托管服务自带连接池对Prisma也很友好。唯一要提防的是免费档的资源上限连接数有限如果团队人不多、每个用户开一个连接绰绰有余。但要避免写一些长时间占用连接的代码比如在事务里做网络请求这种写法任何托管数据库都扛不住。6.2 构建时的TypeScript和Prisma生成顺序deploy时最容易碰到的报错就是构建失败点开日志一看大概率是Prisma schema和生成的类型不一致。原因在于Vercel构建时会先执行prisma generate再跑tsc。如果schema.prisma改动过但没有更新生成的Prisma Client类型构建就会说找不到某个model字段。我在package.json的build里固定了顺序{ scripts: { build: prisma generate next build } }同时也要注意千万不要在prisma schema里使用SQLite专属特性后上生产Postgres否则迁移会失败。前面建模时我已经规避了这一点所以生产切换很顺利表结构和本地完全一致。6.3 数据刷新与缓存Next.js缓存引发的一个幽灵Bug上线后第一周我接到了一个反馈“新增的片段在列表页看不到但详情页能打开。”排查后定位到了根因列表页是服务端组件Next.js默认开启了数据缓存。在开发模式下每次请求都会重新渲染所以不会触发但生产模式下页面在某个时间窗口内是静态缓存的新增数据不会立刻反映出来。解法是在列表页顶部加上export const dynamic force-dynamic;这样页面每次请求都会重新渲染查询最新数据。对于内部工具来说这个选项非常合适——我们不需要让列表页变成CDN静态缓存的对象保证数据一致比性能优化更重要。同样的问题也出现在tRPC的query缓存上。客户端组件里调api.snippet.getAll.useQuery返回的数据在5分钟内不会重新拉取默认staleTime所以创建完片段后我调用utils.snippet.getAll.invalidate()强制刷新。如果不做这个操作用户会看到自己刚创建的片段“消失”了实际上数据在库里只是前端拿到的还是旧缓存。6.4 数据库连接池和Prisma启动警告生产环境日志里出现Prisma的警告“The datasource provider sqlite does not support之类那是我在某个测试分支里误把DATABASE_URL指回到了本地SQLite文件。这个浪费了我半天时间。我吸取的教训是数据库连接字符串不要写在.env里直接复用同一个文件而是按环境分文件.env # 开发环境 .env.production # 生产环境包含生产数据库连接Vercel部署时手动填入生产环境变量即可。本地跑dev始终读的是.env。prisma migrate命令要确保自己连的是想操作的那个库迁移之前先执行prisma migrate status看一眼状态别想当然。7. 实测效果和我的项目体会t3code上线跑了三周团队的用法比我预期的要野生很多。有人把部署脚本、环境变量说明、常用SQL都存成了片段有人会把整页代码贴上来然后用description写一段总结还有人开始给片段打标签并互相引用。这让我意识到工具的价值很多时候不在于它有多少高级功能而在于它能不能低门槛地承接住团队已经存在的协作习惯。顺手做了一次简单的使用统计21个片段、8个标签、平均每个片段被查看了五六次。数据量不大但对一个内部工具来说已经是良性起步了。性能上列表页接口延迟基本稳定在30到50毫秒全文搜索在最坏情况下LIKE全表扫也就100毫秒出头对于这个量级完全无压力。我在这个项目里最大的体会是T3技术栈把“全栈TypeScript”这件事做到了让使用者无感。它没有强迫你理解一堆抽象概念而是用类型系统和统一语言把前后端的边界消解掉。你只需要操心业务本身剩下的交给Next.js的约定和tRPC的推导就好。如果你正在犹豫要不要用T3技术栈做一个内部工具我的建议很直接动手跑一个原型两个晚上就能判断适不适合你的场景。代码片段管理、内部文档、轻量CRM、定时任务后台这类偏数据表单形态的工具T3技术栈基本都是好选择。