T3Code实战:用CLI脚手架把T3技术栈重复代码变成团队资产
如果你在一个技术团队里写过两三个完整的全栈项目,大概率会有同样一种体验:新项目开工的前两天,永远在搭登录、建API路由、配数据库、写一堆重复的 CRUD。T3Code 就是奔着这个痛点去的。它是一个命令行脚手架与代码片段管理工具,核心目标是把 TypeScript、Tailwind、tRPC、Prisma、Next.js 这套常被合称为 T3 生态的技术组合里那些高频代码结构,沉淀成一套可交互、可配置、可团队共享的模板体系。它解决的是从“项目能跑”到“项目能高效产出”之间那一段没人愿意管的空档。适合正在用 T3 相关技术栈赶项目的人,也适合所有想把团队重复代码做成内部工具的开发。这篇文章我会从设计思路、技术选型、核心实现到踩坑记录,完整复盘一遍。
1. 先弄清楚:T3Code 到底解决什么问题
1.1 那些不得不重复的代码到底长什么样
先说我自己。我们团队从 2023 年开始用 Next.js 加 Prisma 加 tRPC 这套组合做内部系统,做了三个中等规模的项目之后,我明显感觉到一个事:真正难的部分不在写页面,不在调样式,而在每一次新需求过来时,都要把同一套骨架代码再铺一遍。比如要加一个“用户管理”模块,光是 tRPC 路由就要写 publicProcedure、protectedProcedure 的 import,写 zod 入参校验,写 prisma 查询,再写到前端用 trpc.xxx.getAll.useQuery() 把数据接回来。这套东西第一次写是学习,第三次写就已经纯属体力活。
更糟的是,每个人写出来的还不太一样。有人喜欢把 router 文件按模型拆开,有人喜欢全部塞在一个 router 里;有人习惯把 zod schema 放单独文件,有人直接写在路由文件顶部。代码风格不一致带来的成本,比多做一遍还高。所以 T3Code 最初的真实定位,不是一个炫技的命令行框架,而是把项目里“99% 都会长成这样”的文件结构固定下来,用模板生成,用配置约束,让不同水平的同事最终交出来的是同一套风格。
我把这类重复代码分了几个类型:一是路由和接口定义,二是数据库模型的创建脚本,三是前端的列表页、详情页、表单页,四是工具函数和类型声明。前两类最值得模板化,因为结构特别稳定;后两类也可以做,但需要抽得够细。T3Code 的核心命令create就是针对这些场景设计的,通过一个交互界面问你要生成什么、模型名叫什么、要不要带分页,然后一次性把涉及到的文件全部吐出来。
1.2 为什么做一个 CLI 而不是再来一个可视化页面
决定做 T3Code 之前,我认真考虑过要不要做成 VS Code 插件或者一个 Web 端的代码生成器。最后全否掉了,原因很实际:团队的协作流程基本都在终端里,跑命令、跑 lint、跑测试,CLI 是和大家已有习惯衔接最自然的方式。VS Code 插件如果想要做到跨项目复用,需要处理一堆工作区配置和扩展激活的边界情况,开发和排障成本比 CLI 高一截;Web 端生成器更麻烦,涉及登录、权限、模板仓库托管,维护成本对于一个内部工具来说完全不划算。
CLI 的优势是模块化、容易测试、容易交给 CI 用。我们后来把t3code create直接接进了需求分支的脚本里,新起一个模块时根本不手动跑命令,而是由 CI 根据任务编号生成初始代码,负责人只需要在生成的代码上做增量开发。这个用法如果是 VS Code 插件就很难办到。所以工具的形态选择,往往取决于你要把它嵌进什么流程里。如果你只是给自己一个人用,做插件没问题;你要是想让整个团队和自动化流程都用,CLI 是最省力的一条路。
1.3 T3Code 的边界:它不替你写业务
还有一点必须说清楚:T3Code 不是代码生成 AI,也不是低代码平台。它的原则是“只生成骨架,不生成逻辑”。生成的 CRUD 里的过滤条件、权限规则、业务校验,都需要开发者自己补。刻意保持这个边界是有好处的:模板一旦想覆盖业务逻辑,就会变成无底洞,各类分支把模板文件撑得没法看;而把边界停在“结构和约定”这一层,模板就能长期稳定,团队里任何人改起来都敢下手。对使用者来说,拿到 T3Code 生成的代码之后,还有大量的活要干,但这些活是增量开发,而不是重复搭骨架。
2. 技术选型:怎么把一个命令行工具做得低调又可靠
2.1 交互层:Inquirer 还是手写参数
命令行工具最容易被忽视的部分是交互体验。第一版 T3Code 我偷懒,只写了一堆参数,比如t3code create user --with-prisma --with-zod,后来发现没人愿意记参数,用的时候老要去翻 help。后来换成交互式问答,体验才真正立起来。用的库是 Inquirer,现阶段 Node 生态里最稳的交互方案。之所以不选手写 readline,是因为要处理的细节太多了:选择列表的上下键、搜索过滤、校验失败重输,这些在 Inquirer 里都是现成的组件。
不过 Inquirer 有一个要注意的地方:@inquirer/prompts和旧的inquirer包 API 差异比较大,新项目建议直接用@inquirer/prompts,按需引入select、input、confirm这些方法,体积更小,类型也清晰。我们内部版本里还加了一个模式:如果启动时带上了完整参数,就跳过交互直接生成,方便 CI 调用。交互和参数并存的设计,是 CLI 工具能同时服务人和机器的关键。
2.2 模板引擎:选 Handlebars 的理由和边界
模板引擎的选型我基本没纠结,直接用了 Handlebars。理由是团队里大部分人都不陌生,语法简单到可以在半小时内学会,而且它支持自定义 helper,能把{{ modelName }}这种占位符经过 camelCase、pascalCase 之类的函数处理后再输出,这对生成代码非常重要。模型名字用户可能输入user_profile,生成的文件名需要user-profile,类名需要UserProfile,靠固定的占位符是完全做不到的,必须有 helper 配合。
用 Handlebars 要注意一个容易被咬的坑:默认情况下{{ }}会做 HTML 转义,生成代码时完全不需要这个行为,反而可能把<>或者&转掉。解决办法是编译时传noEscape: true,或者统一使用{{{ }}}三花括号。我建议前者,因为三花括号写起来容易漏。另外,不要把业务逻辑塞进模板的 if/else 里,Handlebars 的#if、#each够用,但分支一多模板就不可读了。我们约定模板里只允许出现变量替换和简单的#each,任何超过三行的逻辑都放到 helper 里。
2.3 构建与分发:esbuild 单文件的取舍
T3Code 自身是用 TypeScript 写的,发布时我用 esbuild 把它打成一个单文件 CLI。选择 esbuild 而不是 tsc,核心原因是产物管理简单:一个 bin 文件丢到 npm 包里,不需要 src 目录、dist 目录、依赖树跟着一起发布。另一个原因是启动速度,我们日常在 CI 里会反复调用,esbuild 单文件虽然也要被 Node 执行,但省掉了整个 node_modules 的解析成本,实测大概快了一倍多。
单文件打包也有代价:所有依赖都要打进去,体积会变大,而且如果有原生模块会非常麻烦。不过 T3Code 的依赖全是纯 JS 的,Inquirer 虽然包体积不小,但也只是让产物从几 MB 变成十几 MB,对命令行工具来说完全可以接受。当初我也对比过几种方案:
| 发布方案 | 产物形态 | 发布复杂度 | 启动速度 |
|---|---|---|---|
| 直接用 tsc 编译 | 多个 js 文件 + 依赖 | 需要随包带上 node_modules 依赖声明 | 较慢,运行时解析依赖多 |
| esbuild 单文件打包 | 单个 bin 文件 | 只需发布一个入口 | 快 |
| esbuild + packages=external | 单个入口 + 外部依赖 | 需要精确声明 dependencies | 中等 |
另外要注意 esbuild 默认不支持动态 require 某些 cjs 模块,如果遇到打包报错,可以试试加--platform=node和--packages=external,把部分依赖留在 node_modules 里,只打包自己的源码,这是一种折中方案。
3. 核心实现:模板渲染、变量注入与配置文件
3.1 命令体系和一个最小可跑的 create
T3Code 的命令体系一开始加起来很简单:init、create、add。init生成配置文件,create根据配置里的模板生成代码,add把当前目录下的一组文件注册成模板。核心还是create,它做的事情可以用一段伪代码说清楚:
import { program } from "commander"; import * as fg from "fast-glob"; import { render } from "handlebars"; import { loadConfig } from "./config"; program .command("create") .argument("<name>", "模板名") .option("-d, --data <json>", "以 JSON 字符串方式传入变量") .action(async (name, opts) => { const config = await loadConfig(); const tpl = config.templates.find((t) => t.name === name); if (!tpl) { console.error(`找不到模板: ${name}`); process.exit(1); } const answers = await collectAnswers(tpl.prompts, opts.data); const templateDir = path.join(config.templatesDir, tpl.dir); const sourceFiles = await fg.glob(`${templateDir}/**/*`, { dot: true }); for (const file of sourceFiles) { const rel = path.relative(templateDir, file).replaceAll(path.sep, "/"); const outputRel = renderString(rel, answers); const content = renderString(readFile(file, "utf8"), answers); const outputPath = path.join(config.outDir, outputRel); writeFileGuarded(outputPath, content, { force: opts.force }); } });这里面有两个细节很重要。第一,文件路径也是模板,{{ modelName }}既可以出现在文件内容里,也可以出现在文件名里,这样templates/api/{{ modelName }}.ts就能直接生成user.ts。第二,写入前要做存在性检查,避免默默覆盖已有文件。代码里readFile和renderString都是独立封装的,便于加编码处理和缓存。这段逻辑看起来简单,但它是整个工具的地基,后续加变量助手、加 dry-run、加临时文件原子替换,都是在它上面扩展。
3.2 模板变量与 Handlebars helper 的配合
变量从哪来?定义在配置文件的prompts字段里,每一个变量对应一道交互题。比如:
{ "name": "api-crud", "dir": "api-crud", "prompts": [ { "name": "modelName", "message": "模型名(如 user_profile)", "type": "input" }, { "name": "withPagination", "message": "需要分页吗?", "type": "confirm", "default": false } ] }用户回答之后,answers变成{ modelName: "user_profile", withPagination: true },后续渲染时传进去。模板里可以写:
export const {{pascalCase modelName}}Router = createTRPCRouter({ getAll: publicProcedure .query(async ({ ctx }) => ctx.prisma.{{camelCase modelName}}.findMany()), });为了让 pascalCase、camelCase、kebabCase 这些 helper 生效,我注册了一个标准 helper 集,实现都非常简单,比如 camelCase 就是把分隔符后面的字母转大写:
Handlebars.registerHelper("camelCase", (str: string) => str.replace(/[-_]+([a-z])/g, (_, c: string) => c.toUpperCase()) );这种方案比让用户自己去配置正则替换要友好得多。模板文件里用明确的函数式表达,谁来看都知道这个位置会发生什么变换。这里还有一个经验:helper 的命名要和团队日常口头叫法一致,否则模板写起来容易猜错。比如有人写toPascalCase,有人写pascalCase,如果两个都注册,反而造成一种“不知道哪个生效”的混乱。我最终只保留了一套简短命名,并在配置文档里列出来。
3.3 配置文件:让模板成为团队资产
T3Code 的配置文件叫t3code.config.json,放在项目根目录。常见结构如下:
{ "templatesDir": "./t3code/templates", "outDir": "./src", "templates": [ { "name": "api-crud", "alias": "api", "dir": "api-crud", "prompts": [] } ] }把模板目录、输出目录、模板清单统一放进配置文件,带来的直接好处是模板可以跟着 Git 仓库走。新人拉下代码,跑一条npx t3code init就完成本地初始化,再跑t3code create api,生成的代码结构和老成员完全一致。模板本身也纳入 code review,新增一个模板就是一次普通 PR,谁改的、改了什么逻辑,全都有记录。这一点比把一个写好的模板文件私下传来传去要正规得多,也是 T3Code 值得放进团队基建层面的原因。
配置文件按 JSON 设计是因为解析零依赖,但如果你的模板多到需要注释,可以支持改成t3code.config.mjs,用 JS 文件描述配置。我们内部现在用的是 mjs,因为模板清单里渐渐出现了条件组装、基于环境变量的动态路径,纯 JSON 已经完全不够用了。这个变化不用一开始就上,我建议先跑通 JSON,觉得痛了再升级。
4. 实操复盘:从零搭一套能用的 CRUD 生成器
4.1 安装与初始化三步走
假设你有一个现成的 Next.js + tRPC + Prisma 项目,想把 T3Code 加进去。第一步全局安装或者临时使用:npm i -g t3code或者干脆用npx t3code,不污染项目依赖。第二步在项目根目录跑t3code init,它会生成配置文件样板,并且默认创建t3code/templates目录。第三步把现有项目里一个你觉得比较标准的 API 路由文件复制到模板目录,把里面的模型名替换成{{modelName}},再在配置文件里注册一下,整个链路就算通了。
我第一次给团队搭的时候,速度比预想快很多。因为不需要理解什么抽象设计,只需要回答三个问题:模板放哪、生成到哪、有哪些模板。这三个问题恰恰是 T3Code 的配置文件在表达的全部内容。如果你之前没有模板概念,建议不要一步到位做复杂模板,先从最常用的一个场景开始,比如只做 tRPC 的 CRUD 路由,用一周之后再看要不要扩。
4.2 写一个 tRPC 路由模板并跑通
下面用一个最典型的场景演示完整过程。我们想生成一个“标准 CRUD 路由”,文件最终落在src/server/api/routers/{{modelName}}.ts。模板文件t3code/templates/api-crud/{{modelName}}.ts内容如下:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { prisma } from "~/server/db"; export const {{pascalCase modelName}}Router = createTRPCRouter({ getAll: publicProcedure.query(async () => { return prisma.{{camelCase modelName}}.findMany(); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { return prisma.{{camelCase modelName}}.findUnique({ where: { id: input.id } }); }), create: publicProcedure .input( z.object({ name: z.string().min(1), description: z.string().optional(), }) ) .mutation(async ({ input }) => { return prisma.{{camelCase modelName}}.create({ data: input }); }), });然后在配置里加一行:
{ "name": "api-crud", "alias": "api", "dir": "api-crud" }运行t3code create api,交互问模型名,输入user_profile,生成的src/server/api/routers/user_profile.ts内容里的{{pascalCase modelName}}会变成UserProfile,{{camelCase modelName}}会变成userProfile,prisma 表名则是user_profile。这个例子说明了一件事:同一个变量,在不同位置经过不同 helper 处理,完全不需要写多条 prompt。
4.3 自定义团队模板的写法要点
当你的团队准备把模板扩展成自己的资产时,有几个要点是实战中反复被验证过的。第一,模板目录里尽量用.hbs后缀,如果你把模板文件命名为user.ts,生成目标文件又是user.ts,在同一个目录下会冲突,所以建议统一用.hbs或者{{modelName}}.ts这种动态命名。第二,文件内容里如果出现了必须保留的花括号语法,可以用反斜杠转义,或者干脆把那一部分移进 helper,避免模板引擎误解析。第三,不要把所有东西都堆在一个模板里,宁可把大模板拆成多个小片段,用 partial 引入,这样单个文件好维护,团队里多人并行改模板也不会整天冲突。
举一个 partial 的例子。你可以建t3code/templates/partials/pagination.hbs,内容是一段分页参数与返回值定义,然后在api-crud的主模板里用{{> pagination}}引入。这样当分页逻辑改版,只需要改那一个 partial,所有引用了它的模板都会跟着更新。这个模式对模板维护的价值,比任何花哨功能都实在。
5. 常见问题与排查技巧实录
5.1 模板变量没被替换?三处最容易出错
我敢说 80% 的人第一次跑 T3Code 都遇到过“变量没替换”的问题。归纳下来基本是三种原因。第一种是模板文件名和文件内容里的占位符写法不一致,比如内容里写{{modelName}},文件名里写$modelName,然后错误地以为引擎会统一处理。第二种是忘了关掉 HTML 转义,导致&、<这些字符被转成实体,看起来像是代码坏了,其实只是被转义了。第三种是配置里的模板dir写错了,命令行工具没有找到模板目录,但实际上却抓到了一个空列表,看起来就像什么都没干。
排查思路也很简单:给 render 函数加上一个 debug 模式,把渲染前和渲染后的模板内容都打印出来,或者至少打印输出文件的绝对路径。我自己在开发时加了一个--dry-run参数,只打印将要生成哪些文件、内容前 20 行,不实际写入磁盘。这个小开关对排障价值极大,建议任何一个脚手架工具都留一个。
5.2 Windows 上的路径分隔符与 BOM 编码
项目里总有同事用 Windows。fast-glob 返回的路径是带/的,但path.relative出来的结果在某些 Node 版本下会出现\。如果生成的目标路径直接用这个字符串去 join,最终可能生成src/serverrouters/user.ts这种错误目录。解决方法是统一把分隔符转成/,再交给path.join。我在 3.1 的代码里特意写了replaceAll(path.sep, "/"),就是这个原因。
另一个 Windows 特有的坑是 UTF-8 BOM。用记事本或部分编辑器保存模板时会自动带上 BOM,读取文件后字符串最前面会多一个不可见字符,渲染结果第一行会出现奇怪的?或者黑点。处理办法是读取模板时把 BOM 剥掉,Node 生态里很多文本处理库都带 stripBOM 方法,没有的话也可以自己判断charCodeAt(0) === 0xfeff,简单可靠。为了省得每个文件都判断,我在readFile里做了统一封装,所有模板读取都走同一个入口。
5.3 文件覆盖与中断恢复策略
生成代码时最怕的就是写到一半程序崩了,留下一个半截文件。我在 T3Code 里采用的策略是“临时文件 + 原子替换”:先把内容写到目标文件同目录下的临时文件,全部模板都渲染成功之后,再统一用fs.rename覆盖到最终位置。这样做有两个好处:一是渲染失败时不会污染已有代码,二是如果中途用户 Ctrl+C,临时文件很容易识别和清理。
覆盖策略上,默认遇到已存在的文件会询问是否覆盖,追加--force参数可以跳过询问直接覆盖。对于团队在 CI 里的场景,建议配置"overwrite": false,强制让 CI 在冲突时报错而不是静默覆盖,避免把别人正在改的代码冲掉。这类机制看起来不起眼,但真实团队协作里,十个合作冲突里有八个都出在这种小地方。
最后说一点个人体会。做 T3Code 这个项目,我收获最大的不是“我有了一堆模板”,而是逐渐建立了一种习惯:任何代码只要我写过第二遍,就会停下来想,它有没有可能被第三人用标准化的方式复用。很多工具链的诞生不是因为技术多前沿,而是某个团队实在受不了重复劳动了。T3Code 本身的技术含量并不高,它更像一套约定和流程的容器,但恰恰是这种容器,让团队里几十个人写出来的接口长一个样。如果你也想搞一个类似的工具,不要急着把模板做全面,先挑一个每周都会重复的场景,跑通了再慢慢往里加。工具是被用出来的,不是被设计出来的。