接手这个需求之前,我手上已经跑过几套低代码平台,也写过无数套生成模板的脚本。但真正让我决定把“t3code”这套思路沉淀下来的,是一次很崩溃的线上经历:业务方用一个低代码平台搭了三十多个页面,积压了将近半年的迭代需求,因为平台默认的运行时引擎没法支撑,所有页面都得重构。那段时间我就在想,低代码平台如果只解决“搭建”而不解决“代码交付”,到最后一定会把团队逼到墙角。t3code就是在这样的压力下出现的:它不是又一个页面拖拽工具,而是一套面向开发者的低代码代码生成器设计方案,核心是让配置结果真正变成可维护、可评审、可进 Git 仓库的工程代码。这篇文章不聊概念,只聊我在设计 t3code 时踩过的坑、拆过的模块和最终沉淀下来的那套能落地的做法,适合正在自建低代码平台、或者被“生成代码质量不行”折磨的团队参考。
1. 为什么多数低代码平台在做完表单配置后,还需要一个代码生成器
1.1 运行时引擎和代码生成,是两条完全不同的路线
先说一个很多人没想明白的问题:低代码平台渲染页面,和低代码平台生成代码,这两件事看起来都在“把配置变成页面”,但底层逻辑完全不一样。前者是“运行时解释”,平台里跑一个庞大的渲染引擎,配置数据进去、页面在浏览器里被动态组装出来;后者是“编译期产物”,配置数据被翻译成一份真实的、可独立部署的前端工程,平台只是辅助工具。
大多数商业低代码平台走的是运行时解释的路线。优点是上手快,业务人员拖拖拽拽就能看到效果;缺点是平台一旦升级、引擎一旦变更,所有基于这套引擎的页面都可能跟着抖动。更难受的是,开发团队想在这套体系里做代码审查、做自动化测试、做性能优化,几乎无从下手,因为拿到手的只是一份 JSON 配置,不是代码。
t3code 走的是第二条路。配置模型进来,经过解析、校验、模板渲染,最后落盘的是标准化的 TypeScript / React 组件、API 调用模块、类型定义和样式文件。这套代码生成完就可以提交 MR,走团队原有的 Code Review 流程,CI 里跑的 lint、单测、构建全部适用。不是说运行时引擎路线一无是处,而是当你发现团队开始长期维护几十上百个“配置化业务”时,代码生成一定是更稳妥的终点。
1.2 什么信号出现时,说明你的平台真的需要 t3code 了
我这个判断不一定适合所有团队,但如果你遇到下面这些信号,大概率也到了该上代码生成方案的时候:
- 业务方频繁提出“这个字段要联动另一个字段”“这个按钮要根据用户角色隐藏”这类精细逻辑,而可视化配置界面已经塞不下那么多规则。
- 每次改配置,发布之后线上出问题,但你没有办法在本地打断点、看报错堆栈,因为线上跑的是一套动态解释引擎。
- 老板要求做组件库沉淀,但组件库里新增一个组件,低代码平台根本没法直接引用,只能等平台官方适配。
- 测试同学反映没法给页面写自动化用例,因为 DOM 结构是运行时生成的,每次渲染都可能不一致。
- 新来的开发要接手一个“低代码项目”,结果发现项目里全是配置文件,没有一份看得懂的源码。
如果你中了两条以上,就不要在运行时引擎这条路上继续加功能了。t3code 这类代码生成器解决的核心问题,就是把“配置”和“工程化”之间的断层补起来。
2. t3code 的架构拆分:生成器不是“写文件脚本”,而是流水线
2.1 生成流水线的四个阶段
我最早写 t3code 第一版的时候,以为写个 Node 脚本遍历配置然后拼字符串就行。结果第一个项目还没交付就被自己的代码恶心到了:模板里塞满了条件判断,配置字段一改,模板逻辑就崩。后来我参考编译器前端的设计思路,把生成器拆成了解析、校验、渲染、产物整理四个阶段,这套架构一直稳定用到现在。
- 解析阶段:读取输入的项目描述文件(JSON / YAML),标准化成内部模型。这一步要做字段默认值填充、别名归一化,把脏数据挡在流水线之外。
- 校验阶段:检查配置里是否存在非法枚举值、必填字段缺失、类型不匹配、引用了未注册组件等。校验错误必须一次性全部抛出,而不是报一个停一次,否则配置复杂度上来以后,调试体验会非常痛苦。
- 渲染阶段:以“模板文件 + 上下文对象”为输入,逐文件渲染。这里的核心不是模板引擎本身,而是你如何组织模板之间的引用关系和输出路径。
- 产物整理阶段:跑格式化、按需写入、生成 index.ts 桶文件、输出变更清单。这一步在大多数生成器设计里被完全忽略,但实际上决定了生成结果能不能无痛融入现有工程。
2.2 这里的核心难点是“映射关系”,不是模板语法
很多人在设计代码生成器时,把时间全花在钻研模板语法上,这其实是个误区。模板语法再复杂,本质上就是循环和条件判断。真正复杂的,是如何把“配置模型”映射到“代码模型”。同一个配置,在不同的技术栈下生成的代码完全不同。在 React 下,一个带校验的表单字段可能对应一个受控组件加一段 Yup schema;在 Vue 下,可能是 vee-validate 的 rules 对象;在后端,可能是 DTO 里的注解。
我从 t3code 里抽象出来的一张核心表,每个技术栈都要重新维护一份类似的东西:
| 配置模型 | 前端表现层 | 数据校验层 | 类型定义层 | API 请求层 |
|---|---|---|---|---|
| 字符串字段 | Input 组件 | 最大长度、必填 | string | string类型参数 |
| 数值字段 | InputNumber | 取值范围 | number | number类型参数 |
| 枚举字段 | Select / Radio | 枚举合法性 | union 字面量 | enum类型参数 |
| 对象字段 | Card / FieldSet | 子字段联动校验 | interface | 嵌套对象序列化 |
这四层的映射关系要在一个配置文件里统一定义,模板里只做“按 key 取映射结果”的动作。这样做有一个很明显的好处:换技术栈的时候,只需要换映射配置和模板目录,解析、校验、产物整理完全不用动。我在另一个项目里从 React 生成切换到 Vue 生成,只花了三个小时,就是因为映射层和渲染层完全解耦了。
2.3 t3code 内部的关键模块和职责边界
为了让你对整体结构有个直观印象,这是我目前维护的 t3code 目录结构里最核心的部分:
t3code/ ├── core/ │ ├── parse/ # 配置解析、默认值归一化 │ ├── validate/ # 模型校验,收集全部错误 │ ├── context/ # 构建模板上下文对象 │ └── render/ # 调用模板引擎逐文件渲染 ├── templates/ │ ├── react-ts/ │ │ ├── component/ │ │ ├── api/ │ │ └── types/ │ └── vue-ts/ ├── mappings/ │ ├── field-to-component/ │ └── field-to-validator/ ├── formatters/ # 接入 prettier / eslint 修复 ├── reporters/ # 输出变更清单、报告 └── t3code.config.ts # 入口配置文件每新增一个能力,优先考虑放在哪个模块,而不是在模板里硬编码。这个纪律很重要,一旦你把校验逻辑写进模板,模板会迅速膨胀到不可维护,而且没法写单元测。
3. 模板引擎与上下文对象:最容易被低估的两个设计点
3.1 为什么 Nunjucks 比 JavaScript 模板字符串更适合生成代码
t3code 最早用的模板方案是 JavaScript 的模板字符串,后来演变成 Nunjucks。原因很简单:模板字符串的语法会把逻辑写死在你正在生成的那份代码里,而 Nunjucks 这样的模板引擎强制你在“表现层模板”和“逻辑层数据”之间划一条清晰的线。生成出来的代码不应该看到任何模板语法痕迹,Nunjucks 通过自定义标签和过滤器可以实现这种完全隔离。
举个例子,我要生成一个表格列配置。用模板字符串写的时候,你很可能在代码里看见这种混在一起的东西:
const columns = [ ${fields.map(f => `{ title: '${f.label}', dataIndex: '${f.name}' }`).join(',')} ];这看起来没问题,但一旦字段数量多、有特殊类型,代码会迅速变成一团浆糊。Nunjucks 版本长这样:
{% for field in fields %} { title: '{{ field.label | safe }}', dataIndex: '{{ field.name }}', {% if field.type === 'number' %} align: 'right' {% endif %} }, {% endfor %}模板里只有循环和条件,复杂计算全部放在上下文对象里预处理好。生成器团队和模板团队(有时候是同一个人)也能分开维护,模板文件结构保持干净。
3.2 上下文对象的构建,决定模板能有多简单
t3code 的上下文对象分三层构建,这是我连续经历了三轮重构之后才定下来的:
- 项目级上下文:包含项目名、基础路径、包名、技术栈标识。
- 模块级上下文:包含模块内所有页面、API、数据模型的汇总信息,以及模块内部的互相引用关系。
- 字段级上下文:包含每个字段的完整元数据,包括原始配置、类型、校验规则、展示配置,以及一系列派生属性。
派生属性是上下文对象设计里的精髓。比如模板里经常要判断“这个字段在表格里是不是居右显示”,我的模板不直接写field.type === 'number' && field.align,而是在构建上下文的时候预先算一个field.alignInTable属性,模板里只判断if (field.alignInTable)。这样一来,业务规则变化时,只需改 context 构建逻辑,模板一行都不用动。
下面是字段级上下文里我比较常用的一组属性:
| 属性名 | 来源 | 示例 |
|---|---|---|
name | 配置输入 | userName |
label | 配置输入 | 用户名 |
type | 配置输入 | string |
required | 配置输入 | true |
rules | 映射层推导 | [ { required: true, message: '请输入用户名' } ] |
component | 映射层推导 | Input |
alignInTable | 规则推导 | 'left' |
hasDetailField | 规则推导 | false |
上下文对象构建完成后,模板里用的每一个属性都必须有值。我也养成了一个习惯:写模板之前先写一份“上下文对象字段清单”的文档,这个清单比任何架构图都实用。
3.3 模板继承和局部覆盖,是大型项目的救命稻草
当你从一个生成器生成几十个页面的时候,一定会遇到“大部分页面一样,但有个别页面要特殊处理”的场景。一开始我用了大量if (page.name === 'xxx')这种写法,后来代码就废了。
Nunjucks 的模板继承机制帮了大忙。t3code 的页面级模板会拆成base_page.html和区块(block),每个页面配置可以选择自己的模板变体。当某个页面确实特殊时,新建一个变体模板,只覆盖需要变化的 block,而不是改全局模板。这样做的好处是,全局模板更新时,变体模板也能跟着吃到改动,不会因为复制粘贴导致一致性崩盘。
这个设计思路直接决定了生成器能管理多少规模的代码库。没有模板继承,基本超过 20 个页面就乱了;有了模板继承以后,几百个页面依然可以在模板层统一升级。
4. 代码质量保障:生成出来的代码要敢直接进仓库
4.1 为什么我坚持“格式化 + lint”是生成器的内置能力,而不是外部操作
很多代码生成器只负责把文件写出来,格式化交给开发者自己去跑。t3code 最开始也这样,结果生成出来的代码 MR 一大堆格式 diff,Review 体验极差。后来我把 prettier 和 eslint 直接内置到生成流水线里,生成完立即格式化、自动修复,然后再落盘。
关键是应按“每个文件单独执行格式化”,不是对全项目跑一遍,因为全项目格式化会把原有代码的 diff 也搅进来。你只希望看到本次生成涉及的文件的变更,不想让格式化器顺手改了旁边的人写的代码。这个细节很奇怪,但真的影响 MR 体验。
现在 t3code 的产物整理阶段会执行这么一串动作:
- 渲染后的文件先写入临时目录。
- 对临时目录中所有
.ts、.tsx、.vue文件执行 prettier。 - 执行 eslint --fix,并将无法自动修复的告警汇总成报告。
- 将临时目录与目标目录进行差异对比,输出新增、修改、删除文件清单。
- 确认无误后再自动覆盖(或生成到指定输出目录)。
4.2 幂等性校验,防止生成器把已改过的代码又覆盖了
这是一个非常痛的坑:生成器第二次运行的时候,把开发人员手工调整过的文件当作“历史产物”,直接覆盖了。后来我给 t3code 加了幂等性校验:每次生成之前,计算当前目标目录中的文件指纹,如果指纹与上次生成结果完全一致,说明文件没有被手工动过,可以放心覆盖;如果不一致,则把差异列出来,由开发者决定是否覆盖还是保留手动改动。
哈希比较本质上很简单,但设计时要考虑一个问题:生成结果中每次都会变的头注释(比如时间戳)不应该参与指纹。我的做法是先把文件里自动生成的声明头去掉,再计算哈希,避免因为秒级时间戳导致每个文件每次都不一致,整个幂等性机制形同虚设。
| 检查项 | 判定逻辑 | 处理动作 |
|---|---|---|
| 文件是否存在 | 不存在 | 新建 |
| 文件指纹一致 | 一致 | 覆盖写入(无风险) |
| 文件指纹不一致 | 不一致 | 输出差异,人工确认 |
| 目标目录已有废弃文件 | 生成清单不含此文件 | 提示可清理,不自动删除 |
这个机制让我放心了很多。以前团队不敢频繁改生成器模板,因为一改全量生成,不知道哪个文件会被意外动到;现在每次跑完,报告里明确告诉你有哪些文件需要人工确认,哪些可以自动覆盖。
4.3 生成代码的单测,到底谁来写
很多团队纠结于“生成的代码要不要写单测”。我的答案是:生成的代码本身不需要额外写单测,但生成器必须有单测。产物的正确性,通过生成器自身的快照测试来保障。每改一次模板,跑一遍全量快照测试,能直接把模板改坏的地方暴露出来。
快照测里比较关键的是控制“快照文件”的粒度。如果是整个项目级的快照,一个小改动会导致几千行 diff,根本看不出问题;按模块、按文件粒度分别快照,改的是哪个模板、影响的是哪份代码,一目了然。我在 t3code 里把快照按“页面级 + 组件级 + 工具函数级”分层,只有层级之间引用了同一个模板且确实需要联动时才合并快照。
5. 性能与缓存设计:从迭代几百次到每天开生成服务
5.1 全量生成和增量生成的取舍
t3code 一开始是全量生成:无论配置改没改,跑一次就把整个项目重新生成一遍。项目规模小的时候完全没问题,等配置项超过几千条的时候,全量生成要十几秒,并且生成的临时文件对 IO 压力很大。
后来我把增量生成的逻辑做了出来。核心是“指纹比对 + 定向渲染”,把配置按模块拆分,某个模块的配置变了,只渲染这个模块涉及的模板文件。增量生成也不是完全没有代价:模板之间的共享片段如果变了,增量机制会漏掉依赖这部分片段的其他模块。我的解决方式是给模板加依赖声明,模板文件变更时,生成器会把依赖它的所有模块自动标记为需要重新渲染。
增量生成带来的收益非常大。配置迭代效率从几分钟一次变成了秒级反馈,团队也更愿意频繁调整配置来验证生成结果。
5.2 配置解析层加缓存,效果比预期更好
另外一个不起眼但收益很高的优化是配置解析缓存。项目里的配置解析结果(原始 JSON 转成内部模型、字段派生属性计算完的上下文)缓存到内存里,只有在配置文件真正发生变更时才重新解析。因为在一个生成服务里,很多时候采用同一个配置模型反复生成不同变体的模板,频繁重复解析完全浪费。
这里要注意缓存的一致性问题。我踩过一个坑:直接用文件修改时间判断缓存是否失效,结果某些编辑器保存文件时不更新时间戳,导致配置改了但缓存没刷新。后来改成同时检测文件内容哈希,彻底解决了这个问题。代价是多一次哈希计算,但相比反复解析配置便宜得多。
5.3 作为本地 CLI 与服务化,性能策略完全不同
t3code 既支持命令行运行,也支持起一个 HTTP 服务提供在线生成能力。同一个核心流水线,两种场景下的性能调优思路不一样:
- 本地 CLI 场景:优先保证每次结果绝对准确,缓存可以保守一点,甚至可以全量解析,因为单个开发者本机跑,速度不是瓶颈。
- 服务化场景:多团队并行调用,要做请求级缓存、预热热门模块,减少并发冲击。
我在公司内部把 t3code 做成服务之后,在项目初始化或模板升级的时候,可以一次性批量生成几十个模块,也要考虑是否需要做并发控制,避免磁盘 IO 瞬间拉满导致其他应用受影响。目前的方案是简单的工作池:并发数限制在 CPU 核心数的两倍,队列排队处理,实测下来机器负载稳定很多。
6. 团队协作与生成器的边界:什么时候该继续扩展,什么时候该收手
6.1 模板仓库和生成代码仓库分离,是长期维护的基础
一开始我把模板和生成的代码放到同一个仓库里管理,结果模板一改,生成代码的 MR 也跟着出来,Review 的人永远分不清“这是模板变更”还是“这是产物变更”。后来强制规定:模板仓库单独一个 repo 或一个独立目录,生成的代码产物仓库单独一个 repo。模板改动走模板仓库的 MR,产物改动走产物仓库的 MR,评审对象彻底分开。
与此同时,我要求产物仓库里必须保留一份生成配置和生成器版本记录,每次生成的代码文件头部会带有生成器版本号。这样一旦产物出现诡异问题,可以快速定位是哪个版本的模板生成出来的。
6.2 有些逻辑永远不该进模板,宁可多写两行手写代码
t3code 用久了,团队很容易产生“所有代码都要用模板生成”的执念。这不是个健康的信号。我遇到过最典型的是一个复杂图表页,光配置模型就写了六百多行,最后生成的代码还需要手工大改。后来我直接让这个页面脱离生成体系,改用原生代码写,反而比维护配置快得多。
我的判断标准是:如果需要为单个页面单独写超过一份模板变体,同时这个页面生成后又必然要手工改代码,那它就不适合走代码生成流程。生成器的边界在于“配置化能准确描述的重复劳动”,而不是“万物生成”。作为平台方,有时候要勇敢地把某个页面踢出生成范围,给后续维护留一条活路。
6.3 配置变更应该走评审流程,而不是直接改配置
许多团队的配置都直接放在代码仓库里,改一下就发布,没有任何评审。这在生成器体系下隐患更大,因为配置变更可能影响几十个文件的生成结果。我强烈建议配置变更也纳入 Code Review:开发者在 MR 里同时看到“配置文件改动”和“触发的产物改动”,因为两者都会展示,评审人可以根据产物变化确认这个配置改动的影响边界。
这也是为什么上面说产物仓库要和模板仓库分离:分离之后,配置在产物仓库(或者一个专门的配置仓库)里,它的 MR 会自然关联产物文件的改动,评审人看着产物 diff 判断“这个配置改对了没有”非常直观。
最后补一段实操体会
如果你要自己搭 t3code 这类生成器,我最大的建议是:第一版千万别求全,先只覆盖一个核心业务模块、一种技术栈、一份模板,跑通“配置 → 代码 → 构建 → 测试”全链路。不要在第一天就把多技术栈、动态模板、服务化全部铺开,否则大概率会淹没在配置和模板的适配里。
真正常见的坑往往出现在你觉得“已经稳定了”之后的第一次大改版。改模板之前一定要先跑一遍快照测试,把变更影响面打开看清楚。我踩过一次:改了一个公共组件模板的命名规则,结果全局三百多个文件同时变动,幸好有快照测试和指纹比对兜底,没有造成线上事故,但那次之后我彻底记住了“生成器能力越大,越要用工程手段管住它”。
如果你也在做类似的代码生成器,建议尽早把模板继承、上下文对象分层、指纹比对这三件事推进去。前两件决定模板能维护多久,最后一件决定你敢不敢频繁改模板。等这三件都齐了,再考虑增量生成、服务化这些锦上添花的能力,节奏就会顺很多。