t3code 实战:模板驱动代码生成与自动化流程
1. 从“t3code”这个代号说起它到底指什么第一次看到“t3code”这个词很多人会一头雾水。它不像“React”“Vue”那样有明确的官方文档也不像“Docker”“Nginx”那样在运维圈子里人尽皆知。它更像是一个在特定团队、特定项目里流传的代号带着一点“圈内人才懂”的味道。我最早接触这个词是在一个内部工具链的讨论里有人提到“用 t3code 那套方案把构建流程串起来”当时我以为是某个开源库的缩写后来才发现它其实是一类轻量级代码生成与模板驱动开发模式的统称。所谓 t3code拆开来看“t3”通常指代“template、transform、target”三个环节而“code”则点明了最终产物是代码。合在一起它描述的是一种工作流以模板为起点经过数据转换和逻辑映射最终生成目标代码。这套思路并不新鲜早年的代码生成器、脚手架工具、ORM 映射框架都在做类似的事。但 t3code 的特别之处在于它不依赖重型框架也不要求你写复杂的 DSL而是用最朴素的“模板 数据 规则”三件套把重复性的编码工作压缩成一次配置。为什么这个代号会突然被拿出来讨论我观察下来原因有两个。一是现在很多团队都在做多端适配同一套业务逻辑要生成 Web、小程序、App 甚至服务端的不同代码手工写一遍成本太高维护起来更是噩梦。二是低代码平台泛滥之后大家发现“完全可视化”并不适合所有场景反而这种“半自动、可干预、产物可控”的代码生成方式更接地气。t3code 正好卡在这个位置上它不抢程序员的饭碗只是把那些“闭着眼睛都能写”的样板代码自动化掉。如果你是一个前端工程师、全栈开发者或者正在维护一套需要频繁生成重复代码的工具链那 t3code 这套思路值得你花时间研究。它不需要你精通编译器原理也不需要你搭建复杂的 AST 解析环境只要你会写模板、会处理 JSON 数据、会写一点映射规则就能跑起来。接下来我会从核心机制、实操步骤、常见坑点、进阶玩法几个角度把 t3code 这套东西拆开揉碎讲清楚。2. t3code 的核心机制模板、转换、目标三段式2.1 模板层为什么不用字符串拼接而用占位符很多人第一次做代码生成最直觉的做法是字符串拼接。比如要生成一个 API 请求函数直接写function getUser() { return fetch(/api/user) }然后替换里面的 URL 和函数名。这种做法在只有两三个变量的时候没问题一旦模板变复杂比如要生成一个完整的 CRUD 模块包含类型定义、请求函数、错误处理、缓存逻辑字符串拼接就会变成一场灾难引号转义、换行缩进、条件分支全混在一起改一个地方就牵一发而动全身。t3code 的模板层解决的就是这个问题。它要求你把模板写成带占位符的文本文件占位符用统一的语法标记比如{{name}}、{{#if condition}}...{{/if}}、{{#each list}}...{{/each}}。这样做的好处是模板本身是可读的你一眼就能看出生成后的代码长什么样而不是在一堆加号和引号里找逻辑。更重要的是模板和数据的分离让复用变得极其简单同一个模板换一份数据就能生成不同模块的代码。我实测下来模板引擎的选择上如果是 JavaScript 生态Handlebars、Mustache、EJS都够用。Handlebars 的语法更严格适合团队协作EJS 更灵活可以直接在模板里写 JS 逻辑但容易写乱。我的建议是如果团队里有人对模板语法不熟优先选 Handlebars因为它的{{#if}}和{{#each}}语义清晰不容易写出副作用。如果追求极致性能可以看看art-template它在 Node 环境下渲染速度很快而且支持模板编译缓存。注意模板文件不要放在源码目录里建议单独建一个templates/目录和生成产物隔离。这样你在配置构建流程时可以明确区分“输入”和“输出”避免误删或误改。2.2 转换层数据从哪来怎么映射到模板模板有了接下来要解决数据来源。t3code 的转换层负责把各种输入JSON 配置、数据库表结构、API 描述文件、甚至 Excel 表格转换成模板能直接消费的数据对象。这一步是整个流程里最灵活、也最容易出问题的地方。举个实际场景你要根据一份 API 定义文件生成前端请求代码。这份文件可能是 Swagger/OpenAPI 格式里面描述了每个接口的路径、方法、参数、返回值。转换层要做的事就是遍历这份定义提取出每个接口的operationId、path、method、parameters然后组装成一个数组每个元素对应一个接口。模板层再遍历这个数组为每个接口生成一个函数。这里的关键在于数据结构的规范化。我见过很多团队在这一步偷懒直接把原始 JSON 丢给模板结果模板里全是{{data.paths[/user].get.parameters[0].name}}这种深层嵌套的表达式可读性极差而且一旦源数据结构变化模板就要跟着大改。正确的做法是在转换层做一次“扁平化”和“重命名”把深层嵌套的数据整理成模板友好的扁平对象。比如// 转换前原始 OpenAPI 片段 { paths: { /user: { get: { operationId: getUser, parameters: [{ name: id, in: query, type: string }] } } } } // 转换后模板友好 { apis: [ { name: getUser, path: /user, method: get, params: [{ name: id, location: query, type: string }] } ] }这样模板里只需要写{{#each apis}}然后直接访问{{name}}、{{path}}清晰得多。转换层的代码通常不复杂但需要你对源数据格式和目标模板需求都有清晰的认识。我的经验是先把模板写出来看看模板里需要哪些字段再倒推转换层要输出什么结构这样比先写转换再写模板效率高得多。2.3 目标层生成后的代码怎么落地目标层是最后一步把渲染好的代码字符串写入文件系统。听起来简单但这里有几个细节决定了整套方案好不好用。第一个细节是文件路径的生成。你不能把所有生成的代码都塞进一个文件里那样维护起来就是灾难。通常的做法是在数据里为每个模块指定一个outputPath比如src/api/user.js、src/api/order.js然后渲染时按路径分别写入。路径的命名规则最好和业务模块保持一致这样生成后的代码目录结构一目了然。第二个细节是覆盖策略。如果目标文件已经存在是直接覆盖、跳过、还是合并直接覆盖最简单但风险也最大万一有人手动改过生成的文件覆盖后就丢了。跳过则会导致代码不同步。我的建议是生成的文件头部加一行注释标记比如// AUTO-GENERATED BY T3CODE, DO NOT EDIT然后在写入前检查这个标记如果有标记说明是自动生成的可以安全覆盖如果没有标记说明是手动创建的应该报错或跳过提醒开发者处理冲突。第三个细节是格式化。模板渲染出来的代码缩进和换行往往不完美尤其是嵌套的{{#each}}会导致缩进错乱。这时候可以接一个格式化工具比如 Prettier在写入前对代码字符串做一次格式化。Prettier 提供了 Node API可以按文件类型调用非常方便。这一步虽然增加了一点构建时间但生成的代码可读性会提升一个档次代码审查时也不会因为格式问题被挑刺。3. 动手搭一套最小可用的 t3code 流程3.1 环境准备与依赖选择要跑通一套 t3code 流程你不需要安装什么庞大的框架几个 npm 包就够了。我列一下我常用的组合用途推荐包说明模板引擎handlebars语法严格适合团队协作文件操作fs-extra比原生 fs 多了 ensureDir、copy 等实用方法格式化prettier支持多种语言API 稳定数据校验ajv如果输入数据来自外部建议做一次 schema 校验命令行交互commander如果需要做成 CLI 工具用它解析参数安装命令很简单npm install handlebars fs-extra prettier ajv commander --save-dev这里我特意把依赖放在devDependencies里因为代码生成通常是开发阶段的事不应该打包进生产环境。如果你的生成流程需要在 CI 里跑那放在devDependencies也没问题CI 安装依赖时会一并装上。提示如果你的项目用的是 TypeScript建议再装一个ts-node这样转换层和生成脚本可以直接用 TS 写类型提示会让数据映射少出很多错。3.2 写一个最简单的模板从 API 函数生成开始我们先从一个最小场景入手根据一份 JSON 配置生成一组 API 请求函数。假设配置文件api-config.json长这样{ baseUrl: https://api.example.com, apis: [ { name: getUser, method: GET, path: /user/:id }, { name: createOrder, method: POST, path: /order }, { name: listProducts, method: GET, path: /products } ] }对应的 Handlebars 模板api.hbs可以这样写// AUTO-GENERATED BY T3CODE, DO NOT EDIT const BASE_URL {{baseUrl}}; {{#each apis}} export async function {{name}}(params) { const url BASE_URL {{path}}.replace(/:(\w)/g, (_, key) params[key]); const response await fetch(url, { method: {{method}}, headers: { Content-Type: application/json }, {{#if (eq method POST)}}body: JSON.stringify(params.body),{{/if}} }); if (!response.ok) { throw new Error(Request failed: ${response.status}); } return response.json(); } {{/each}}这个模板里用到了{{#each}}遍历接口列表用{{#if (eq method POST)}}做条件判断。注意eq是一个自定义 helperHandlebars 本身不提供相等判断需要自己注册Handlebars.registerHelper(eq, (a, b) a b);渲染脚本generate.js大概长这样const fs require(fs-extra); const path require(path); const Handlebars require(handlebars); const prettier require(prettier); Handlebars.registerHelper(eq, (a, b) a b); async function generate() { const config await fs.readJson(path.join(__dirname, api-config.json)); const templateSource await fs.readFile(path.join(__dirname, templates/api.hbs), utf-8); const template Handlebars.compile(templateSource); const rawOutput template(config); const formatted await prettier.format(rawOutput, { parser: babel }); await fs.ensureDir(path.join(__dirname, src/api)); await fs.writeFile(path.join(__dirname, src/api/index.js), formatted, utf-8); console.log(API code generated successfully.); } generate().catch((err) { console.error(Generation failed:, err); process.exit(1); });跑一下node generate.js你就能在src/api/index.js里看到格式化好的代码。这个流程虽然简单但已经包含了 t3code 的核心三要素模板、数据、目标文件。你可以在这个基础上逐步扩展比如增加更多模板、支持多种输出格式、接入数据库表结构等。3.3 把生成流程接入构建脚本生成脚本写好了下一步是让它自动跑起来而不是每次手动执行。最常见的做法是在package.json里加一个 script{ scripts: { gen: node scripts/generate.js, prebuild: npm run gen, build: your-build-command } }这样每次执行npm run build之前都会先跑一遍代码生成。如果你的项目用了husky做 Git hooks还可以在pre-commit里跑一次生成确保提交的代码和配置是同步的。不过这里有个坑如果生成的文件被 Git 追踪每次生成都会产生 diff导致提交历史很乱。我的建议是把生成产物加入.gitignore只在 CI 里生成或者把生成产物提交上去但确保每次生成结果一致比如固定 Prettier 版本和配置。我个人的做法是生成产物提交到仓库但在 CI 里加一个检查步骤跑一次生成然后git diff --exit-code如果有差异就报错。这样既能保证开发者本地有代码可读又能防止有人改了配置忘了重新生成。4. 踩坑实录t3code 流程里最容易翻车的五个地方4.1 模板里的空格和换行生成的代码格式惨不忍睹Handlebars 默认会保留模板里的所有空格和换行包括{{#each}}标签本身占用的行。如果你这样写{{#each apis}} export function {{name}}() {} {{/each}}生成的结果里每个函数前面会多出一个空行因为{{#each}}单独占了一行。解决办法是用~符号去除空白{{~#each apis~}}或者把标签和内容写在同一行。但更推荐的做法是不要纠结模板里的格式生成后统一用 Prettier 格式化。Prettier 会把多余的空行、错误的缩进全部修正你只需要保证生成的代码语法正确即可。不过 Prettier 也不是万能的。如果模板生成的代码本身有语法错误Prettier 会直接报错而且报错信息往往指向格式化阶段而不是模板阶段排查起来很费劲。我的经验是在模板渲染后、Prettier 格式化前先打印一下原始输出确认语法没问题再格式化。可以在脚本里加一个--debug参数开启时把原始输出写到临时文件里方便对比。4.2 数据里的特殊字符引号、反斜杠、换行符代码生成本质上是在拼接字符串所以数据里的特殊字符必须处理。最常见的是字符串里的引号如果 API 描述里有一个字段的默认值是its a test直接渲染到模板里就会变成const defaultValue its a test语法错误。解决办法是在转换层对字符串做转义把单引号替换成\或者用 JSON.stringify 包一层。反斜杠也是重灾区。Windows 路径里的\在模板里会被当成转义字符导致生成的代码路径错误。换行符\n如果直接出现在字符串里也会破坏代码结构。我的建议是在转换层对所有字符串字段做一次统一处理用JSON.stringify转成带引号的字符串然后在模板里直接输出不要自己加引号。比如模板里写const name {{jsonEncode name}};其中jsonEncode是一个自定义 helper内部调用JSON.stringify。这样无论数据里有什么特殊字符都能安全输出。4.3 循环嵌套导致的性能问题如果你的数据量很大比如要生成上千个接口函数Handlebars 的渲染速度可能会成为瓶颈。我实测过渲染一千个简单模板Handlebars 大概需要几百毫秒加上 Prettier 格式化总时间可能超过两秒。如果每次构建都跑一遍开发体验会受影响。优化的思路有两个。一是减少不必要的格式化只在文件内容发生变化时才调用 Prettier。可以在写入前对比一下新旧内容如果一致就跳过。二是把模板编译缓存起来Handlebars 的compile方法每次调用都会重新解析模板如果模板不变可以只编译一次然后重复使用编译后的函数。对于大型项目还可以考虑把生成任务拆分成多个子任务并行执行比如按模块分组每组独立渲染和写入。注意并行写入同一个文件会导致内容错乱所以拆分任务时要确保每个任务写入不同的文件。如果多个任务需要写入同一个文件应该先合并数据再统一渲染。4.4 生成产物和手动代码的冲突这是最让人头疼的问题。假设你生成了一个src/api/index.js里面包含了所有 API 函数。后来某个开发者觉得某个函数需要特殊处理直接在这个文件里改了。下次再跑生成脚本他的修改就被覆盖了。他来找你你也很无奈明明说了不要手动改生成的文件为什么就是不听解决这个问题光靠注释标记是不够的还需要在流程上做限制。我的做法是把生成产物放在一个独立的目录里比如src/generated/然后在业务代码里通过 re-export 的方式引用。比如src/api/index.js里写export * from ../generated/api。这样开发者知道generated目录是自动生成的不会去改如果确实需要定制就在src/api/里写包装函数而不是直接改生成产物。这个约定一旦建立起来冲突就少了很多。4.5 模板版本管理和回滚模板也是代码也需要版本管理。但很多人会把模板和生成脚本混在一起改模板的时候不小心改了脚本或者反过来。我的建议是模板目录单独建一个 Git 仓库或者至少在主仓库里单独一个目录并且给模板文件加上版本号。比如templates/v1/api.hbs、templates/v2/api.hbs。这样当模板升级导致生成结果变化时你可以清楚地知道是哪个版本产生的回滚也有依据。另外生成脚本本身也应该有版本号并且在生成的文件头部写上版本信息比如// Generated by t3code v1.2.0。这样当生成结果出现问题时你可以快速定位到是哪个版本的脚本和模板产生的。5. 进阶玩法让 t3code 从“能用”变成“好用”5.1 多模板组合一个数据源生成多种产物t3code 真正强大的地方在于同一个数据源可以驱动多个模板生成不同用途的代码。比如你有一份数据库表结构定义可以用它同时生成服务端的 Model 类TypeScript前端的 TypeScript 类型定义数据库迁移 SQLAPI 文档的 Markdown 表格单元测试的骨架代码每个模板关注一种产物数据源只有一份。这样当表结构变化时所有相关代码一次性同步更新不会出现“后端改了字段前端类型没改”这种低级错误。实现上你只需要在生成脚本里遍历模板目录对每个模板文件分别渲染和输出即可。输出路径可以在模板文件名里约定比如model.ts.hbs输出到src/models/types.ts.hbs输出到src/types/。5.2 条件渲染与差异化配置不同项目、不同环境可能需要不同的生成结果。比如开发环境需要生成 mock 数据生产环境不需要或者某些模块只在特定客户版本里存在。这时候可以在数据里加一个features字段模板里用{{#if features.mock}}做条件渲染。更灵活的做法是在转换层根据配置文件动态组装数据模板层只负责渲染不关心业务规则。我做过一个项目同一套模板要生成三个客户版本的代码差异点有二十多处。如果每个差异都写一个{{#if}}模板会变得极其臃肿。后来我把差异点抽象成配置项转换层根据客户配置生成不同的数据对象模板里只保留最基础的循环和判断。这样模板保持简洁差异逻辑集中在转换层维护起来清晰得多。5.3 生成结果的自动化校验生成完代码不代表万事大吉还需要验证生成的代码是否可用。最简单的校验是语法检查比如用eslint或tsc --noEmit跑一遍生成产物。如果生成的是 TypeScript 代码tsc能发现类型错误如果是 JavaScripteslint能发现语法问题和潜在 bug。这一步建议放在 CI 里生成后立即校验有问题就阻断构建。更进一步的校验是行为校验。比如生成的 API 函数可以写一个简单的测试mock 一下 fetch调用生成的函数验证请求参数和返回值是否符合预期。这种测试不需要覆盖所有生成产物挑几个代表性的即可。一旦模板或数据出错测试会立刻发现比等到运行时才暴露问题要好得多。5.4 把 t3code 做成团队内部工具如果这套流程在团队里用得好可以考虑把它封装成一个内部 CLI 工具让其他项目也能快速接入。工具的核心功能包括初始化模板目录、根据配置文件生成代码、校验生成结果、清理生成产物。用commander或yargs解析命令行参数用inquirer做交互式配置用chalk输出彩色日志。打包发布到内部 npm 仓库其他项目npm install就能用。不过要注意工具化之后模板的定制化需求会变多。不同项目对模板格式、输出路径、命名规范的要求都不一样。我的建议是工具只提供核心的渲染和写入能力模板和配置完全由项目自己维护。工具通过读取项目根目录下的t3code.config.js来获取模板路径、数据源、输出目录等信息。这样工具保持通用项目保持灵活两边都不会互相绑架。6. 一些个人体会和后续扩展方向t3code 这套东西说到底不是什么高深技术它的价值在于把重复劳动自动化同时保持产物可控。我见过太多团队在“完全手写”和“完全低代码”之间摇摆手写太累低代码又太黑盒。t3code 提供了一条中间路线你用模板描述“代码长什么样”用数据描述“有哪些模块”用脚本把两者结合起来。生成的代码是纯文本可以读、可以改、可以进版本控制没有任何魔法。我在实际使用中最大的体会是模板的设计比转换逻辑更重要。模板写得好转换层就简单模板写得烂转换层就要做大量补丁。判断模板好坏的标准很简单把模板给一个没参与过项目的同事看他能不能看懂生成后的代码结构。如果他能看懂说明模板的抽象层次是对的如果他看不懂说明模板里混入了太多业务逻辑应该把逻辑挪到转换层。后续如果要继续扩展我觉得有几个方向值得尝试。一是接入 AST 做更精细的代码生成比如用ts-morph直接操作 TypeScript 的抽象语法树而不是生成字符串再格式化。这样生成的代码在类型层面更准确也更容易做增量更新。二是支持双向同步不仅从配置生成代码还能从代码反向提取配置这样手动修改的代码也能被纳入生成流程。三是和现有的构建工具深度集成比如做成 Vite 插件或 Webpack loader在构建过程中动态生成代码而不是提前生成静态文件。不过这些都是后话。如果你现在正准备尝试 t3code我的建议是从一个小场景开始比如只生成 API 请求函数跑通整个流程感受一下模板、数据、输出三者的配合。等你熟悉了这套节奏再逐步扩展到更多场景。不要一上来就搞大而全的框架那样很容易在细节里迷失最后不了了之。先跑起来再优化这是我在多个项目里验证过的路径。