大概半年前我把自己电脑里十几个散落各处的 shell 脚本合成了一个项目代号就叫 rea英文全称是 Rapid Engineering Assistant。简单说它是一个面向日常工程重复动作的命令行工具用来解决代码模板生成、目录初始化、批量改名、文件搬运这一类事情。当时最让我烦躁的不是某个需求做不出来而是同样一套初始化动作每换一台电脑、每接一个新项目都要重新手工点一遍。rea 把这些动作收敛成一份配置配合少量模板文件就能在本地快速生成一套结构一致、可直接进入开发的代码骨架。这篇文章不是想把它包装成什么惊天动地的框架我只想把我怎么设计、怎么实现、怎么踩坑的过程完整写下来如果你也在做类似的工程效率工具应该能少走不少弯路。1. 需求与设计思路拆解1.1 代号为什么叫 rea它到底想解决什么问题先说代号。rea 是我随手取的三个字母刚好是 Rapid Engineering Assistant 的缩写但这并不重要重要的是它背后的痛点。我在一段时间里连续处理过好几类重复工作新项目要建一整套目录结构从src、tests、docs到配置文件手写容易漏复制旧项目又容易带进来一堆用不上的东西组件库新增一个组件要从命名空间、类型定义、样式文件到测试文件四五份文件逐一手动创建文档站点要批量重命名文件顺便把文件内部引用的路径也一起改掉靠编辑器逐个替换效率低还容易出错。rea 的目标就是把这类“有规则但没价值”的动作全部脚本化用一份配置描述清楚三个问题从哪里取模板、输出到什么位置、生成之后要执行什么额外动作。它不需要是一个复杂的平台也不需要服务端和数据库只要在命令行里跑一下结果马上可见。我非常看重一点生成出来的文件必须是普通可读、可审查的代码而不是封装在某个黑盒运行时里的抽象对象。因为工程文件最终要提交到代码仓库里团队里的人必须能直接阅读和修改任何“运行时动态生成”的设计都会增加排查问题的成本。1.2 两条关键设计约束设计约束第一条普通工程师不应该因为使用 rea 而去读源码。使用方式应该只包含两件事改配置文件改模板文件。配置文件用 YAML 写模板文件用常见的文本文件格式里面允许出现少量占位符和表达式。这样哪怕没有一点编程经验的新人也能在五分钟内看懂某个模板是怎么生产的。第二条所有操作必须可回滚。rea 不是那种一执行就不可逆的工具。每次生成新文件之前它会检查输出目录下是否已经有同名文件如果是新文件就直接生成如果是已有文件默认跳过并要求加--force参数才覆盖。覆盖前还会先把旧文件备份到一个隐藏目录确保万一生成结果不对还能用一条命令恢复。这个设计看起来牺牲了一点便利性但实际操作中我见过太多脚手架工具一跑就把整个目录冲掉的情况宁可多做一次检查也不要让用户面对无法挽回的后果。1.3 为什么不做成 Web 服务而是本地 CLI团队里有人问过我既然要共享模板为什么不部署一个 Web 服务大家在浏览器里填表单生成代码。我认真想过这个问题最后还是坚持本地 CLI 的路线。原因有几点第一本地工具可以自然嵌入 Git hooks、CI 流程和编辑器插件Web 服务很难做到这些场景的无缝集成。工程代码本来就是本地文件命令行工具直接操作文件系统链路最短也最容易调试。第二代码模板里有大量内网路径、私有依赖和团队习惯约定这些东西放在本地仓库里比放在一个中心服务器上更安全也不需要额外维护访问权限。第三Web 服务天生有延迟和可用性问题如果 CI 里每一步都要请求远程接口出故障的概率会上升而本地 CLI 的依赖只是一台机器和一个可执行文件。当然本地 CLI 也有代价最大的问题是模板分发的同步。我的解决方式很朴素把所有模板和配置都放进 Git 仓库团队拉代码时自然就同步了。这已经足够解决大部分场景。2. 核心技术与实现原理2.1 整体架构配置层、模板层、执行层rea 的整体结构被我拆成三个层次每一层只关心自己的事。配置层负责读入rea.config.yml解析出任务列表、模板目录、输出目录和变量定义模板层负责处理模板文件里的占位符和表达式输出纯文本执行层负责调度整个流程包括目录扫描、文件渲染、权限设置、备份和钩子命令触发。我习惯把这个流程理解成一条流水线。配置层是流水线的入口它把用户意图变成一张可执行的任务清单模板层是加工车间每个模板文件进入车间后被替换上真实变量执行层是传送带末端把成品放到指定位置。这样的分层带来的直接好处是每一层都可以单独测试。我的测试用例里最基础的就是直接调用模板渲染函数输入一个模板字符串和一组变量断言输出结果。这比端到端测试稳定得多也快得多。一个简化后的核心伪代码大概是这样的def run(config): tasks parse_config(config) for task in tasks: matched scan_task_files(task.template_dir) for file in matched: content read_text(file) rendered render_template(content, task.variables) output_path resolve_output_path(task.output_dir, file, task.path_mapping) backup_if_exists(output_path) write_text(output_path, rendered) run_hooks(task.hooks)看起来简单但真正做好细节很花功夫。比如scan_task_files要考虑忽略编辑器临时文件、隐藏文件和二进制文件render_template要考虑语法错误提示的可读性resolve_output_path要考虑模板目录结构与输出目录结构的一致性。任何一个环节偷懒最后都会在真实项目里爆发。2.2 模板引擎从字符串替换到 AST 表达式rea 最早一版模板引擎就是简单的字符串替换用{{name}}这种形式把变量名直接替换成真实值。跑了一周我就发现问题了实际项目里需要的不是简单替换而是“把button-submit转成ButtonSubmit”这种派生逻辑。如果只做字符串替换那用户必须在配置文件里把每个派生值都手动写一遍又回到了重复劳动的老路。后来我把模板引擎改成基于 AST 的表达式解析。模板文件先被解析成节点树普通文本是文本节点{{ }}内部是表达式节点。表达式节点可以是一个变量名也可以是一个函数调用比如{{pascalCase name}}。渲染时遍历节点树对表达式求值。这样模板从一个“带洞的字符串”升级成了一个“带逻辑的模板”。举个例子下面是一个组件模板的核心片段import React from react; export interface {{pascalCase name}}Props { className?: string; } export const {{pascalCase name}} ({ className }: {{pascalCase name}}Props) { return div className{className}{{pascalCase name}}/div; }; export default {{pascalCase name}};当用户执行rea generate component button-submit时rea 不仅拿到原始名字button-submit还会通过内置的pascalCase、camelCase、constantCase等函数派生出多个可用变量模板里只需要写对应的表达式。最开始我担心 AST 会增加理解成本但从使用反馈来看只要模板作者会写 JavaScript 语法就能快速适应。2.3 任务编排与钩子系统一个工程动作往往不是“生成文件”这么简单。比如生成一个组件后我还要更新组件总索引文件初始化一个新模块后我要在路由表里注册页面生成完一批文档后我要自动跑一遍格式检查。这些动作如果都靠用户手动拼装命令那 rea 的价值就少了一半。所以我设计了一套轻量的钩子机制任务配置里可以声明多个生命周期事件每个事件对应若干条命令。rea 的生命周期分成四个阶段before、after、on-error、on-rollback。before阶段一般用来做预检比如检查输出目录是否存在、检查模板目录是否有语法错误after阶段用来做文件生成后的收尾工作比如更新索引、格式化代码、启动测试on-error阶段在生成失败时执行可以记录错误日志或者清理半成品目录on-rollback阶段在前一次备份恢复完成后执行用于通知用户恢复结果。钩子命令默认走操作系统的 shell因此可以调用任意工具链。这里有一个经验不要在设计钩子时过度追求“跨平台全兼容”。我最初所有钩子命令都要求是纯 Node 脚本后来发现用户很多时候就是想顺手跑一下团队已有的 shell 脚本强行限制反而碍事。现在我的做法是提供shell和node两种执行器默认shell关键路径上的钩子建议用node以保证跨平台稳定。2.4 并发设计与增量更新rea 需要处理的文件数量可以很大尤其是初始化整个项目的时候模板目录里可能有上百个文件。最初我图省事用同步方式一个个处理结果生成一个中型项目耗时十几秒用户体感很糟。后来我换成了有界并发的方式用一个并发池控制同时处理文件的数量默认 8 路并发。实测下来同样一个项目从 13 秒降到了 3 秒以内效果非常明显。并发处理有个隐患如果多个任务写同一个输出文件会产生竞争。所以我在执行层里加了两道防线。第一道防线是目录锁同一个任务实例在同一时间只能有一个进程持有写权限第二道防线是文件级校验写出前先检查输出路径是否已经在本次执行中生成过如果生成过就以后生成的文件为准。这里的本质是“宁可慢一点也不要让并发导致状态混乱”。增量更新则是另一个关键点。rea 在渲染单个文件前会先比较原模板和输出文件的元数据。如果模板文件没有变化且对应的输出文件已存在且内容 hash 一致就跳过这次写入。这个优化看起来不起眼但在大型代码库中它能让重复执行rea generate变得几乎无感。hash 计算我用的是内容哈希而不是简单的修改时间因为修改时间在跨平台 git 同步时非常不可靠。3. 实操从零到一跑通 rea3.1 初始化一个工程假设你现在拿到一份 rea 的发行版第一件事是在项目根目录执行rea init它会询问几个问题项目名称、优先使用的模板集、是否导入历史配置。初始化完成后目录下会多出两个东西rea.config.yml和templates/目录。配置文件是核心模板目录里则放着所有可用模板。初始化的配置文件大致长这样version: 1 project: name: demo-project language: typescript templates: component: input: templates/component output: src/components/{{pascalCase name}} overwrite: false variables: author: fe-team这份配置表达的意思很简单执行rea generate component button-submit时读取templates/component目录下所有文件渲染后输出到src/components/ButtonSubmit变量里除了命令行传入的name之外会自动注入author。如果某个输出文件已经存在不要覆盖除非用户显式加--force。3.2 编写第一批模板模板文件本身和普通代码文件没有太大区别只是在需要变的位置加上占位符。以组件模板为例templates/component目录下会有四个文件templates/component/ index.ts.tpl index.test.ts.tpl index.module.css.tpl README.md.tpl对应关系是模板层的核心设计模板文件路径上有一个.tpl后缀rea 在渲染时会自动去掉这个后缀保留其余结构。比如index.ts.tpl会生成index.ts。目录结构也支持动态命名假设我想让每个组件输出到以自身命名的子目录里只需要在配置里把output写成src/components/{{pascalCase name}}rea 会按真实值创建目录。这种设计并不神奇但它保证了模板在磁盘上的结构和生成后的结构几乎一一对应特别容易理解。一个模板文件内部的写法大概长这样import { FC } from react; import styles from ./index.module.css; export interface {{pascalCase name}}Props { title: string; } export const {{pascalCase name}}: FC{{pascalCase name}}Props ({ title }) { return ( div className{styles.wrapper} h2{title}/h2 /div ); };如果你需要根据条件输出不同内容模板里也能用{{#if bool}} ... {{else}} ... {{/if}}这类语法。这一点方便的地方在于同一个模板可以用一个布尔变量控制是否生成某段代码不用维护两份几乎一样的模板。3.3 执行生成与二次修改模板配好了执行命令非常简单rea generate component button-submitrea 会打印每一步执行过程包括匹配到的文件、渲染后输出到哪个路径、耗时多少毫秒。如果配置中开启logLevel: debug它还会输出每个文件的具体渲染差异方便排查问题。生成完毕后打开src/components/ButtonSubmit/index.ts你会看到所有占位符已经被替换成正确的大小写格式。二次修改有两种常见需求。一种是改模板本身改完后重新执行rea generaterea 会用新模板重建输出。但要注意如果输出的文件已经在第一次生成后被手工修改过重新生成会失败因为文件已存在且overwrite: false。这是有意为之目的是防止意外覆盖掉人工改动。另一种是改配置文件里的变量改完再生成效果类似。我强烈建议在第一次跑通后把生成的代码和模板文件一起提交进版本库然后刻意改坏一个模板再重新生成观察 rea 是否按照预期保护了已有文件。这个测试只需要十分钟但能帮你确认自己真正理解了这套流程。3.4 接入 Git Hooks 与 CIrea 最顺手的一个场景是不需要主动生成而是让它跟着工程流程自动跑。我通常会在项目里加两个接入点。第一个是 Git 的pre-commithook在提交前自动检查所有模板生成的代码是否和模板一致。具体做法是加一条命令rea generate --check这个参数会让 rea 进入检查模式只计算差异不实际写入文件。如果某个人修改了模板但没有重新生成检查就会失败提交被拦住。要知道这类问题如果不挡在提交前早晚会在代码评审时被发现到时候再改就麻烦得多。第二个接入点是 CI 流程。我会在流水线里单独加一个 job跑一遍rea generate然后把生成的目录和基准分支一起用 diff 工具对比。如果差异不为空直接报错。这保证了模板在任何分支上的变更都能得到验证而不是只在某个人的电脑上有效。接入 git hooks 有一个小技巧不要让 hook 脚本直接调用全局安装的 rea因为不同开发者的机器上全局 bin 路径不一定一样。我建议在 package.json 里配置一个开发依赖然后 hook 里调用npx rea generate --check这样版本是锁定的路径也不会出问题。4. 常见问题与排查实录4.1 模板渲染出现中文乱码我在 Windows 上踩过一个很经典的坑模板文件是 UTF-8 编码但某位开发者的系统区域设置是简体中文保存文件时编辑器自动转成了 GBK 编码。rea 读取文件时默认按 UTF-8 解析碰上 GBK 内容就会读出乱码。解决思路是双重的。一方面rea 在读取模板时会做编码探测遇到不合法 UTF-8 序列就尝试用 GBK 解码。另一方面我会在团队文档里明确要求所有模板文件统一保存为 UTF-8 无 BOM。这里有个容易被忽略的细节UTF-8 带 BOM 的模板在首行会有一个不可见字符用某些模板引擎渲染时不会出错但生成的代码文件在 Linux 下执行可能会报告: not found一类的错误。所以模板文件统一加一个规范化检查比较省心。4.2 文件监听模式丢事件rea 的watch模式可以监听模板目录变化一旦有文件改动就自动重新生成。它在小规模测试时一切正常但在真实编辑器里表现不稳定。后来发现问题出在“编辑器批量保存”上。IDE 保存一个文件可能会先写临时文件再 rename 到目标路径甚至连续触发多次 modify 事件。rea 的程序逻辑只监听change事件没处理rename和临时文件的干扰所以丢事件就很正常了。修复方式是监听多个事件并把事件丢进一个冷却窗口里比如 500ms 内同一路径的所有事件合并成一次执行。同时还要在文件扫描里忽略以~开头、以.tmp结尾的临时文件。这个坑不只在 rea 里存在几乎所有文件监听工具都需要面对所以我在代码里单独抽象了一个FsWatcher模块方便统一处理。4.3 大模板目录扫描慢第一次在真实项目里跑时模板目录里塞了上千个文件rea 花了大概 8 秒才完成扫描。我最初用的是递归readdir每次读目录都要做一次全量 I/O文件一多就明显变慢。后来我改成流式扫描不再把全部路径一次性加载进内存而是以“目录为单位”分批读取每拿到一批就交给并发池去处理。还有一个优化是用“基于扩展名快速筛掉非模板文件”。rea 只对.tpl后缀的文件做渲染对其他文件直接走拷贝逻辑。这样扫描阶段就可以用后缀名过滤减少不必要的读取操作。我对比过数据优化前 1000 个文件扫描约 8.4 秒优化后约 2.1 秒提升非常明显。如果你的工具也会处理大量文件记住一个原则能早过滤就早过滤能延迟读就延迟读。4.4 路径分隔符在 Windows 下不一致代码生成工具最容易翻车的地方就是路径拼接。用 Node.js 开发时path.join在 Windows 下会输出反斜杠在 Linux 下会输出正斜杠。模板目录里如果写了相对路径比如src/components/{{pascalCase name}}/index.ts在 Windows 上生成出来可能是src\components\ButtonSubmit\index.ts。大部分情况没问题但一旦有人把生成的路径字符串写进一个需要URL格式的文件里反斜杠就会直接报错。解决方式是定义统一规则rea 内部所有面向展示和配置文件输出的路径一律用正斜杠只有真正传给文件系统操作的路径才用系统原生分隔符。我在配置解析阶段就把路径标准化避免在渲染模板时看到不一致的分隔符。这个问题排查起来非常隐蔽它不会让你生成文件失败但会让你生成的文件内容在不同操作系统上出现差异进而导致 CI 里模板检查失败。下面是几个高频问题速查表方便你直接对照现象可能原因处理方式模板渲染中文乱码模板文件不是 UTF-8 编码统一改用 UTF-8 无 BOM读取时做编码探测文件监听只触发一次或无反应编辑器先写临时文件再 rename同时监听 change/rename加冷却窗口生成速度特别慢全量扫描所有文件改成流式目录扫描按扩展名过滤非模板文件同一次生成覆盖了不该覆盖的文件并发任务写同一目标加目录锁和文件级校验CI 模板检查因路径分隔符失败Windows 反斜杠进入生成内容统一转正斜杠系统调用时再转原生覆盖已有文件时无法恢复没有备份机制开启默认备份到/rea-backup用回滚子命令恢复4.5 钩子命令失败后留下半成品这个坑发生在生成流程的后半段。rea 把文件都写好了然后执行after钩子里的格式化命令格式化工具有一个文件解析报错导致整个生成任务失败。问题是此时文件已经被写入了用户看到的报错并不清楚是模板的问题还是格式化的问题。我在 on-error 阶段加入了中间态清理机制。具体逻辑是rea 在写入文件前先把内容放到一个临时缓冲目录全部文件渲染并校验成功后再一次性提交到真实输出目录。如果提交过程中某个文件写入失败就把已写的部分退回缓冲目录同时执行 on-rollback 钩子。虽然这会让整体速度稍微慢一点但换来的是一种“要么不生成要么生成完整一套”的确定性。这套机制也带来一个额外价值rea 可以在“提交前”做一次全局的渲染后检查比如检查生成的代码是否引用了未定义变量检查文件路径是否有重复。这些检查如果分散在单个文件层面反而做不了。所以从 v2 开始我把提交前的校验做成了一个可插拔的扩展点团队可以根据自己项目的特点注册自定义校验规则。5. 最后说点项目之外的体会rea 这个项目做到后面我发现真正有价值的往往不是代码本身而是对“工程效率工具该长什么样”的判断。最初我也想把模板引擎做得更强大把配置系统设计得像一个小型编程语言把钩子做成插件市场。好在一段时间的实测让我及时踩了刹车。工程效率工具最忌讳的是“把所有可能性都留给用户”因为每多一个抽象概念用户的学习成本就高一层。rea 现在仍然坚持几个非常朴素的理念配置只描述“做什么”模板只描述“长什么样”钩子只描述“还缺什么”。复杂的逻辑应该放在真正的代码里而不是塞进一个生成工具。根据我自己的使用习惯还有两个建议是愿意推荐给所有人的。第一个建议是任何模板驱动的工作流一定要把“生成后的文件”本身纳入代码评审。模板写得再漂亮生成结果如果没有人review早晚会积累出问题。第二个建议是尽快加入--check这种只读验证模式。它在你没有意识到问题的时候像一个安静的路障挡住那些“本来不该发生”的变更。如果你也在做类似的东西我建议你把这两个能力排到优先级最高的位置。