1. 为什么会冒出 t3code 这个项目最近把我的一个攒了挺久的开源工具整理了出来名字叫 t3code。这东西说复杂也不复杂一个面向 TypeScript / Next.js 开发者的代码片段管理 CLI内置了 T3 Stack 常用模板也支持完全自定义的片段。以前我每次开新项目都要手动复制一大堆配置代码tsconfig.json的严格模式配置、tailwind.config.js的内容安全列表、tRPC router 的初始化脚手架、Prisma client 的单例封装诸如此类。复制得多了就难免出错漏一个字段、改错一个缩进都是常有的事。后来我实在觉得烦干脆自己写了个命令行工具把这些重复劳动全部收编。这篇文章就把这个项目的来龙去脉、核心设计、实现细节和踩坑过程都摊开讲讲写给同样被重复样板代码困扰的人也给想动手写 CLI 工具的读者一个参考。1.1 每天重复粘贴样板代码我受够了我团队里以前有个不成文的规矩新项目一律从某个「黄金模板」仓库复制。这个模板仓库维护得还算勤快但问题也很明显。第一复制之后要全局搜索替换项目名一个不小心就漏掉.env.example里的变量名。第二模板仓库是单体仓库里面混杂了 API 服务、后台管理、内部工具等多个场景实际用的时候往往只需要其中一部分但没人愿意花时间拆。第三模板更新之后老项目不会自动同步过两个月回头看每个项目的依赖版本、配置写法都有细微差异维护成本直线上升。我最初的想法很简单能不能有一个工具让我在终端里输入一行命令就把某个文件或者某一组文件精确地生成到当前项目里我不需要它像create-next-app那样从零搭建整个工程因为那解决的是「冷启动」问题我面对的问题更细碎比如给老项目补一个eslint配置、加一个trpcrouter 文件、初始化vitest环境这些场景下整包脚手架太重了。我需要的是「点到点」的模板注入能力。t3code 就是围绕这个需求设计的。它不是一个全量脚手架而是一个模板片段管理工具。你可以把它想成「命令行版的多功能粘贴板」每个粘贴内容都是一个可复用、可共享、可版本化的模板文件。1.2 t3code 到底管什么先明确边界t3code 不负责创建项目不负责安装依赖不负责启动开发服务器。它只做三件事把预先定义好的模板文件渲染到指定目录用一个统一的本地索引来管理所有模板支持从远程 Git 仓库同步模板让团队共享成为可能。具体到使用场景它解决的是这三类问题新项目初始化时快速生成一组基础配置比如tsconfig.json、.eslintrc.cjs、prettier.config.js已有项目开发过程中按需添加某个功能模块的样板代码比如一个标准的 tRPC router、一个 Prisma service、一个 React hook团队内部沉淀最佳实践把「应该这么写」的规范代码固化成模板通过t3code sync拉取到本地避免每个人自己复制粘贴造成漂移。这个定位一开始可能让人觉得不够劲爆但实际用下来是真的省时间。脚手架工具负责「从 0 到 1」t3code 负责「从 1 到 100」过程中所有零散的、重复的代码落地场景。1.3 为什么叫 t3code而不是别的名字名字里的 t3 来源于 T3 Stack。T3 Stack 是 Next.js、tRPC、Tailwind CSS、TypeScript 这四个东西的组合社区里很流行我日常工作流也基本围绕这套技术栈。t3code 最初就是专门为 T3 Stack 场景写的生成 tRPC router、生成 Prisma client、生成 Next.js 页面模板。后来我逐渐发现模板机制完全可以和具体框架解耦语言也不该局限在 TypeScript。所以现在 t3code 的模板引擎是语言无关的你完全可以用它管 Java、Go、Python 的代码片段。但名字保留了因为 T3 生态是它的出发点也是我最有心得的领域。如果你完全不用 T3 Stackt3code 依然能当一款通用片段管理工具用只是默认模板里有一部分你用不上而已。加上code后缀是想强调它处理的是代码不是笔记、不是文档、不是环境配置就是实打实的源码文件。2. t3code 的核心设计与技术选型工具类的项目技术选型比功能列表更重要。功能可以上线后慢慢加但底层设计一旦走偏后面每加一个功能都要付出额外成本。我在做核心设计时花了大量时间考虑这些基础问题。2.1 用 TypeScript 写 CLI不只是为了优雅t3code 本身是一个 npm 包是用 TypeScript 写的运行时依赖 Node.js 18 以上。选 TypeScript 的原因很直白它本来就是为 TypeScript 生态服务的解析tsconfig.json、生成.tsx文件这些场景用 TS 写最顺手类型定义可以直接复用。有人会问CLI 工具为什么不用 Go 或 Rust 编译成单个二进制文件分发更方便我承认这是更好的分发方案但对于这个项目来说选择 Node.js TypeScript 有以下几点实际考量目标用户本身就是前端/全栈开发者本地一定有 Node.js 环境npm i -g是最没有心智负担的安装方式模板渲染、配置读取、Git 操作这些功能Node 的生态里都有成熟方案不需要自己造轮子工具内部需要和开发者本地的tsconfig.json、package.json交互用 TypeScript 处理这些 JSON 文件不需要写额外的解析代码。性能上CLI 工具的瓶颈从来不在运行时而在于 IO 和终端渲染。加载模板、替换占位符、复制文件这些操作用 Node.js 做都是毫秒级完全不用考虑性能问题。2.2 命令体系一个工具怎么做到「少而够用」我见过太多工具死于疯狂堆命令。一个简单的生成器非要整出generate、create、scaffold、new四个同义命令参数列表长得像多国语言词典。t3code 从一开始就定下规矩只保留五种操作每种操作只有一个名字。命令动作典型使用场景t3code init初始化一个新的项目骨架用内置的 next-trpc 模板创建一个新目录t3code add向当前项目注入一个或多个模板给已有项目补 Prisma client、加 tRPC routert3code list查看本地可用的模板看看有哪些片段可以用t3code sync从远端同步模板库拉取团队维护的模板更新t3code export导出 VS Code 可用的 snippets 文件把模板变成编辑器补全片段每个命令都遵循同样的参数风格必选参数放位置参数可选参数用--标志交互式提示负责收尾。比如t3code init my-app --template next-trpc --package-manager npm如果漏掉了--template工具会进入交互式列表让你选而不是直接报错退出。这种设计会让第一次用的用户非常舒服同时资深用户可以用标志参数跳过交互。2.3 代码片段存储格式JSON 之外的另一种答案很多代码片段工具把片段存在一个大 JSON 文件里每个片段是一个字符串。这种方案对于短小的单行代码还好但对于多行、带缩进的组件代码写进 JSON 字符串是一场灾难转义引号、处理换行改一个空格都可能破坏整个结构。t3code 采用的方案是「目录即模板」。一个模板在本地就是一个文件夹里面是真实的代码文件外加一个meta.json。例如内置的trpc-router模板长这样.t3code/ templates/ trpc-router/ meta.json trpc/ router.ts router.test.tsmeta.json描述模板的元信息{ name: trpc-router, description: 生成一个带有公共流程的 tRPC router, variables: [ { name: routerName, prompt: 请输入 router 名称 } ], output: { trpc/router.ts: src/server/trpc/{{routerName}}.ts, trpc/router.test.ts: src/server/trpc/__tests__/{{routerName}}.test.ts } }output字段定义了「模板文件夹里的文件」要输出到「项目里的什么路径」{{routerName}}是占位符用户输入后会被替换。这样做的好处非常多代码文件按原样保存缩进、引号、换行不会被 JSON 转义破坏模板可以做版本管理直接放进 Git变量替换只在meta.json指定的文件名和文件内容里进行不会误伤代码里的正则表达式或字符串支持一个模板往多个位置输出文件比简单复制粘贴灵活得多。3. 从零实现 t3code关键模块拆解这一部分我讲讲 t3code 的实际代码结构。虽然工具逻辑不复杂但有几个模块的设计经过了好几轮迭代值得单独拿出来说。3.1 初始化项目与依赖选择我在写第一个版本的时候犯了个错什么都想自己写参数解析手写、交互提示用原生readline、模板渲染自己拆字符串。折腾了三天做了个勉强能用的东西但代码又乱又难维护。后来痛定思痛引入了两个非常成熟的依赖commander负责参数解析和子命令分发prompts负责交互式问答。选prompts而不是inquirer是因为prompts的体积更小API 更现代化对 ESM 的支持更好而且它的交互列表在 Windows 终端下相对稳定。这一点我在后面的踩坑部分会细说。package.json里最关键的是bin字段{ name: t3code, version: 0.3.0, bin: { t3code: ./dist/cli.js }, type: module, engines: { node: 18.0.0 }, scripts: { build: tsc -p tsconfig.json, dev: tsx src/cli.ts }, dependencies: { commander: ^11.1.0, prompts: ^2.4.2 } }入口文件cli.ts的结构非常简单#!/usr/bin/env node import { Command } from commander; import { init } from ./commands/init.js; import { add } from ./commands/add.js; const program new Command(); program.name(t3code).description(代码模板片段管理工具).version(0.3.0); program .command(init) .argument(name, 项目名称) .option(--template template, 使用的模板名称) .action(init); program .command(add) .argument(template, 要添加的模板名称) .option(--force, 覆盖已存在的文件) .action(add); program.parseAsync();这里有个细节action 回调里的init函数需要返回 Promise这样program.parseAsync才能正确处理异步流程。如果你用parse()异步操作会提前退出进程控制台还没输出完就被强制终止了。3.2 模板渲染引擎字符串拼接是最容易翻车的地方模板的本质是「用变量替换文件中的占位符」。这一步听起来简单真正实现时却容易在细节上翻车。我最初用简单的replaceAll来处理变量替换结果模板里如果包含$符号就会导致替换值里的特殊字符被正则引擎解析别人写的$nextTick就会变成奇怪的引用。正确的做法是始终用正则表达式配合回调函数不要直接传字符串给replacefunction renderContent(content: string, variables: Recordstring, string): string { return content.replace(/\{\{\s*(\w)\s*\}\}/g, (match, key: string) { if (Object.prototype.hasOwnProperty.call(variables, key)) { return variables[key]; } return match; }); }这个函数有几个好处占位符语法固定为{{ name }}前后允许多余空格键名必须是字母或下划线开头如果变量不存在保留原占位符避免静默消音导致文件出现空字符串使用Object.prototype.hasOwnProperty而不是直接variables[key] || 这样变量值如果是空字符串或者0也能正常替换。文件名里的占位符处理思路相同但要额外注意路径分隔符。生成文件路径时我用path.join拼接目标目录并把模板源文件的相对路径转换成目标路径在 Windows 上必须统一使用/作为模板元数据里的路径分隔符。3.3 配置加载与交互式命令行的实现t3code 在用户主目录下维护一个全局配置~/.config/t3code/config.json里面保存模板仓库的位置、默认选项等信息。此外每个项目里可以有一个.t3code/config.json用于项目级模板配置。加载配置的代码要处理两个场景用户还没有配置文件以及配置文件 JSON 解析失败。我会做一层兜底import { readFileSync, mkdirSync } from node:fs; import { homedir } from node:os; import path from node:path; const GLOBAL_CONFIG_DIR path.join(homedir(), .config, t3code); const GLOBAL_CONFIG_PATH path.join(GLOBAL_CONFIG_DIR, config.json); function loadGlobalConfig(): Recordstring, unknown { try { return JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, utf-8)); } catch { mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }); const defaultConfig { templateRepos: [] }; writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(defaultConfig, null, 2), utf-8); return defaultConfig; } }交互式命令行的逻辑不复杂核心原则是「能用默认值的就不问必须问的给默认提示」。比如init命令里如果用户在命令行已经给了--template就不再弹出选择列表如果没有给才列出现有模板让用户选择。这里我借鉴了设计命令行工具时的一个经验每次交互都是有成本的能少问一个问题就少问一个问题。3.4 片段缓存与本地索引模板多了之后t3code list需要快速展示所有可用模板。每次递归扫描目录虽然也能用但在大型模板库会上百个文件时响应会变慢。我的方案是生成一个本地索引文件.t3code/index.json在每次sync或者模板变更后自动更新。索引的结构非常简单{ version: 1, updatedAt: 2024-06-18T12:00:00Z, templates: [ { name: trpc-router, description: 生成一个带有公共流程的 tRPC router, path: templates/trpc-router } ] }加载模板时优先读索引命中后直接定位目录索引缺失时再走全量扫描。这样实现起来很快而且索引文件本身是文本可以直接提交到 Git 里团队成员不需要各自重建索引。4. 安装与日常使用实录前面写了太多设计接接地气我实际演示一遍 t3code 的安装和使用过程你照着敲就能跑通。4.1 全局安装与第一个命令安装 t3code 只需要一个命令npm install -g t3code安装完成后先跑一下帮助命令确认环境正常t3code --help如果看到版本号和命令列表说明一切正常。现在试试最基础的能力查看本地已经有哪些模板。t3code list刚安装完时本地只有内置的几个模板输出大概是这样的trpc-router 生成本地 tRPC router 脚手架 prisma-client 生成 Prisma client 单例及导出 next-page 生成 Next.js 页面组件含 getServerSideProps 模板 tailwind-config 生成 Tailwind CSS 配置文件含安全列表 eslint-config 生成 ESLint 扁平化配置这些模板都是 T3 Stack 项目里最常见的重复文件。4.2 拉取一套 Next.js tRPC 的初始代码假设我要创建一个新项目使用的是 Next.js tRPC 这套组合。我不先跑create-next-app因为那会生成一个庞大的基础应用里面很多样板代码我根本用不到。我的习惯是手动mkdir my-app cd my-app初始化package.json用 t3code 把需要的配置文件和模块代码放进去。比如我需要一个基础的 tRPC routert3code add trpc-router --routerName health这条命令会在当前目录下生成src/server/trpc/health.ts和对应的测试文件。如果当前目录已经存在同名文件t3code 会询问是否覆盖如果加了--force则直接覆盖不提示。生成的文件内容大概是这样的import { router } from ../trpc; import { z } from zod; export const healthRouter router({ check: publicProcedure .input(z.object({ name: z.string().optional() })) .query(({ input }) { return { status: ok, message: hello ${input.name ?? world}, }; }), });如果你想要的是一整套初始化文件可以直接用initt3code init my-app --template next-trpc它会创建一个新目录my-app然后把next-trpc模板定义的所有文件都生成进去包括基础的配置文件和目录结构。4.3 让自己的片段进库add 命令的完整流程内置模板永远不够用真正好的工具是能让你把自己的代码沉淀成模板。t3code 的add命令既负责「把模板加到项目里」也负责「把项目里的文件提取成模板」。具体用法是先手动创建一个模板目录例如~/.config/t3code/templates/my-hook里面放一个meta.json和代码文件。然后运行t3code add my-hook --from ~/.config/t3code/templates/my-hook--from指定的是模板目录位置。如果不给--from命令会进入交互模式逐个问你模板文件放在哪、输出路径怎么映射。这个过程对于初学者来说有点繁琐所以我后来加了一个更人性化的快捷方式t3code add my-hook --with src/hooks/useDebounce.ts这个命令把src/hooks/useDebounce.ts提取成模板元数据中的输出路径默认与源路径一致变量列表留空后续你可以手动编辑meta.json补充变量和说明。4.4 和 VS Code 配合快速插入片段命令行工具适合成块地生成文件但你在写代码时有时候只想要一小段代码补全而不是完整文件。t3code 的export命令可以把所有本地模板转换成 VS Code 的 snippet 文件t3code export --format vscode --output .vscode/t3code.code-snippets生成的文件可以直接被 VS Code 识别。比如一个模板文件里如果包含了一段带占位符的组件代码导出后你在编辑器里输入t3code-hook就会弹出对应的代码补全。这对于把团队最佳实践「内置」到编辑器里特别有用。我实际使用时的操作路径是先集中花半小时把常用代码整理成模板提交到团队的模板仓库然后每个人t3code sync拉到本地再用export生成编辑器补全。这样不管在终端还是编辑器写代码的效率都会提升一大截。5. 开发过程中的坑与解法工具虽小五脏俱全。t3code 从第一个能用的版本到现在经历了不少奇怪的 bug。挑几个印象深刻的讲讲给想写 CLI 的人提个醒。5.1 模板字符串里的反引号和换行一次预处理引发的 Bugt3code 内部处理模板文件时我最初使用 JavaScript 模板字符串来组织代码比如生成prisma.ts时直接写const template import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient }; export const prisma globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma prisma; ;一开始没问题直到我在某个模板里放了一段包含反引号字符的代码比如正则表达式new RegExp(\^${String.raw})。整个模板字符串直接语法错误编译都过不去。解决方案是放弃「在 JS 源码里内嵌模板内容」的做法把所有模板都移到独立的.t3code/templates目录用文件系统读取。这样模板代码就只是普通文本文件不会和 JS 的语法产生任何冲突。这个改动也顺带实现了「目录即模板」的设计算是因祸得福。5.2 交互式列表在 Windows 终端下的显示错乱用prompts库包出的多选列表在 macOS 和 Linux 下都表现良好但到了 Windows 上偶尔会显示错乱每行文字前面多出一个奇怪的符号或者选择项重叠。这个问题排查了很久最后发现是prompts默认使用 ANSI 转义序列做光标移动而 Windows 的默认终端尤其是老的 conhost对这些序列支持不完整。解决办法不是修复终端而是主动适配检测到process.platform win32时把交互模式降级为普通的input输入方式或者在初始化时设置prompts.override来禁用高级特性。简单但不失优雅牺牲一点交互美观换来跨平台稳定值得。5.3 文件覆盖策略没有确认就删除差点酿成事故早期版本的add命令在输出文件时如果目标文件已存在会直接覆盖。我原本认为这是 CLI 工具的正常行为毕竟用户主动运行命令应该清楚自己在做什么。但有一次我在一个项目根目录下运行t3code add prisma-client因为meta.json里的输出路径配置有误直接覆盖了项目里一个已经写好的src/lib/prisma.ts用户的不少自定义代码瞬间没了。从那以后我定了两条铁律默认情况下目标文件存在时必须交互式确认只有显式加--force才允许覆盖覆盖前写一个临时文件写入成功后再rename替换原文件避免写入中断导致文件半截损坏。这个改动让 t3code 的安全性上了一个台阶也让我意识到CLI 工具越方便越要防手滑。5.4 错误处理与退出码设计小工具的容错最容易被忽略。一开始我的错误处理很随意所有异常都交给 Node 默认的uncaughtException结果经常出现「报错信息是一大段堆栈用户根本看不出错在哪」的情况。后来我加了一个统一的错误处理层try { await runCommand(...); } catch (error) { if (error instanceof CliError) { console.error(✖ ${error.message}); process.exit(error.exitCode ?? 1); } console.error(发生未知错误请查看日志文件); process.exit(2); }我自定义了CliError所有业务上的失败模板不存在、配置格式错误、网络请求失败都抛出带明确信息的CliError。只有真正的意外异常才会走到未知错误分支。这样做之后用户体验好了很多我自己调试定位问题也快了不少看到第一条错误信息就知道是配置错了还是模板目录不对。6. 后续扩展与个人体会工具做到目前这个程度对我来说已经「够用」了。但我不打算就此止步后续还有几个明确的方向在计划里。6.1 团队同步把片段库放到 Git 上t3code 的sync命令目前支持从 Git 仓库同步模板。用法是t3code sync --repo https://github.com/your-team/t3code-templates.git这个命令会把远端仓库克隆到本地缓存目录然后合并进本地的模板库。如果本地也有同名模板默认以远端仓库为准。团队里任何人更新了模板提交到 Git其他人下次sync就能拿到。这比通过聊天工具发文件、让人手动替换要可靠太多。我建议每个团队都建一个这样的「模板仓库」把 React Hook、工具函数、配置模板、代码规范片段全部丢进去。时间长了这个仓库就是团队工程实践的活文档新成员入职后跑一次sync就能立刻拥有和团队一致的代码基础。6.2 与常用脚手架对比t3code 的生态位有人会问create-next-app、plop、hygen这些工具已经很成熟了t3code 还有什么存在的必要我的看法是它们解决的问题不一样。create-next-app是应用生成器一次性生成完整项目骨架但灵活性低定制化成本高plop和hygen是「文件生成器」非常适合组件级别的「按需创建」但配置门槛偏高需要写 generator 模板t3code 则定位在两者之间不生成整个应用也不强制你定义一套复杂的 generator 语法只需要准备好普通代码文件再写一个简单的meta.json。如果你只是偶尔创建一两个文件用 t3code 可能觉得有点大材小用。但如果你维护了十几个项目、几十个常用片段又希望这些片段能在团队里标准化流转t3code 的价值就会非常明显。6.3 我个人坚持的三条设计原则项目写到现在虽然还在不断加功能但有三条原则我会一直坚持下去也建议你在设计类似工具时参考。第一模板与逻辑分离。模板代码永远放在文件系统里不内嵌到 JS 源码中。这样模板本身可以被任何支持文本编辑的同事维护而不需要了解工具的实现细节。第二命令要少参数要准。每多一个命令都是增加用户的学习成本。t3code 目前只有五个命令每个命令只解决一种需求。如果发现某个命令的职责开始变得模糊我会考虑拆分而不是加参数。第三错误信息必须说人话。工具可以在功能上有所欠缺但绝不能让用户看到一堆堆栈后手足无措。把所有已知异常变成清晰易懂的中文提示是我一直坚持的底线。最后说一个自己使用中的小技巧我会在全局而且不仅是项目级维护一份模板库把所有和具体项目无关的通用片段都放进去比如「生成一个防抖 Hook」「生成一个 API 错误处理工具函数」。只要是在多个项目里重复写过两次以上的代码我就会花五分钟把它转成 t3code 模板。这五分钟长期来看能帮我省下几十个小时。如果你也有类似的重复劳动不妨试试用 t3code 把它们沉淀下来。
阅读完成 · 觉得有帮助?