ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从0到1:用T3 Stack打造团队代码片段管理平台

2026/10/8 3:26:21 拓冰建站 浏览量
从0到1:用T3 Stack打造团队代码片段管理平台 1. 为什么在2024年我还要亲手做一个代码片段工具事情是这样的上周我想找一段之前写过的防止按钮重复提交的工具函数。先翻了本地 VSCode 里的 Snippets 目录没有又去翻团队飞书文档找到了三个版本但都不确定哪个是最终版最后问了一圈同事有人从聊天记录里捞出来一段还有人说在 GitHub Gist 里见过。整个过程花了二十多分钟而且我很确定类似的时间浪费在团队里每天都在发生。市面上不是没有代码片段工具。GitHub Gist 够轻但和团队协作、权限管理基本不搭本地 Snippet 只管自己换台电脑就断档用 Notion、飞书这类文档工具管理代码语法高亮、多语言识别、快速插入这些体验又约等于零。我需要的是一个面向小团队、以类型安全和开发体验为第一优先级、能自托管的代码片段管理平台。这就是我的个人项目 t3code 的由来——名字也很直白T3 生态下的代码工具。t3code 定位非常聚焦开发者可以快速创建代码片段打上语言和标签系统自动做语法高亮和 Markdown 渲染片段可以私有、可以分享给团队空间支持全文搜索、标签筛选、版本历史、点赞收藏。所有数据都通过 tRPC 接口做完整的端到端类型安全校验。适合两类人看这篇分享一是正在计划做工具类全栈项目、想在技术选型上少踩坑的开发者二是好奇 T3 Stack 在生产项目中如何落地的朋友。1.1 现有方案的三个差一点先说清楚我为什么没有直接用现成方案这个思考过程决定了 t3code 的很多设计取舍。GitHub Gist 的优势是托管理想、天然有社交元素但它的团队模型基本是公开/私密二分法私有 Gist 的协作体验相当原始。想象一下你们团队有 6 个人想共享一些内部 API 的调用样例用 Gist 就要么公开到外网要么每个人自己 fork 一份完全没有团队工作区的概念。本地 Snippet 的问题则是同步和发现我的习惯性写法在本地存了上百条但同事遇到同样问题的时候永远不知道我写过这违背了共享知识的初衷。文档工具的问题更隐蔽——它们把代码当成富文本的一部分而不是一等公民。没有语言识别、没有编辑器体验、没有版本对比粘贴进去的代码很快会失去可读性和可信度。t3code 专注解决的是团队的私有代码知识库这个场景。一条片段从创建开始就天然属于某个用户或团队权限模型清晰搜索不只是标题匹配而是覆盖代码内容本身的全文检索。这些需求虽然听起来朴素但在现成工具里始终找不到完全对口的那一个。1.2 t3code 的核心功能范围和边界为避免做一个大而全的东西这种不切实际的冲动我给 t3code 划了明确的能力边界。第一版只做四件事片段管理增删改查、Markdown 描述、一键复制、标签与多语言分类、团队空间与权限、全文搜索。不做评论系统不做 Web IDE不做插件市场不做社交订阅流。每一条不做的事情背后都有理由评论会引入消息通知、富文本编辑、提及等一长串需求Web IDE 更是深不见底。把边界收缩到存、找、分享三个动作项目才能在可控时间内真正跑起来。1.3 为什么是 T3 Stack 而不是别的选 T3 Stack 就是 Next.js TypeScript Tailwind CSS tRPC Prisma 这套组合。老实说一开始我也纠结过是不是用 NestJS Vue MySQL 更正统或者直接上 Express React 更简单后来真正让我下定决心的是 TypeScript 端到端的体验。tRPC 允许我在服务端定义 procedure前端直接像调用本地函数一样调用类型自动推断不用手写 API 文档不用维护 DTO 层。对于 t3code 这种以 CRUD 和搜索为主的工具型项目这套组合的性价比极高。加上 Prisma 的 schema 即类型从数据库到页面组件TypeScript 类型可以一路穿透写翻页和搜索的时候再也不用对着字符串字段名猜类型。这个选择也意味着如果后续要加功能大部分情况下不需要新引入其他基础设施。2. 数据模型设计代码片段不只是一段文本t3code 的数据库我选的是 PostgreSQL不是 SQLite也不是 MongoDB。原因后面细说。先看数据模型这是整个项目的骨架也是我一开始花最多时间琢磨的部分。2.1 Prisma Schema从片段到团队空间的实体关系一条代码片段表面上只是标题 内容 语言但一旦涉及团队协作实体关系就会多出几个维度谁创建了它、归属哪个团队、有哪些标签、谁收藏了它、历史版本怎么存。我设计了这样一组核心实体model User { id String id default(cuid()) email String unique name String? image String? snippets Snippet[] memberships Membership[] votes Vote[] createdAt DateTime default(now()) } model Team { id String id default(cuid()) name String slug String unique members Membership[] snippets Snippet[] createdAt DateTime default(now()) } model Membership { id String id default(cuid()) userId String teamId String role Role default(MEMBER) user User relation(fields: [userId], references: [id]) team Team relation(fields: [teamId], references: [id]) unique([userId, teamId]) } enum Role { OWNER ADMIN MEMBER } model Snippet { id String id default(cuid()) title String description String? content String db.Text language String default(plaintext) visibility Visibility default(PRIVATE) author User relation(fields: [authorId], references: [id]) authorId String team Team? relation(fields: [teamId], references: [id]) teamId String? tags Tag[] votes Vote[] versions SnippetVersion[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([language]) index([visibility]) index([teamId]) } model SnippetVersion { id String id default(cuid()) snippetId String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) content String db.Text note String? createdBy String createdAt DateTime default(now()) } model Tag { id String id default(cuid()) name String unique snippets Snippet[] } model Vote { id String id default(cuid()) userId String snippetId String user User relation(fields: [userId], references: [id]) snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) unique([userId, snippetId]) }几个设计决策我想特别解释一下。Visibility 我拆成了 PRIVATE / TEAM / PUBLIC 三档PRIVATE 只有本人可见TEAM 需要关联 teamIdPUBLIC 所有人可读。这样从数据层就把个人草稿、团队共享、公开分享三种使用场景划分清楚。SnippetVersion 用来做版本历史保存每次编辑的快照避免后续做 diff 功能时还要去翻日志。Vote 张表处理点赞/收藏用复合唯一约束保证一个用户对一条片段只能点一次赞。2.2 标签与全文搜索实用性藏在细节里标签模型我最初想得很简单Snippet 和 Tag 之间做多对多Tag 表就 name 一个字段。真正开始写搜索才发现问题——代码内容本身是搜索的重头戏光靠标签远远不够。PostgreSQL 有内置的全文搜索能力 tsvector但 Prisma 对原生全文索引的支持还不够灵活我最后采用的是折中方案片段标题和 description 走 Prisma 的普通查询用contains做模糊匹配代码内容走 PostgreSQL 的to_tsvector原生查询通过$queryRaw执行。// 在迁移中添加全文搜索索引 CREATE INDEX snippet_content_search_idx ON Snippet USING GIN (to_tsvector(simple, content));这个方案在数据量到达十万条级别之前都够用而且实现成本很低不需要额外引入 ElasticSearch 或 Meilisearch。搜索接口最终支持按查询词、语言、标签过滤返回结果按更新时间倒序并做了游标分页。这里要提醒一句不要一上来就上重型搜索引擎对 t3code 这个体量的项目来说PG 自带能力已经能覆盖绝大多数场景。2.3 为什么选 PostgreSQL Neon而不是 SQLite 或 MongoDBSQLite 对于单机工具很友好部署也简单但 t3code 天然是多人多端的 Web 应用SQLite 的并发写入能力满足不了团队共享这个核心诉求。MongoDB 的灵活 schema 对快速原型很吸引人但代码片段这种结构相对固定的数据用关系型模型反而省心——多对多标签、多人协作、版本关联都是关系型数据库的舒适区。生产环境我选择部署在 Neon 上因为这个服务提供兼容 PostgreSQL 的无服务托管自带连接池对按需扩容场景很友好也不用自己维护数据库集群。本地开发跑 Docker 里的 PostgreSQL 或直接连 Neon 都可以Prisma schema 完全不用改。这也算我为部署省下的最大的一笔心力和租金。3. 类型安全全链路tRPC Prisma 的化学反应接下来是这篇文章的重头戏。t3code 里最有意思的部分就是前端如何像调用本地函数一样调用后端逻辑而且全程有类型保障。这一节我会把从定义 router 到页面调用、再到类型推断的完整链路拆开讲。3.1 tRPC 的核心心智模型简单说tRPC 就是让你不需要定义 REST 路由、不需要手写 API 客户端直接把服务端函数暴露给前端。这听起来有点像魔法但原理其实清楚服务端定义 procedure可以理解为可被远程调用的函数并用 zod 定义输入输出类型tRPC 把这些函数的类型信息通过Router对象完整传给前端前端引用类型时就能拿到服务端的类型签名。这就解决了一个长期痛点传统 REST 方案里前后端各维护一份接口类型定义改一个字段要同步两个仓库。t3code 用 tRPC 之后服务端改了输入结构前端 TypeScript 立刻报错接口文档直接不存在了——因为类型就是文档。3.2 一个完整的创建片段流程看一个真实的例子。创建片段是 t3code 最核心的操作。服务端 router 是这样写的import { z } from zod; import { router, protectedProcedure } from ../trpc; export const snippetRouter router({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), description: z.string().max(500).optional(), content: z.string().min(1), language: z.string().default(plaintext), tags: z.array(z.string()).max(10).default([]), teamId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) { const { prisma, session } ctx; // 权限如果指定了团队必须确认当前用户是该团队成员 if (input.teamId) { const membership await prisma.membership.findFirst({ where: { userId: session.user.id, teamId: input.teamId }, }); if (!membership) { throw new TRPCError({ code: FORBIDDEN, message: 你不是该团队成员无法在此空间创建片段, }); } } const snippet await prisma.snippet.create({ data: { title: input.title, description: input.description, content: input.content, language: input.language, authorId: session.user.id, visibility: input.teamId ? TEAM : PRIVATE, teamId: input.teamId, tags: { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, })), }, }, include: { author: true, tags: true }, }); return snippet; }), });注意几个细节。第一输入校验用的是 zodtitle、content、tags 的数量和长度都在入口处卡死不需要在业务代码里再写一遍if title is empty。第二权限检查直接写在 mutation 内这也是 tRPC 的典型风格——中间件可以复用但最内层的业务逻辑里依然值得对关键资源做二次校验。第三connectOrCreate是 Prisma 多对多关联的标准操作标签不存在就自动创建存在则直接关联。前端调用同样简单const utils trpc.useUtils(); const mutation trpc.snippet.create.useMutation({ onSuccess: () { utils.snippet.invalidate(); }, }); function handleCreate(data: SnippetFormValues) { mutation.mutate(data); }trpc.snippet.create 这个方法的类型、入参结构、返回值类型全部由服务端推导。写前端的时候按下点号IDE 的自动补全直接列出可调用的 procedure 和字段根本不用翻接口文档。3.3 前端推断Refine 速度和错误处理带来的体验提升我实际开发中的最大感受是前端代码里的类型断言几乎可以全部删掉。React Query 底层被 tRPC 集成trpc.snippet.list.useQuery()返回的 data 类型直接就是服务端查询函数的返回类型。列表页、详情页、收藏页可以共用同一个数据模型而无需任何 DTO 转换。错误处理也很流畅——tRPC 抛出的TRPCError会沿调用链一路传到前端我在前端统一用useErrorHandler把NOT_FOUND、FORBIDDEN、UNAUTHORIZED之类的错误码映射成用户可读的提示文案。少了前端自己定义一堆请求状态枚举的麻烦也少了状态不同步的隐患。4. 编辑器集成与高亮渲染Monaco 之外的轻量选择代码片段平台的核心体验在写和看。写的时候需要舒适的多行编辑看的时候需要高质量高亮。这里我做了两个选型决策编辑器用 CodeMirror 6不用 Monaco高亮渲染用 Shiki不用 Prism。4.1 CodeMirror 6 为什么比 Monaco 更适合这个场景Monaco 是 VS Code 的编辑器内核功能极其强大但体积和复杂度也惊人。它更像一个应用需要非常仔细地处理加载、布局、主题、快捷键和生命周期。而 t3code 的编辑场景是一个中等大小的代码块而不是一个大型源文件CodeMirror 6 更轻、更模块化而且对 React 的集成方式更自然。下面是我在 React 组件里集成 CodeMirror 的核心逻辑use client; import { useEffect, useRef } from react; import { EditorView, basicSetup } from codemirror; import { EditorState } from codemirror/state; import { javascript } from codemirror/lang-javascript; import { python } from codemirror/lang-python; import { go } from codemirror/lang-go; import { java } from codemirror/lang-java; import { cpp } from codemirror/lang-cpp; import { oneDark } from codemirror/theme-one-dark; const languageMap: Recordstring, LanguageSupport { javascript: javascript(), typescript: javascript({ typescript: true }), python: python(), go: go(), java: java(), cpp: cpp(), }; export function CodeEditor({ initialValue, language, onChange, }: { initialValue: string; language: string; onChange?: (value: string) void; }) { const containerRef useRefHTMLDivElement(null); const viewRef useRefEditorView | null(null); useEffect(() { if (!containerRef.current) return; const state EditorState.create({ doc: initialValue, extensions: [ basicSetup, oneDark, languageMap[language] ?? [], EditorView.lineWrapping, EditorView.updateListener.of((update) { if (update.docChanged) { onChange?.(update.state.doc.toString()); } }), ], }); const view new EditorView({ state, parent: containerRef.current, }); viewRef.current view; return () { view.destroy(); viewRef.current null; }; // 注意initialValue 只在初始化时生效 }, [language]); return div ref{containerRef} classNamew-full rounded-lg border border-zinc-800 /; }这段代码有意识地避开了 SSR hydration 问题——use client将编辑器隔离为纯客户端组件useEffect创建 EditorView挂载和销毁都有严格的生命周期管理。如果你打算在自己的项目里集成 CodeMirror我最想提醒的细节就是EditorState.create时传入的 doc 只作为初始值绝不能在外层依赖里反复触发重建否则每次父组件重渲染都会重新创建一个编辑器实例光标和输入内容都会闪。用onChange回调把内容同步到 React 状态机由状态机统一管理数据流才是正解。4.2 高亮渲染Shiki 的取舍和主题定制看代码列表时用户不需要一个完整编辑器他们需要的是可读性极高的高亮代码块。传统方案 Prism 推广度高、插件多但它的高亮依赖正则匹配遇到 TS 泛型、嵌套模板字符串这类语法时经常高亮错误。Shiki 的底层是 TextMate 语法直接从 VS Code 生态继承解析能力精确度完全不是一个级别。t3code 里我选择让 Shiki 在服务端完成高亮渲染生成纯 HTML 返回前端不需要再加载任何高亮相关 JS。访问页面时看到的是静默的、无闪烁的代码块。import getHighlighter from shiki; const highlighter await getHighlighter({ theme: github-dark, langs: [javascript, typescript, python, go, java, cpp], }); export function highlightCode(code: string, lang: string) { return highlighter.codeToHtml(code, { lang: lang.toLocaleLowerCase() }); }但这里有个教训Shiki 的高亮结果默认是带内联样式和固定主题的。如果网站支持亮色/暗色主题切换就需要谨慎处理。t3code 第一版就翻过车——页面上同时存在基于暗色主题生成的代码块和亮色界面的背景对比度差到没法看。我的解决方案是固定使用一个中性的深色主题生成代码块代码块本身始终深色让它在亮色和暗色界面下都成立。虽然不算完美但对代码片段平台来说代码区域保持深色反而是很多开发者欢迎的设定。4.3 前端代码浏览器的几个交互细节围绕代码块我还做了不少小而关键的功能。一键复制按钮用navigator.clipboard.writeText配合三种反馈状态默认、已复制、复制失败并在按钮上显示 2 秒的已复制提示。行号由 Shiki 输出 HTML 时在行内塞入line之类的 class 控制避免前端再去做 DOM 操作。折叠长片段时使用纯 CSSmax-height overflow hidden展开时用transition做平滑动画不引入任何额外组件库。语言徽章用颜色编码表示——JS 黄色、TS 蓝色、Python 绿色、Go 青色——这样在列表中扫一眼就能定位目标片段。5. 团队权限认证与授权的完整落地工具可以做给个人用但 t3code 的核心场景在团队。权限模型从第一版就决定做成员—角色—空间三级而不是简单的公开/私密。5.1 NextAuth 接入与 Session 类型的一次性处理认证方案我选了 NextAuth因为和 Next.js App Router 结合最顺而且实现社交登录只要配置 provider 就好。t3code 支持 GitHub 和 Google 登录。但 NextAuth 的 Session 类型默认只包含标准字段不会自动带上数据库里的 User id。你必须在声明文件里做类型增强否则在 tRPC context 里读session.user.id会直接 TS 报错。// types/next-auth.d.ts import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }这个文件必须在 tsconfig 的include范围内并且在你修改类型后重启tsc --watch否则看不到效果。当初我因为忘了重启一直以为 NextAuth 没暴露 id 字段浪费了小半天。另一个要点是 tRPC context 里取 session 用getServerSession而不是客户端 API否则服务端代码拿不到 session这是 t3code 在 RSC 场景下的标准姿势。5.2 tRPC middleware 做权限控制避免到处散落 if我把团队权限检查抽取成可复用的 tRPC middleware。比如必须是团队成员、必须是团队管理员这两类检查就不再是散落在每个 mutation 里的 if 块而是中间件链import { middleware, publicProcedure } from ../trpc; import { TRPCError } from trpc/server; const isAuthed middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session }, }); }); export const protectedProcedure publicProcedure.use(isAuthed); function isTeamMember(teamIdResolver: (input: any) string) { return middleware(async ({ ctx, next, input }) { const teamId teamIdResolver(input); const membership await ctx.prisma.membership.findFirst({ where: { userId: ctx.session!.user.id, teamId, }, }); if (!membership) { throw new TRPCError({ code: FORBIDDEN, message: 没有该团队的操作权限, }); } return next({ ctx: { ...ctx, membership, }, }); }); } export const teamProcedure protectedProcedure.use( isTeamMember((input: any) input.teamId) );于是删除片段、修改团队设置等接口只需要teamProcedure.input(...).mutation(...)中间件会在进业务逻辑前统一完成鉴权。这让代码审查变得很舒服——翻 router 的时候权限语义一眼就能看出来不需要逐行推演。5.3 版本历史与操作审计数据安全的小投入大回报团队场景里删错片段、改坏内容是不可避免的。t3code 的版本机制是每次 update 操作插入一条 SnippetVersion 快照保留 content、note编辑说明和 createdBy。界面上的历史版本按钮打开侧边栏可以逐个版本查看并一键恢复到指定版本。这个功能实现成本不高但救过我很多次——有一次同事清理旧代码时误把还在引用的工具函数删了从版本历史里两分钟找回。我不夸张地说如果做团队工具没有版本历史上线两个月后你一定会花更多的时间去救数据。6. 从本地到线上Vercel Neon 的部署实践6.1 部署架构与连接池配置t3code 的部署架构我尽量做了减法前端和 API 都部署在 Vercel 上数据库用 Neon Postgres存储用 Vercel Blob 或云存储桶存用户头像域名托管在 Cloudflare。整套架构没有一个需要我长期运维的常驻服务这也是 T3 Stack 生态里非常典型的部署模型。唯一需要关注的性能点是数据库连接。Vercel Serverless 函数的冷启动和连接数峰值是真实存在的直接连数据库会遇到连接池不够的瓶颈。Neon 提供了内置连接池连接字符串协议上有区别类似postgresql://user:passwordpooled-neon-host:5432/dbPrisma 需要配置directUrl和url两个字段一个用于查询、一个用于直接连接执行迁移。DATABASE_URLpostgresql://user:passwordpooled-neon-host:5432/t3code DIRECT_URLpostgresql://user:passwordprimary-neon-host:5432/t3codedatasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) }这套配置让我省去了对 pgbouncer 的手动运维。实测在 Vercel 免费档 Neon 免费档的组合下个人项目的响应时间完全够用。6.2 缓存策略哪些页面适合 SSR哪些必须 CSRt3code 的页面大致分成三类。公开的片段列表页、标签页适合用静态生成或增量静态再生——因为数据变化频率低适合generateStaticParams配合revalidate。用户自己的片段管理页、团队空间页适合用客户端渲染 React Query 缓存——因为每次进来都可能看到最新状态而且需要和 mutation 后的 invalidate 联动。详情页则走 SSR因为需要保证搜索引擎和社交平台 Open Graph 抓取到完整标题和内容。这里我学到的一条经验不要一刀切全用 CSR 或全用 SSR按数据新鲜度要求划分策略才能兼顾首屏速度和交互体验。t3code 里列表页派生的缓存 key 也会绑定语言和标签的筛选条件避免一个页面切换筛选后继续展示旧数据。6.3 数据备份、迁移与回滚的常规操作很多独立开发者容易忽略数据库备份直到数据没了才后悔。我的做法是每个月用pg_dump导出一份全量 SQL 存到私有存储同时在每次 Prisma migration 之前先手动备份。Neon 本身有时间点回滚能力这让我在做 schema 变更的时候底气足了不少。迁移流程遵循 Prisma 的标准链路本地改 schema -prisma migrate dev生成迁移文件 - 提交到代码库 - 部署时在 CI 里执行prisma migrate deploy。注意不要把migrate dev用在生产环境它会尝试重置数据生产环境只能用migrate deploy做增量迁移。这条规则我见过太多人踩坑多强调一次。7. 我在 t3code 中踩过的坑你应该已经不用再踩7.1 NextAuth Session 类型丢失与 RSC 环境取不到 session第一个坑我在前面提到过就是 NextAuth 的 Session 类型增强没生效。再补一个场景App Router 的 Server Component 里如果要用 session必须把getServerSession放在服务端组件里调用不能放在客户端组件否则你会得到 null。这个规则导致我一开始在导航栏里展示用户头像时总是显示未登录排查了很久才发现是组件用了use client指令。正确做法是在服务端组件里取一次 session传给客户端组件做展示。t3code 现在的用户信息组件就是这样——客户端组件只接收userprop不会自己请求 session。7.2 Prisma 多对多自关联和标签去重的坑标签在 connectOrCreate 时如果同一批输入里有两个相同名称的标签Prisma 会尝试创建两个同名的 Tag然后撞上 unique 约束抛错。我的解决办法是入库前做一个去重Array.from(new Set(input.tags))。这是很小的细节但很典型——只要标签输入来自自由的文本框重复就一定会出现。另一个相关坑是 tag 的name大小写问题React和react会被当成两个标签。我最后决定对 tag 名称做 toLowerCase 归一化虽然损失了一些展示上的花哨但搜索和去重都干净了。7.3 tRPC Error 信息的序列化问题tRPC 的TRPCError消息默认在客户端可以访问但如果你在错误对象里附加自定义 data比如一个错误码枚举、一个字段路径需要开启errorFormatter否则这些信息会被吞掉。我踩到的问题是前端想要根据不同的错误码做不同 UI 提示结果只收到了统一的INTERNAL_SERVER_ERROR费了好大劲才发现是 formatter 没配置。配置方式并不复杂在 tRPC router 创建时传errorFormatter去透传你放入cause或data的内容。建议从项目第一天就配好全局 errorFormatter别等踩坑再补。7.4 CodeMirror 编辑器 SSR 崩溃和内容不同步CodeMirror 如果直接在服务端渲染的组件里 import 就会挂因为EditorView依赖window和document。解决方法我前面提过——独立客户端组件 use clientuseEffect初始化。但还有一个隐蔽的坑编辑器初始化完成后如果父组件的异步请求比如拉取片段详情导致initialValue在第二次渲染时变了而编辑器内部 doc 并没有随之更新界面就和数据不一致了。我的解决方案是给编辑器加一个受控的syncExternalValue逻辑只有当编辑器尚未聚焦、且外部值发生变化时才dispatch修改文档。这个逻辑处理完之后编辑器的受控体验终于稳定了。7.5 小团队工具最容易忽略的容量治理最后说一个许多人做个人项目时完全忽略的点多语言列表数量和存储周期。t3code 目前支持 12 种常见语言从 TypeScript 到 Kotlin。语言选择器如果用原生 select 会特别丑也不方便搜索我用的是自绘下拉加模糊过滤。存储上代码内容作为纯 Text 存在 PG 里其实比预想中便宜但需要注意大字段会影响查询性能。所以列表页查询时只 select 标题、语言、标签这些轻量字段详情页才拉 content避免列表接口一次性把十 KB 的数据全部传回来。这个优化在数据量小的时候没什么感觉一旦团队成员开始贴长配置、长日志差别会非常明显。8. 一点后续的想法t3code 还可以怎样生长代码片段管理这个领域看起来小但实际上和团队协作、开发者效率工具之间的关系千丝万缕。t3code 目前完成了核心主链路下一步我打算做几件不大不小的事一是支持从本地文件批量导入省去手工逐条粘贴的体力活二是支持片段之间的引用关系当一段代码经常被多段引用时可以提示这段代码被改动了哪些调用方可能需要关注三是把搜索从模糊匹配升级到语义理解至少做同义词和错误拼写容忍四是接入 AI 生成描述和标签的辅助能力减少用户整理元数据的成本。这些方向每个都不小我会按使用反馈排序而不是一口气全做出来。我在实际开发这个项目时最真实的感受是T3 Stack 让一个人做完一个全栈应用这件事的门槛降到了前所未有的程度。你不用写 controller、不用定义 DTO、不用手动同步前后端类型专职于业务逻辑和数据模型本身。那种打开 IDE改 schema改一个 query前端立刻获得类型提示的体验一旦你真的经历过就很难再回到分两层维护接口的时代。t3code 作为一个个人项目它最大的收获可能不是代码本身而是让我重新思考了工具类产品的边界和取舍。任何工具都该有锋利的一面而不是试图覆盖一切。代码片段平台的核心价值始终是让一段已经写好的代码在下一次需要时以最快的速度重新出现在你眼前。