从复制粘贴到模板驱动:t3code代码生成器实战

发布时间:2026/10/26 23:23:57
从复制粘贴到模板驱动:t3code代码生成器实战 t3code 这个项目是我半年多前被一份 800 行的重复代码逼出来的。当时给一个后台管理系统补模块每个模块至少要生成 7 个文件数据库实体、Mapper 接口、Service 实现、Controller、前端 API 封装、表单校验、路由注册。名字不一样字段不一样其余结构几乎完全一样。复制粘贴十分钟改名字四十分钟最要命的是漏改一处字段名编译直接红一片。于是我把这堆重复劳动拆成三步提取模板、转换变量、追踪输出项目代号就叫 t3code——template、transform、track。如果你是前端、全栈或者平时只写脚本的人只要也被“新模块 复制旧模块再全局替换”折磨过这篇复盘值得看看。用 t3code 的思路你可以把一套 CRUD 从半小时压缩到半分钟而且生成结果和你手工写得一模一样。我会把选型原因、引擎实现、真实踩坑都写清楚模板和代码可以直接抄走改。下面进入正题。1. t3code 的由来不是造轮子是被重复劳动逼出来的1.1 痛点场景复制粘贴那套 CRUD为什么总是出错先说一个具体的场景。假设要新增一个“用户模块”后端通常要配套这些文件UserEntity.java数据库实体UserMapper.javaMyBatis 或 JPA 的 Mapper 接口UserService.java业务逻辑UserController.java对外接口前端src/api/user.ts请求封装前端views/user/List.vue、Edit.vue页面这些文件里的命名规则差异很大Java 类名要UserEntity包目录要用user数据库表名要user前端 API 文件叫user.ts页面目录叫user。靠人工改最典型的问题就是复制出来的UserService.java里 import 了UserDto但实际 DTO 文件叫UserInfoDTO等编译才发现。// 某次复制粘贴后出现的真实错误 import com.example.modules.UserDto; // 实际类名是 UserInfoDTO public UserDto getUserById(Long id) { return mapper.selectById(id); }表面上看只是一行 import 写错了背后其实是“找出哪里需要变”这个过程特别容易漏。更麻烦的是团队里每个人风格不一样有人把表字段叫create_time有人叫createdAt生成出来的代码进了仓库就变成历史遗留问题。复制粘贴最大的成本从来不是粘贴本身而是“心算哪些地方要改”。t3code 想解决的就是这件事把命名规则固化到模板里把变量交给引擎统一处理人只需要输入moduleNameuser和字段列表剩下的路径、类名、包名、前端变量名都自动算好。1.2 为什么不用现成的 Plop、Hygen 或 Yeoman说实话在动手写 t3code 之前我先后试过 Plop、Hygen也看过 Yeoman。它们都是好工具但我用下来总觉得差一点。工具优点我放弃的原因Yeoman生态强generator 多generator 本身是代码不是纯模板维护门槛偏高Plop上手快适合单文件生成模板多了以后每个 action 都要写配置配置会膨胀Hygen轻量目录结构清晰变量预处理不够灵活命名风格转换还是要在模板里塞逻辑Shell 脚本随处可用一旦涉及多目录、参数解析、覆盖保护脚本就变得很难维护不是说这些工具不好而是我的场景里最痛的不是“缺工具”而是缺一个能把“命名规则”固化下来的东西。t3code 的核心定位非常窄模板即文件templates目录下摆什么结构生成出来就是什么结构变量由引擎预处理camelCase、pascalCase、snakeCase这些转换统一在引擎层做输出路径由模板自己声明每个模板文件头部写output字段支持变量这点很重要。很多脚手架把“输入变量”和“输出路径”拆成两套配置结果变量一多就找不到对应关系。t3code 直接把路径写进模板看到模板第一眼就知道它会生成到哪里。2. 技术选型与工程结构为什么是 Node.js TypeScript2.1 Node.js 是我唯一没纠结的选项确定要自研之后技术栈几乎没有第二种选择。原因很实际团队前端为主Node.js 人人能改Python 和 Go 还需要一部分人额外去学Handlebars 在 JS 生态里非常成熟模板语法、helper 机制都够用Commander 做 CLI 参数解析prompts 做交互式确认chokidar 做文件监听全都是现成轮子后续如果要接 VS Code 插件TypeScript 可以直接复用类型定义不用再做一层桥我承认 Go 的编译产物很香单文件分发、部署简单。但 t3code 是个本地开发工具不是线上服务部署成本低一点高一点无所谓反倒是“改完立刻能跑”的体验更重要。Python 的问题则是环境版本太乱模板引擎选择也多反而容易踩坑。最终依赖极其克制{ name: t3code, version: 0.1.0, bin: { t3code: dist/cli.js }, scripts: { build: tsc, dev: ts-node src/cli.ts, watch: ts-node-dev --ignore templates --ignore src/generated src/cli.ts }, dependencies: { commander: ^11.0.0, handlebars: ^4.7.8, prompts: ^2.4.2, chokidar: ^3.5.3 }, devDependencies: { typescript: ^5.2.0, types/node: ^20.0.0 } }注意watch脚本里的两个--ignore后面讲坑的时候会专门说这里先埋个伏笔。2.2 目录结构让模板和引擎彻底分离t3code 的目录结构长这样t3code/ ├── src/ │ ├── cli.ts │ ├── config.ts │ └── engine/ │ ├── extract.ts │ ├── render.ts │ └── writer.ts ├── templates/ │ └── crud/ │ ├── entity.java.tpl │ ├── mapper.java.tpl │ ├── service.java.tpl │ ├── controller.java.tpl │ ├── api.ts.tpl │ └── List.vue.tpl ├── .t3code.config.json ├── package.json └── tsconfig.jsonsrc/engine里三个文件各干各的extract.ts从模板文件里解析出 frontmatter也就是头部---之间的配置render.ts用 Handlebars 渲染模板主体内容writer.ts负责目录创建、覆盖保护、写文件templates是纯模板目录里面不允许写业务逻辑。所有逻辑都在引擎层模板作者只需要关心“生成出来的文件长什么样”。这种分工很重要因为模板往往由不同人维护如果模板里塞太多 helper最后会变成一锅粥。2.3 配置协议少即是多.t3code.config.json一开始被我想得很复杂后来删到只剩几个字段{ templateRoot: templates, outputRoot: src/generated, presets: { backend: { outputRoot: src/main/java/com/example, template: crud/** } } }templateRoot模板根目录默认templatesoutputRoot生成根目录默认src/generatedpresets预设可以针对不同工程指定不同输出根目录为什么这么精简因为我把“输出路径”放到了模板 frontmatter 里配置只需要定义“根”就够了。具体每个文件落到哪里由模板自己声明。这样调整一个模板的生成位置不需要翻配置调整一整组模板的输出根目录才需要碰presets。我踩过的坑是一开始配置里放了很多format、indent、extension之类的选项到最后根本没人用反而让工具变得难理解。// 一个典型的 presets 用法 { presets: { frontend: { outputRoot: src/api, template: web-api/** } } }执行t3code generate web-api/user --preset frontend就会以templates/web-api下的模板生成写到src/api下。这样做的好处是同一个模板目录可以被不同项目复用只是输出位置不同。3. 引擎核心模板语法、变量预处理与批量生成流程3.1 模板语法frontmatter Handlebarst3code 的模板文件后缀统一用.tpl内容分两部分头部 frontmatter 和正文。frontmatter 用---包裹里面至少要有output字段指定生成目标路径。看一个 Java 实体模板--- output: src/main/java/com/{{company}}/modules/{{snakeCase moduleName}}/{{pascalCase moduleName}}Entity.java --- package com.{{company}}.modules.{{snakeCase moduleName}}; import javax.persistence.*; import java.util.Date; Entity Table(name {{snakeCase moduleName}}) public class {{pascalCase moduleName}}Entity { {{#each fields}} Column(name {{snakeCase this.name}}) private {{this.type}} {{camelCase this.name}}; {{/each}} private Date createdAt; }注意到没output本身也用了变量。也就是说你输入moduleNameuser这个文件会自动生成到src/main/java/com/example/modules/user/UserEntity.java。不用额外写任何路径映射。提取 frontmatter 的代码很简单但有个细节值得注意// src/engine/extract.ts export function extractFrontmatter(source: string) { if (!source.startsWith(---)) { return { frontmatter: {}, content: source }; } const end source.indexOf(\n---, 4); if (end -1) { throw new Error(template frontmatter is not closed: expected --- on a new line); } const raw source.slice(4, end); const content source.slice(end 4); const frontmatter: Recordstring, string {}; for (const line of raw.split(\n)) { const idx line.indexOf(:); if (idx 0) { frontmatter[line.slice(0, idx).trim()] line.slice(idx 1).trim(); } } return { frontmatter, content }; }这里没有用 YAML 解析库因为 t3code 的 frontmatter 只需要output一个字段用简单解析就够了。真引一个 YAML 库反而增加维护成本。3.2 变量预处理从一条命令参数到一组模板变量命令行里的原始输入通常是moduleNameuser、fieldsname:string,age:number。模板里需要的是pascalCase、snakeCase、camelCase各种风格。我的做法是注册全局 Handlebars helper让模板里直接用{{pascalCase moduleName}}// src/engine/render.ts import Handlebars from handlebars; function toCamelCase(value: string): string { return value.replace(/[-_]([a-z])/g, (_, c) c.toUpperCase()); } function toPascalCase(value: string): string { const camel toCamelCase(value); return camel.charAt(0).toUpperCase() camel.slice(1); } function toSnakeCase(value: string): string { return value.replace(/([a-z])([A-Z])/g, $1_$2).toLowerCase(); } Handlebars.registerHelper(camelCase, toCamelCase); Handlebars.registerHelper(pascalCase, toPascalCase); Handlebars.registerHelper(snakeCase, toSnakeCase);字段列表也要在引擎层提前解析好而不是把name:string,age:number一整个字符串丢给模板。原因很简单模板里要遍历的是一个个字段对象如果只给字符串每个模板都得自己 split重复又容易错。// 把字段字符串解析成对象数组 function parseFields(input: string) { if (!input) return []; return input.split(,).map((item) { const [rawName, rawType] item.split(:); const name rawName.trim(); return { name, type: (rawType || string).trim(), camelName: toCamelCase(name), pascalName: toPascalCase(name), snakeName: toSnakeCase(name), }; }); }这样模板里就可以写{{this.pascalName}}、{{this.snakeName}}不需要再显式套 helper。凡是能用“预计算”解决的问题就不要在模板里做二次逻辑。这是我做 t3code 得到的最重要的一条经验。3.3 批量生成主流程扫描模板、渲染、写文件生成命令的主流程可以浓缩成这一段// src/engine/render.ts import { glob } from glob; import { extractFrontmatter } from ./extract; import { writeWithProtection } from ./writer; export async function generate(templateName: string, variables: Recordstring, unknown) { const pattern ${config.templateRoot}/${templateName}/**/*.tpl; const files await glob(pattern, { posix: true }); if (files.length 0) { console.error([t3code] no templates found under: ${pattern}); process.exit(1); } for (const file of files) { const source await fs.promises.readFile(file, utf-8); const { frontmatter, content } extractFrontmatter(source); const renderContext { ...variables, fields: parseFields((variables.fields as string) || ), }; // 用 frontmatter.output 作为目标路径模板 const compiledOutput Handlebars.compile(frontmatter.output); const targetPath compiledOutput(renderContext); const rendered Handlebars.compile(content, { strict: true, })(renderContext); await writeWithProtection(targetPath, rendered); } }strict: true是我后来加的。默认 Handlebars 对未定义变量会静默渲染成空字符串这在代码生成场景里非常危险你漏传一个moduleName生成出来的文件照样写出去了只是路径和类名都不对编译期才炸。开启strict后一旦变量缺失渲染阶段就会抛出明确错误[Handlebars] moduleName not defined in src/engine/templates/crud/entity.java.tpl错误信息里带上模板文件名定位问题快很多。不过要注意strict会让#if判断未定义变量时也抛错所以模板里用条件判断时要把变量默认值都配好或者先做一层默认值注入。我在实际使用中更推荐在renderContext里把所有变量都显式设置默认值避免模板作者为了“不报错”而写一堆#if。3.4 dry-run只打印不写文件批量生成很容易发生“一跑就是十几个文件”的情况如果路径算错了目录就长歪了。所以我给generate加了一个--dry-run参数t3code generate crud --moduleName user --fields name:string,age:number --dry-run输出[t3code] would write: src/main/java/com/example/modules/user/UserEntity.java [t3code] would write: src/main/java/com/example/modules/user/UserMapper.java [t3code] would write: src/main/java/com/example/modules/user/UserService.java [t3code] would write: src/main/java/com/example/modules/user/UserController.java很多工具的 dry-run 只是把“路径列表”打印出来没多大用。t3code 的做法是在写文件前把所有模板都渲染出来然后逐条上报结果这样能看到每个文件渲染后的大小、行数甚至可以加上--verbose输出文件内容摘要。这个功能在调整模板结构时帮了我大忙。4. 实战中踩过的四个坑覆盖保护、路径分隔符、布尔参数与 watch 循环4.1 覆盖保护默认绝对不能直接覆盖第一次用 t3code 时我图省事writeFileSync直接写结果把一个已经手工调整过的配置文件冲掉了。那次丢的代码不多但教训很深刻生成工具一旦开始被团队使用默认行为应当是“保守”而不是“高效”。现在的writeWithProtection是这样// src/engine/writer.ts import prompts from prompts; export async function writeWithProtection(targetPath: string, content: string) { const abs path.resolve(config.outputRoot, targetPath); if (fs.existsSync(abs)) { if (config.overwrite always) { await fs.promises.writeFile(abs, content); return; } const res await prompts({ type: confirm, name: value, message: ${targetPath} already exists. Overwrite?, initial: false, }); if (!res.value) { console.log([t3code] skip: ${targetPath}); return; } } await fs.promises.mkdir(path.dirname(abs), { recursive: true }); await fs.promises.writeFile(abs, content); }三个具体设计交互确认的默认值是false不是true。宁可让人多按一次回车也不能默认覆盖配置里提供overwrite: always的逃生口但只建议在 CI 或一次性生成时用跳过时不报错只打印skip方便在日志里看出哪些文件没动4.2 Windows 路径分隔符反斜杠会咬人这个问题是在前端 API 文件上爆出来的。生成的src/api/user.ts里有一行 importimport request from ../utils/request;这个没问题。但有些模板会生成多级目录的引用比如import UserService from ../../../api/user.service;在 Windows 上如果目标路径是用path.join拼出来的就变成了import UserService from ..\\..\\..\\api\\user.service;浏览器和 Node 都不认反斜杠直接报错。根因在于“文件系统路径”和“代码里的 import 路径”是两回事。文件系统里用path.join没问题但写到代码里的必须是正斜杠。解决办法是在所有输出相关的位置统一做一次转换function toPosixPath(p: string): string { return p.replace(/\\/g, /); }然后模板 frontmatter 里写输出路径时用正斜杠渲染结果里的import字符串如果是用变量拼的也必须经过toPosixPathglob扫描模板文件时返回的路径本身就是正斜杠但用户手输的路径可能是反斜杠要在入口处统一我后来加了一条测试用例在 Windows 和 Linux 上分别跑生成然后 git diff 生成结果确保完全一致。这个坑看起来小但踩一次要花半小时才能定位。4.3 布尔参数被字符串化false也是true这是 Commander 参数解析的经典坑。我在 CLI 里定义了一个选项希望用户传布尔值t3code generate user --isPublic false一开始的写法是program .command(generate) .requiredOption(--moduleName name, module name) .option(--isPublic value, is public flag, false) .action(async (template, options) { await generate(template, options); });问题在于命令行参数永远是字符串所以options.isPublic的值是字符串false不是布尔值false。而 Handlebars 的#if判断只看 truthy / falsy字符串false是 truthy于是模板里{{#if isPublic}} public class {{pascalCase moduleName}}Service { {{else}} class {{pascalCase moduleName}}Service { {{/if}}会错误地走public分支。最直接的解决办法是布尔选项不带值用 flag 形式t3code generate user --publicprogram.option(--public, mark as public, false);--public出现就是true不出现就是false根本不存在字符串问题。如果你的场景确实需要接收true/false字符串就写一个解析函数function parseBoolean(value: string) { return value true || value 1 || value yes; } program.option(--isPublic value, is public flag, parseBoolean, false);这个函数要显式处理默认值别只用Boolean(value)因为Boolean(false)的结果也是true。4.4 watch 循环生成到一半项目重启了开发 t3code 时我用的是ts-node-dev监听src目录改动引擎代码自动重启。结果第一次跑生成后程序突然重启了生成的项目也一直在崩。排查了很久才发现问题不在于ts-node-dev自己而在于 watch 的范围没有排除输出目录。这里有两层坑模板目录templates不是源码但ts-node-dev默认也会监听整个工作目录。模板一改CLI 重启代价不大但很烦。更严重的是如果你用chokidar监听源码目录而outputRoot也在源码目录里每次生成一批新文件watch 就会触发一次重新构建构建再生成文件无限循环。解决办法在 package.json 里已经写了ts-node-dev --ignore templates --ignore src/generated src/cli.ts--ignore后面既能跟目录也能跟 glob。实际团队使用场景里最好在.t3code.config.json里加一个watchIgnore字段让不同项目的输出目录不会被引擎自己监听。这个坑在单人开发时不容易暴露一旦接进 CI 或多人协作就会出现“生成一次、循环十分钟”的诡异问题。5. 从单人脚本到团队工具命令设计、doctor 验证与落地建议5.1 命令设计把常用操作收敛成四个动词t3code 的命令一开始很乱什么gen、create、new都有后来收敛成五个t3code init t3code generate crud --moduleName user --fields name:string,age:number t3code list t3code doctor t3code generate crud --moduleName user --dry-runinit在当前目录生成.t3code.config.json和空模板目录generate template按模板生成文件核心命令list列出templateRoot下所有模板以及每个模板的 frontmatter 信息doctor检查所有模板的语法、frontmatter 完整性、变量引用是否正确list看起来简单但对团队规范很有用。没有这个命令前新同事得翻模板目录才知道有哪些模板能用。有了list直接看输出[t3code] templates: crud - 6 files, variables: moduleName, fields web-api - 2 files, variables: moduleName, fields这里我特意让list显示“变量列表”因为很多模板需要的变量比命令行传参里的多提前展示能减少使用者的试错成本。5.2 doctor让模板问题在生成前就暴露模板语法错误很隐蔽因为只有真正生成时 Handlebars 才会报错。如果某个模板已经被用了几个月某个变量名被删了所有生成都会失败但没人知道是哪一行的问题。doctor做的事很简单遍历所有模板文件逐个编译并尝试用一组“空变量”做渲染// src/commands/doctor.ts import { glob } from glob; import Handlebars from handlebars; import { extractFrontmatter } from ../engine/extract; export async function doctor() { const files await glob(${config.templateRoot}/**/*.tpl); let hasError false; for (const file of files) { const source await fs.promises.readFile(file, utf-8); const { frontmatter, content } extractFrontmatter(source); if (!frontmatter.output) { console.error([t3code] ${file}: missing frontmatter.output); hasError true; } try { Handlebars.compile(content, { strict: true })(emptyVariables()); console.log([t3code] ok: ${file}); } catch (e) { console.error([t3code] error: ${file}: ${e.message}); hasError true; } } process.exit(hasError ? 1 : 0); }注意我这里用了一个emptyVariables()目的是让strict模式把所有缺失变量都“炸”出来。一开始我直接用{}渲染结果每个模板都报一堆变量不存在反而看不出真实错误。后来改成变量缺失时只记录变量名等所有模板扫完后再汇总。这样 doctor 能一次性列出所有模板的问题而不是遇到第一个错误就退出。5.3 如果要把 t3code 用到自己的团队我的建议最后分享几条落地经验都是我自己趟过水以后总结的第一先收集 20 个真实模板再写引擎。不要一上来就设计抽象的模板语法和插件机制。我第一版引擎做了很多“看起来很强大”的功能比如多级继承、函数式 helper、动态目录映射结果实际用到的不到 20%。后来全部删掉只留了 Handlebars 和 frontmatter。真正有用的工具往往是那些“看起来平平无奇”的。第二模板必须入库走代码评审。模板一旦成为团队的唯一来源它的质量直接决定生成代码的质量。建议把templates目录放在项目仓库里任何修改都走 MR/PR至少在合并前要看一眼 diff。模板文件不像代码那样有编译期校验doctor也只能查语法查不了业务逻辑。第三生成结果要给人留后路。我最初的想法是“只要模板对生成出来的文件不需要再手改”。现实是总会有人生成后手动改一小段然后这个文件就不再适合被模板覆盖。所以覆盖保护必须是默认行为同时要在生成日志里明确提示哪些文件被跳过。不要强行让工具比人更聪明。第四dry-run 是团队的必需品。单个开发者可以直接跑但团队使用尤其涉及多个模块时最好让执行者养成先--dry-run看一眼路径和文件数的习惯。一次生成十几个文件如果路径错了git 里会多出一大堆垃圾目录清理起来比写代码还烦。我在实际使用中还发现一个不错的扩展方向把 t3code 的生成结果先走一次git diff确认无误后再真正写入磁盘。这个功能我留在了--dry-run --verbose里后续可以直接升级成“check 模式”。最后说一句心里话t3code 不是那种能改变行业的大项目它更像是被日常重复劳动逼出来的小工具。但恰恰是这种小工具让我真正理解了“模板驱动开发”的价值——不是替人写代码而是替人记住那些本来就不该由人记的规则。如果你也有一个高频重复的代码模式花半天时间做成模板长期来看一定回本。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询