ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

模板驱动的全栈脚手架:t3code 命令行工具设计与实现

模板驱动的全栈脚手架: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.json,src/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.tpl

3. 关键实现细节:从零写出第一个可用的 t3code

3.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),流程如下:

  1. 读取template.json,获取排除文件列表(比如template.json本身不拷进目标项目)。
  2. 递归遍历模板目录。
  3. 对.tpl文件:读取内容,经过解析器渲染,写入目标路径(去掉.tpl后缀)。
  4. 对静态文件:直接拷贝。
  5. 每次写文件前检查目标是否存在,存在则询问是否覆盖。

文件权限位这个细节值得一提。模板里如果有一个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 t3code@latest 升级,否则模板中的部分语法可能无法正常渲染。 是否仍然继续?(y/N)

把"建议"和"后果"都写在提示里,用户做决定就有依据了。

5. 让 t3code 更好用的进阶思路

5.1 模板仓库化与远程加载

单个模板目录用起来方便,但团队协作不够。我后来给 t3code 加了一个template source的概念:模板可以放在一个 Git 仓库的templates/目录下,本地维护一份索引,执行 init 时先拉取或更新。这样团队成员之间共享模板,就不需要互相传文件了。实现上其实很简单,就是git clone到本地的缓存目录,然后把模板解析路径指向那里。

t3code source add team-templates git@github.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 行脚本,慢慢长成一个五脏俱全的小工具。回头看看,最值得的并不是代码本身,而是那一套"模板优先"的思路。不过话说回来,工具终究只是工具,真正让它起作用的,是你愿意花时间把团队的约定沉淀成模板。如果你也被反复搭架子折磨过,强烈建议照着这个思路做一个自己的版本,哪怕只做给自己用——你会发现,把重复劳动交给程序的感觉,是真的会上瘾的。

返回列表