模板驱动的全栈脚手架:t3code 命令行工具设计与实现
做全栈开发的时间越长我越发现一个反直觉的事实真正消耗时间的不一定是复杂的业务逻辑反而是项目最初那二十分钟的脚手架搭建。每接到一个新需求我都要重复同样一套动作——拉 Next.js 项目、配置 TypeScript、装 Tailwind、接 tRPC、初始化 Prisma然后把环境变量、ESLint 规则、提交规范一个一个补上。这套流程我走了不下几十遍每次都觉得是在浪费时间。后来我参考 T3 Stack 的思路结合社区里各种脚手架工具的设计动手写了一个叫 t3code 的命令行工具专门解决从零开一个符合团队规范的项目这件事。t3code 本质上是一个模板驱动的项目生成器你准备模板它负责问答、渲染、落盘。比起 create-t3-app 这种官方全家桶t3code 更在意模板的自由度和答案的可复用性。这篇文章我会完整讲一遍它的设计思路、核心实现、踩过的坑以及我后来加上的一些进阶功能。不管你是想写自己的脚手架还是只是想了解一个 CLI 工具背后的细节相信都能找到有用的东西。1. 为什么会有 t3code从重复劳动到一键生成1.1 现有工具的两类困境在动手写之前我认真对比过市面上的方案。第一类是官方初始化工具比如create-next-app。它能生成一个能跑的基础项目但只覆盖单个框架tRPC、Prisma、鉴权这类全栈要素全部要自己手动补。第二类是全家桶式脚手架比如 create-t3-app。它的模板质量高、交互体验好可问题在于模板是固定的——团队如果对目录结构、代码风格、数据库选型有自己的约定就只能 fork 一份出来之后上游更新还得手动合并维护成本不低。我当时的处境正好卡在这两类工具之间既要快速生成全栈项目又要能随时调整模板细节还要保证新人和我生成出来的项目结构完全一致。t3code 就是冲着这个需求去的。1.2 三个设计原则t3code 从第一天起定了三条原则后来所有功能都是围绕它们展开的本地优先模板放在本地目录或者 Git 仓库里不依赖远程 CDN断网也能用。这对经常在客户现场或者内网环境工作的人特别重要。模板驱动生成逻辑只认模板目录模板里有什么文件、什么占位符生成出来就是什么。工具本身没有硬编码的文件清单加模板不需要改代码。答案可复用每轮问答的结果会保存成一份配置下次可以一键复用或者只改几个参数再跑一次不用从头答一遍。这三条原则听起来简单但真正做起来会发现很多细节要处理。比如模板驱动就意味着模板解析引擎要有足够的表达能力答案可复用则要求配置的序列化格式足够稳定要兼容后续新增的字段。1.3 一张对比表看清楚定位拿 t3code 和几个主流工具做对比定位会更直观能力create-next-appcreate-t3-appt3code覆盖全栈要素tRPC/Prisma否是取决于模板模板可自定义否低需 fork高即插即用离线可用是是是问答结果可复用否否是团队统一规范弱中强这张表不是拉踩而是想说明t3code 的定位不是替代 create-t3-app而是补它够不到的角落。create-t3-app 解决从零到能跑t3code 解决从零到符合我们的规范。2. 核心架构模板、问答与类型安全的三层设计2.1 五个模块的分工t3code 代码层面分成五个模块CLI 入口、Prompt 问答模块、模板解析引擎、文件写入器、配置管理层。它们之间的依赖是单向的CLI 入口只负责解析参数和调用流程Prompt 模块产出TemplateContext对象模板解析引擎把TemplateContext和模板文件结合产出渲染后的文本文件写入器负责计算目标路径、处理冲突、写盘配置管理层负责把TemplateContext持久化到配置文件。这种单向依赖的好处是每个模块都可以单独测试。我写单元测试的时候只需要 mock 掉 Prompt 模块直接传入一个固定的 context就能验证模板渲染逻辑。后来加功能的时候也轻松比如给文件写入器增加覆盖前备份的选项完全不碰其他模块。2.2 模板语言为什么自己写模板引擎这块我一开始考虑过 handlebars 和 ejs。它们功能强大社区成熟但有个问题模板的编写者往往是团队里的同学不是库的作者让他们学一套完整模板语法心智负担不小。t3code 模板真正需要的语法能力只有三种变量替换、条件块、简单循环。所以我写了一个不到三百行的小解析器只支持这三种语法。简单替换的语法是双大括号{ name: {{ projectName }}, scripts: { dev: next dev } }条件块用来处理选了 Prisma 才生成 Prisma 相关文件这类逻辑{{#if orm.prisma}} import { PrismaClient } from prisma/client; {{/if}}循环则用来生成路由列表这种重复结构{{#each api.routers}} export { default as {{this}} } from ./{{this}}; {{/each}}自己写解析器的另一个好处是错误信息可以做得非常友好。模板里如果引用了不存在的变量我不会报一个笼统的 template parse error而是直接定位到文件名和行号告诉模板作者你这里写了一个不存在的变量orm.prsi是不是拼错了。用现成模板引擎虽然方便但这种体验很难做到。2.3 类型安全的两层保障模板变量拼写错误是脚手架工具最噩梦的问题因为它运行时才暴露。t3code 用两层机制来规避第一层是强类型的TemplateContext。所有问答的结果必须符合这个接口定义一旦新增字段TypeScript 会在编译期强制所有模板作者补齐类型。interface TemplateContext { projectName: string; packageManager: npm | pnpm | yarn | bun; orm?: { type: prisma | drizzle; db?: postgres | mysql | sqlite; }; api?: { type: trpc | rest; routers: string[]; }; }第二层是模板预检命令t3code check。它会扫描模板目录里所有.tpl文件把出现的占位符 key 和TemplateContext的类型定义做静态比对输出类似这样的警告检查通过service 模板共发现 24 个变量引用2 个条件块1 个循环。 警告src/server/db/schema.prisma.tpl 第 8 行引用了未知变量 dbDatbase 如果这不是有意为之请检查是否拼写错误。有了这个预检流程模板作者在提交模板之前就能发现大部分低级错误而不是等用户生成失败后再去排查。这条命令后来成了我们团队模板评审的必跑项。2.4 模板描述文件与目录约定每个模板目录下有一个template.json用来声明模板的元信息和问答项。问答项是数据驱动的所以新增模板不需要改代码只要写 JSON{ name: service, description: Next.js 全栈服务模板, prompts: [ { key: orm.type, message: 选择 ORM, type: select, options: [prisma, drizzle], default: prisma }, { key: api.type, message: 选择 API 方案, type: select, options: [trpc, rest] } ] }模板里的静态文件直接放需要渲染的文件用.tpl后缀渲染后会去掉后缀。比如package.json.tpl渲染成package.jsonsrc/server/trpc/router.ts.tpl渲染成router.ts。后缀规则简单模板作者几乎不需要额外学习。目录结构大致长这样templates/ └── service/ ├── template.json ├── package.json.tpl ├── src/ │ ├── server/ │ │ ├── trpc/ │ │ │ ├── router.ts.tpl │ │ │ └── context.ts.tpl │ │ └── db/ │ │ └── schema.prisma.tpl │ └── app/ │ └── layout.tsx └── .env.example.tpl3. 关键实现细节从零写出第一个可用的 t3code3.1 初始化与命令注册t3code 本身是一个 Node.js CLI 项目用 pnpm 管理TypeScript 编译为 ESM。入口文件用 commander 注册三个命令init、check、list。#!/usr/bin/env node import { Command } from commander; const program new Command(); program .name(t3code) .description(模板驱动的全栈项目生成器) .version(0.1.0); program .command(init) .description(根据模板生成新项目) .argument(template, 模板名称) .option(-n, --name name, 项目名称) .option(-c, --config path, 复用已有配置) .action(initAction); program .command(check) .description(预检模板变量) .argument(template, 模板名称) .action(checkAction); program .command(list) .description(列出所有可用模板) .action(listAction); program.parse(process.argv);list命令看着简单实际很常用。它读取模板仓库根目录下所有template.json把名称、描述、最低版本要求列成一张表输出。这样即使用户不记得模板名也能先看一眼再决定。3.2 交互问答把答案变成上下文init命令的核心逻辑是先读取template.json按顺序执行 prompts把答案合并成一个 context 对象。clack 的 API 是 Promise 风格配合 for...of 循环写起来非常顺import { select, text, confirm, isCancel } from clack/prompts; async function collectAnswers(templateConfig) { const context: TemplateContext { projectName: my-project, }; for (const prompt of templateConfig.prompts) { const result await runPrompt(prompt); if (isCancel(result)) { process.exit(1); } setByPath(context, prompt.key, result); } return context; }setByPath是一个小工具函数把orm.type这样的路径展开成嵌套对象。问答顺序可以依赖前面的答案比如选了prisma才继续问数据库类型。这种顺序性用数组配置正好能表达而且在 clack 里每个 prompt 还可以根据前面答案动态调整选项列表灵活性很高。3.3 模板渲染与落盘渲染阶段是逐文件处理的。我维护了一个内部函数renderTemplateDir(templateDir, targetDir, context)流程如下读取template.json获取排除文件列表比如template.json本身不拷进目标项目。递归遍历模板目录。对.tpl文件读取内容经过解析器渲染写入目标路径去掉.tpl后缀。对静态文件直接拷贝。每次写文件前检查目标是否存在存在则询问是否覆盖。文件权限位这个细节值得一提。模板里如果有一个scripts/deploy.sh它是可执行文件直接拷贝内容没问题但文件模式是 0644生成后还得chmod x。这个坑我一开始完全没想到后来同事用的时候才发现。现在模板解析引擎的 API 会返回一个GeneratedFile数组每个元素包含路径、内容和 mode写入器统一处理权限interface GeneratedFile { path: string; content: string; mode?: number; }3.4 生成后的收尾动作项目文件落盘之后t3code 默认还会做三件事在目标目录写入t3code.config.json记录本次使用的模板名、版本和 context方便后续升级或复现。检测当前环境可用的包管理器根据 lockfile 优先然后问用户是否需要立即安装依赖。如果选择是就用child_process.spawn调用npm install或pnpm install。如果模板里有 format 脚本生成完成后提示用户运行而不是自动跑——因为全自动安装已经很慢了再自动格式化会让用户等待更久。我试过自动执行体验反而不如给一句提示。这个提示但不自动的策略是在实际使用中总结出来的。命令行的反馈节奏很重要信息密度过高用户反而记不住。生成结束后我会打印一段简短的下一步指引像cd my-project npm run dev这种让用户不用翻文档就能继续。4. 实测踩坑记录模板渲染、依赖安装与跨平台4.1 占位符把 package.json 搞坏了第一个大坑是 JSON 文件里的占位符转义。我的模板里有一段{ scripts: { postinstall: prisma generate {{#if orm.prisma}} prisma db push{{/if}} } }问题在于条件块内外的空格会被原样保留渲染出来变成postinstall: prisma generate prisma db push双空格虽然不会让 JSON 解析失败但格式很难看。更严重的是如果条件为 false渲染结果是prisma generate后面跟一个空字符串尾部有尾随空格某些脚本工具会报错。解决办法是给条件块加一条规则条件块前后的空白如果是紧贴着的渲染时自动折叠。这个规则用正则实现测试覆盖了好几轮总算把 JSON 渲染问题彻底解决了。后来我又加了第二个规则模板里任何{{...}}结构渲染后都会做一次 JSON 合法性校验如果模板的目标文件是 JSON 格式的话。相当于在写盘之前多了一道保险。4.2 Windows 上的路径分隔符第二个坑是路径处理。我最初用字符串拼接路径在 Windows 上生成出来的路径偶尔会变成src\server\trpc\router.ts但模板里是正斜杠混着用会导致部分工具找不到文件。后来统一改用node:path的join和relative并且约定所有模板内部一律使用正斜杠写入器在落到文件系统时由 Node 自身处理分隔符。这个方案既简单又可靠再没出现过路径问题。这个坑也提醒我CLI 工具一定要在 CI 里跑一次 Windows 环境的测试。GitHub Actions 上配一个windows-latest的 job 成本很低但能拦住一大批只有在特定平台才出现的问题。4.3 ESM 迁移带来的连锁反应第三个坑是 ESM 迁移。项目最初用 CommonJS 写一切正常。后来想用import语法和顶层 await迁移到 ESM 后发现__dirname不见了require也不可用了。模板加载逻辑里require(template.json)全都要改成readFileSync JSON.parse。如果你也打算写一个 Node CLI建议一开始就用 ESM并显式声明type: module减少后期迁移成本。迁移过程中还有一个隐藏问题动态加载用户模板里的 JS 函数比如自定义校验器时ESM 下必须处理import()的异步性和绝对路径。如果你的工具允许模板带自定义逻辑这块建议用data:URL 或者把自定义逻辑统一收敛成一个eval受限的小沙箱避免引入安全风险。4.4 版本锁定与升级提醒第四个坑是模板和工具的版本匹配。模板多了之后很容易出现新模板用到老工具不支持的新语法的情况。我给每个模板加了一个minVersion字段t3code init执行前先校验工具版本低于要求就友好提示升级。这个机制后来救了我好几次因为我自己也记不清哪个模板是什么时候加的。{ name: service, minVersion: 0.4.0 }校验逻辑很简单就是把版本号拆成数字数组逐个比较。真正麻烦的是提示文案怎么写。太生硬用户会直接跳过太委婉又起不到提醒作用。我最终定稿的文案是模板 service 要求 t3code 版本不低于 0.4.0当前版本是 0.3.2。 建议先运行 npm i -g t3codelatest 升级否则模板中的部分语法可能无法正常渲染。 是否仍然继续(y/N)把建议和后果都写在提示里用户做决定就有依据了。5. 让 t3code 更好用的进阶思路5.1 模板仓库化与远程加载单个模板目录用起来方便但团队协作不够。我后来给 t3code 加了一个template source的概念模板可以放在一个 Git 仓库的templates/目录下本地维护一份索引执行 init 时先拉取或更新。这样团队成员之间共享模板就不需要互相传文件了。实现上其实很简单就是git clone到本地的缓存目录然后把模板解析路径指向那里。t3code source add team-templates gitgithub.com:our-org/templates.git t3code init service --source team-templates远程加载还要考虑一个问题模板仓库更新后用户本地的缓存可能过期。我设置了一个简单的策略——每次执行 init 前如果缓存目录存在就先git pull一次如果网络不通则回退到本地缓存并给出提示。这样离线环境也能工作符合最初本地优先的原则。5.2 自定义变量注入还有一个小功能值得提t3code init支持从环境变量或者命令行注入变量比如--owner team-xxx。这在 CI 环境里生成项目很实用不用人工问答。t3code init service --name ci-demo --owner team-xxx --no-install--no-install是给 CI 场景用的防止生成项目时卡在依赖安装上。CI 里通常有独立的安装步骤脚手架只需要负责渲染文件。我一开始没加这个选项结果流水线里总是超时后来加了之后整体速度提升明显。5.3 建立模板规范的几点建议最后聊聊模板本身的规范。我经过一段时间的迭代总结出几条经验模板文件尽量少用条件块能用几个独立模板拆分就用拆分。条件块太多模板会变得难以阅读review 的时候也很难判断逻辑对不对。所有占位符尽量集中在少数几个文件里比如package.json.tpl、配置文件而不是散落在每个源文件里。这样别人 review 模板时一眼能看清所有变量。每个模板必须带一个README.md说明这个模板适合什么场景、需要什么前置条件。没有说明的模板三个月后连作者自己都想不起来当时怎么设计的。问答项不要超过七个。超过七个用户会烦而且大多数问题其实都有默认值。把高频差异项作为问题其余用预设变量。我现在的使用习惯是把模板仓库放进团队 Git 组织新同学入职后用一条命令就能生成符合规范的项目自己开 demo 项目时也直接用 t3code省掉重复步骤。这个项目从最初的 200 行脚本慢慢长成一个五脏俱全的小工具。回头看看最值得的并不是代码本身而是那一套模板优先的思路。不过话说回来工具终究只是工具真正让它起作用的是你愿意花时间把团队的约定沉淀成模板。如果你也被反复搭架子折磨过强烈建议照着这个思路做一个自己的版本哪怕只做给自己用——你会发现把重复劳动交给程序的感觉是真的会上瘾的。