说实话第一次看到 t3code 这个词的时候我脑子里最先蹦出来的其实是“T3 Stack”——TypeScript、Tailwind、tRPC 那一套前端圈里挺流行的技术组合。再往下想做脚手架的、写代码生成器的、或者就是单纯给团队内部封了一套 CLI 工具的朋友大概率都绕不开“代码复用”和“项目初始化规范化”这两件事。t3code 这个名字没有太多官方资料更像是一个社区里自然流传的代号。但它指向的问题非常具体当你的 Next.js 项目越建越多、共享模块越来越复杂时靠复制粘贴来开启一个新项目到底还能撑多久这篇文章不打算讲什么高深理论我会把 t3code 当成一个完整的“代码生成与项目引导工具”来拆解聊一聊它的工作边界、内部原理、核心模块设计以及我自己在实测工程中踩过的坑和最终沉淀下来的完整方案。如果你正在做内部脚手架、代码模板引擎或者只是好奇这种“生成代码的工具”是怎么一步步从玩具变成能交给团队用的东西这篇文章值得从头读完。1. 从“每次搭项目都想复制粘贴”到做 t3code 的动机先聊聊我为什么会执着于这类工具。1.1 复制粘贴方案在三四个项目里就开始失控很多人最初的态度是项目结构我只要手动搭过一次剩下不就复制一下嘛。前两个项目确实没问题复制目录、改几个名字、删掉用不到的文件十分钟搞定。等到第三个、第四个项目出现事情就开始变味。最典型的场景是你复制了一个用户模块过来结果旧项目里写了三个不同的getUserById实现分布在pages/api、server/api/routers和utils三个地方。新项目里你到底该复制哪一份复制错了后面就是一路追着改 bug。更麻烦的是团队里不是只有你一个人在做这件事别人复制的时候顺手改了命名风格、加了点自己的逻辑模板就悄悄产生了分支。我统计过一次一个中等规模的项目里光user相关的 API 就有七个文件真正被多次复制的只有三个剩下的全是历史包袱。这种时候靠“自觉复制”已经不是低效问题了是在给团队埋雷。1.2 t3code 的目标是“让初始化变成一个可重复的命令”所以我理解的 t3code本质上就是“把搭建新项目、新增业务模块这些重复操作固化成一条命令和一个模板仓库”。它的核心不是写更多代码而是消灭零散的复制粘贴让每一次生成都基于同一份经过验证的模板。它能做的事可以拆成三个层次项目级生成拉一个完整的前端工程骨架包含路由、样式、数据库客户端、接口层的规范目录。模块级生成往已有项目里新增一个带路由、服务、数据库 schema 的完整业务模块。配置引导通过交互式问答或者 JSON 配置决定生成哪些内容、跳过哪些内容。这三点正好对应了实际开发里最常见的三件事起项目、加模块、做配置。t3code 把这三件事统一成一种风格重复次数越多省下的时间越可观。1.3 什么人适合搞一套自己的 t3code说实话不是所有人都需要这种工具。如果你只是个人做几个一次性 Demo手搭一个 Vite 项目完全没问题。但如果你属于下面几类情况很值得投入时间团队里有多个前端项目需要统一技术栈和目录规范。公司内部的接口层有固定写法比如都必须走 tRPC 或特定封装的 fetch。每次新增一个数据模型都要手动同步写数据库、服务层、API 层漏一步就线上报错。新成员入职后对“我们的项目应该长什么样”完全没有概念需要一套可参考的生成结果。我自己的体会是做这套工具最难的从来不是“写生成器”而是“把标准定出来”。技术层面的方案本章后面会展开但动机层面你得先想清楚你希望团队生成的代码长成什么样再让机器帮你去重复。2. 把“生成代码”拆成可执行的三件事模板、变量、产物校验确定了动机之后就要开始设计它的内部结构。很多人一上来就想写一个特别智能的东西觉得 AI 自动生成才是未来。但实际工程里用的工具恰恰要“笨”一点才稳。2.1 生成器本质上是一个渲染管线抛开所有花哨的形容词t3code 的工作方式可以类比成一个“盖章机”输入用户的配置例如{ moduleName: order, withApi: true }。模板一套写好的文件模板里面有各种占位符。渲染用输入变量替换占位符生成实际的文件内容。输出把渲染结果写到指定目录。校验检查生成出来的代码能不能通过编译/类型检查。所以它就是一个典型的“配置 模板 输出管道”。其中最容易被人忽略的就是第 5 步生成出来的代码如果跑不过类型检查那这工具就是半成品。我推荐的模板引擎是 EJS。原因很简单它语法足够简单% % 里可以直接写 JavaScript学习成本几乎为零。用 EJS 写一个模型模板看起来像这样// generated-% modelName %.ts export interface % capitalize(modelName) % { id: string; createdAt: Date; %- fields.map(f f.name : f.type).join(\n) % }注意capitalize这种函数不是 EJS 自带的需要在渲染前注入辅助函数。这也是用 EJS 比用 Handlebars 舒服的地方你想用什么函数直接往上下文里塞就行不需要学一套 helpers 语法。2.2 模板目录结构怎么设计才不乱模板的物理组织很关键。我的建议是把模板按“生成层级”分开而不是按文件类型堆在一起templates/ ├── project/ # 整个项目的骨架模板 │ ├── package.json.ejs │ ├── tsconfig.json.ejs │ └── src/ │ ├── pages/ │ └── server/ ├── module/ # 单个业务模块模板 │ ├── api/ │ ├── model/ │ └── lib/ └── partials/ # 可复用的片段比如错误处理代码 ├── errorHandler.ejs └── pagination.ejs“项目模板”和“模块模板”必须分开否则就会出现一个尴尬情况你想往已有项目里加一个模块结果生成器把整个package.json也覆盖了一遍。分开之后t3code 内部只是不同的“渲染入口”指向不同模板根目录而已。2.3 配置文件就是你和生成器之间的合同生成器需要用户提供什么信息不能全靠在命令行里一个个问答太累了。对于复杂一点的场景我强烈推荐让用户写一个 JSON 配置文件字段用 zod 校验。{ projectName: mall-admin, template: next-trpc-prisma, features: [auth, admin], database: { provider: postgresql, modelDir: prisma/schema } }配置文件的好处是它可以进入 Git 仓库整个团队看到的是同一份“契约”。以后想复现某个项目的生成过程翻一下配置文件就知道当时传了哪些参数。命令行问答适合简单场景但一旦参数超过五个配置文件的可读性优势就会碾压问答。模块职责输入输出配置解析器读取并校验用户输入JSON / CLI 参数标准化的生成选项模板加载器按选项定位模板目录模板名称渲染函数集合渲染引擎把变量注入模板数据 模板内容字符串文件写入器创建目录、写文件文件路径 内容磁盘文件产物校验器检查生成结果文件路径列表通过/失败报告这张表基本就是 t3code 的最小架构清单。后面我写代码的时候也会按照这五行去组织模块优先级从下往上先保证能写出文件再谈校验和优化。3. 手写一个最小可用版 t3code 生成器的完整过程光说不练没什么意义我把一个真正可以跑起来的最小版 t3code 核心代码完整写出来你甚至可以直接复制去改成自己的。3.1 初始化项目和依赖先建一个 Node.js TypeScript 项目这应该是基础操作我只强调几个关键依赖mkdir t3code cd t3code npm init -y npm install ejs zod commander npm install -D typescript types/node types/ejs tsxcommander解析命令行参数例如t3code generate module order。ejs模板渲染。zod校验配置文件里的参数类型。这是保证“输入不合法生成器不工作”的关键。tsx开发阶段直接跑 TypeScript省去先编译再运行的麻烦。tsconfig.json我建议开启strict: true因为它自己就是给别人生成代码的工具如果自己代码都不严格很难说服别人信任它生成的产物。3.2 入口命令解析与分发src/cli.ts里做的事情很纯粹定义命令然后调用对应的逻辑函数。import { Command } from commander; import { generateProject } from ./actions/generateProject; import { generateModule } from ./actions/generateModule; const program new Command(); program .name(t3code) .description(T3 stack code generator) .version(0.1.0); program .command(generate) .argument(type, project or module) .argument(name, project or module name) .option(-c, --config path, path to config json, t3code.json) .action(async (type, name, options) { if (type project) { await generateProject({ name, configPath: options.config }); } else if (type module) { await generateModule({ name, configPath: options.config }); } else { console.error(Unknown type: ${type}); process.exit(1); } }); program.parse();这段代码没什么魔法但有一个值得注意的设计config参数允许用户指定路径。不要硬编码索引t3code.json因为不同项目可能把配置文件放在.config/t3code.json或configs/t3code.json。给用户选择权比偷偷做自适应简单可靠得多。3.3 配置校验用 zod 把关没有校验的生成器是危险的用户传了一个不存在的数据库 provider工具还是傻乎乎地生成了一堆错误代码害用户排查半天。至少要做基础校验。import { z } from zod; export const ProjectSchema z.object({ projectName: z.string().min(1).regex(/^[a-z][a-z0-9-]*$/, { message: 项目名只能包含小写字母、数字和连字符, }), template: z.string().default(next-trpc-prisma), features: z.array(z.string()).default([]), database: z.object({ provider: z.enum([postgresql, sqlite, mysql]).default(postgresql), modelDir: z.string().default(prisma/schema), }), }); export type ProjectOptions z.infertypeof ProjectSchema;注意那个regex正则。项目名如果允许大写和空格后面文件路径、package name、导入路径全都会出问题。宁可在这里严格一点也别让错误扩散到生成的代码里。3.4 渲染与写入核心循环生成器的主函数逻辑其实就是一个循环遍历模板目录对每个模板文件执行“渲染 - 写入目标路径”。import { renderFile } from ejs; import { mkdir, readdir, writeFile } from fs/promises; import path from path; export async function walkAndRender( templateRoot: string, targetRoot: string, data: Recordstring, any, fileMap: Recordstring, string {} ) { const entries await readdir(templateRoot, { withFileTypes: true }); for (const entry of entries) { const src path.join(templateRoot, entry.name); const dest path.join(targetRoot, renderFileName(entry.name, data)); if (entry.isDirectory()) { await mkdir(dest, { recursive: true }); await walkAndRender(src, dest, data, fileMap); } else { const rendered await renderFile(src, data); await mkdir(path.dirname(dest), { recursive: true }); await writeFile(dest, rendered, utf-8); fileMap[dest] rendered.length; } } return fileMap; } function renderFileName(name: string, data: Recordstring, any): string { return name.replace(/\{(.*?)\}/g, (_, key) { const value data[key]; if (value null || value undefined) return _; return String(value); }); }这个函数有两个细节我要单独解释文件名也用模板语法。user.model.ts.ejs渲染后目标文件名需要变成order.model.ts。所以我把模板文件名写成{moduleName}.model.ts.ejs渲染时通过renderFileName把{moduleName}替换掉。如果用 EJS 语法% %来改文件名也行但正则替换更直观不易跑偏。递归时保留原有目录结构。entrusted目录不要拍平否则生成的代码就失去组织性。src/server/routers/(name).router.ts就对应渲染成实际模块的 router 文件。3.5 一个实战模板Prisma 模型生成看一个具体例子。假设我要生成一个带字段的 Prisma 模型模板长这样// templates/module/model/{{name}}.prisma model % modelName % { id String id default(cuid()) createdAt DateTime default(now()) updatedAt DateTime updatedAt % if (includeTitle) { % title String % } % % fields.forEach(field { % % field.name % % field.type % % if (field.optional) { %?% } % % }) % }对应的调用代码const options { modelName: Order, includeTitle: true, fields: [ { name: amount, type: Decimal, optional: false }, { name: remark, type: String, optional: true }, ], }; await walkAndRender( path.join(__dirname, ../templates/module/), path.join(process.cwd(), prisma/schema/), options );生成结果model Order { id String id default(cuid()) createdAt DateTime default(now()) updatedAt DateTime updatedAt title String amount Decimal remark String? }这一步跑通其实你已经有了一台“代码盖章机”。接下来怎么让它变得更可靠、更容易维护才是真正的分水岭。4. 实测里最容易翻车的四个细节路径分隔符、模板转义、命名规范、产物校验把最小版写出来很容易但要能长期用在真实项目里必须解决几个“看着不起眼、踩中真要命”的问题。我自己实测过程中这四类坑出现频率最高。4.1 Windows 下的路径分隔符问题在 Windows 上跑 Node.jspath.join返回的路径分隔符是反斜杠\而在 Linux/macOS 上是正斜杠/。如果你的模板里写死了带斜杠的相对路径在 Windows 上就会生成一堆\混在字符串里的怪路径。我的处理方式很简单所有涉及文件路径的场景一律只用path.join和path.relative组合绝对不手写字符串拼接。import path from path; const moduleDir path.join(projectRoot, src, server, api, routers);另一个细节是用path.parse来处理模板文件扩展名const parsed path.parse(fileName); // { name: user, ext: .ejs } const finalName parsed.name.replace(.ts, ) parsed.name.includes(.d.ts) ? .d.ts : .ts;别小看这种小函数团队里的 Windows 用户会感谢你的。4.2 模板转义陷阱输出引号与特殊符号EJS 里% %会做 HTML 转义%- %不做转义。如果你生成的是 TypeScript 文件里面包含T这样的泛型写法用% %就会把转成lt;。比如这段模板export type ApiResponseT { data: T };正确写法应该是export type ApiResponse%- T % { data: %- T % };实测中我遇到过一个很有意思的问题生成 JSON 配置文件时模板里的双引号在 Windows 环境的某些 shell 工具中会被吞掉。最后我把所有 “字符串模板”统一改成 “渲染时注入辅助函数”也就是不直接依赖 EJS 的%语法而是写一个renderJSON(obj)函数在模板里调用{ compilerOptions: %- renderJSON(compilerOptions) % }这样任何特殊字符都由JSON.stringify来兜底永远不会为了一个引号折腾半天。4.3 命名规范生成器必须强制统一的命名风格不统一命名规范生成出来的代码就是一个“大杂烩”。我建议在生成器内部内置一套命名工具函数export const toPascalCase (name: string) name .split(/[-_]/) .map((s) s.charAt(0).toUpperCase() s.slice(1)) .join(); export const toCamelCase (name: string) name .split(/[-_]/) .map((s, i) (i 0 ? s : s.charAt(0).toUpperCase() s.slice(1))) .join(); export const toKebabCase (name: string) name.replace(/([a-z])([A-Z])/g, $1-$2).toLowerCase();然后在渲染时不要直接把用户给的moduleName传进模板而是传一个names对象const names { original: rawName, modelName: toPascalCase(rawName), serviceName: toCamelCase(rawName), routeName: toKebabCase(rawName), className: toPascalCase(rawName) Service, };这样做的好处是模型名、路由名、类名之间的转换看得见、摸得着。用户输入的order-detail最后生成的代码里所有命名都有迹可循不会出现OrderDetail和order_detail混用的惨剧。4.4 产物校验生成完了下一步必须自查生成器输出的代码如果编译不过它的使用寿命也就到头了。我强烈建议在生成命令的最后一步自动跑一遍静态检查。我的做法是这样的import { execSync } from child_process; export function validateGeneratedCode(projectRoot: string) { try { execSync(npx tsc --noEmit, { cwd: projectRoot, stdio: inherit, timeout: 120_000, }); console.log(类型检查通过); } catch (e) { console.error(类型检查失败请检查生成结果); process.exit(1); } }如果项目里用了 Prisma还要追加一步npx prisma validate确保 model 文件的语法和关系引用是正确的。这两个检查都不算慢但你会在后面的调试时间里赚回十倍。检查项命令捕获的问题TypeScripttsc --noEmit类型不匹配、导入路径错误、未使用变量Prisma schemaprisma validatemodel 语法错误、关系缺失、字段类型错误ESLinteslint .风格不一致、未定义变量单元测试vitest run --silent生成结果的运行时错误多花十秒钟做校验省的是一整天的“不知道为什么报错”。5. 从“跑通”到“好用”增量更新、参数校验与团队规范化最小可用版解决的是“能不能生成”但真正决定一个生成器能不能被团队长期使用的是“生成得稳不稳、能不能增量更新”。这一节我分享几个更进阶的设计思路。5.1 幂等性同一份配置重复跑也不会破坏代码早期版本的生成器有一个让人难受的行为每次运行都会原样覆盖文件哪怕你已经在生成后的代码里加了业务逻辑。结果就是跑一次生成命令手写的内容全没了。解决思路是把生成器分成两种模式覆盖模式适合首次生成直接写文件。合并模式适合二次生成先读目标文件是否存在如果存在只插入缺失的代码块不碰已有内容。合并模式实现起来比较复杂需要约定代码块标记。例如在生成的文件里嵌入// t3code-section: userServiceFunctions export function generateToken() { ... } // t3code-section-end再次运行时解析已有文件只把代码块之间的内容做 diff只替换标记区间的部分。这个方案虽然要写一点解析逻辑但它能让 t3code 从“一次性脚手架”升级成“持续生成的活工具”。5.2 参数传入的三种方式交互、命令行、配置文件不同使用习惯的人对输入方式的偏好完全不同。我自己最终支持了三种方式优先级从高到低配置文件适合 CI 和复杂项目所有选项都在一个文件里可审查。命令行参数适合快速生成例如--modelName order --includeTitle。交互式提问适合完全不知道要填什么的新人用prompts库实现。优先级顺序的实现方式很简单先用交互问答得到默认值再被命令行参数覆盖最后被配置文件覆盖。zod 解析之后生成器内部拿到的永远是一个干净一致的对象。5.3 规范化到团队流程git hooks 与 CI 配合生成器产出的代码需要保证“一致化”但这种一致化不能只靠运行工具的人自觉。最好把校验嵌进开发流程在生成器内部命令结束前自动执行git add --renormalize .和git diff --check保证没有文件尾空格、没有冲突标记。在 CI 里加一个任务跑一遍t3code generate --dry-run比较生成的代码和当前代码是否有差异。有差异说明项目脱离了模板直接失败。--dry-run的实现就是在写文件前把内容打印到一个临时目录比较差异而不是真的覆盖文件。这个功能对团队规范的执行太有用了——它让“是否遵守标准”成为一个可以被机器判定的事实而不是靠 reviewer 的心情。5.4 扩展视角从代码生成到“项目体检”t3code 最后还能做一件很有趣的事既然你已经有了完整的模板语义那就可以写一个t3code doctor命令用来体检已有的项目是否符合内部规范。比如检查所有 API Router 文件是否都放在src/server/routers下。数据库 model 是否都有id和updatedAt。环境变量是否都经过envSchema校验。这本质上就是把“生成规则”反用为“检查规则”。写起来不复杂核心就是把模板解析出的文件清单和当前项目的实际文件清单做一次对齐。体验下来见效比任何文档宣讲都快。写在最后的一点体会这套 t3code 方案从最初的一个“复制粘贴加速器”慢慢长成了我现在天天依赖的基础设施。我自己实际操作中的体会是生成器最值钱的部分不是那几条 ejs 模板而是当你把所有约定都变成机器可校验规则之后团队里再也不用为了“这个代码应该放哪、起什么名字”开会了。如果你也想试着自己做一个我的建议是先从一个模块级生成器开始不要一上来就做项目级脚手架否则配置项会把你淹没。把一个order.module从无到有、从生成到通过类型检查跑通你会得到比我想象中更踏实的收益。后面再往里面加doctor、加 CI 校验都是水到渠成的事。最后分享一个小技巧永远给生成器保留--dry-run选项。它救过我太多次了尤其在那些“我以为改的是模板、其实改的是生成产物”的时刻。跑一次 dry-run看一眼 diff你就知道机器到底打算干什么。