Plop 代码生成实践:从模板到批量文件输出

Plop 是什么

Plop 是一个轻量级代码生成框架,核心能力只有一件事:读取模板 + 填充数据 → 输出文件。它基于 Handlebars 模板引擎,通过 JSON 数据驱动模板渲染,适合任何"根据数据批量生成结构化文件"的场景。

Plop 本身不提供 CLI 入口的绑定限制,可以自由嵌入到自定义脚本中。它的 Generator 机制允许在一个 plopfile 中注册多个生成器,每个生成器独立定义数据源、预处理逻辑和输出模板。

Handlebars 模板基础

Handlebars 使用双大括号 {{ }} 作为变量插值标记,三重大括号 {{{ }}} 表示不转义输出。核心控制结构:

{{#each array}} 遍历数组,内部用 {{{this}}} 引用当前元素。

{{#if value}} 条件判断,值为空数组、空字符串、nullundefinedfalse 时走 {{else}} 分支。

波浪号 ~ 用于控制空白。标签左侧加 ~(如 {{~#each}})吞掉左边的空白,右侧加 ~(如 {{#each~}})吞掉右边的空白。

Handlebars 换行控制

each 块在换行处理上有一些隐蔽行为,写模板时如果不清楚这些规则,生成的文件会出现多余空行或缺少换行。以下用数组 [1, 2, 3] 逐步演示,每个元素的渲染内容统一用 this 表示,用 \n 标记换行位置以便观察。

第一步,基础模板:

start
{{#each items}}
{{{this}}}
{{/each}}

把模板中的换行位置显式标出来:

start\n{{#each items}}\n{{{this}}}\n{{/each}}

模板里有 4 个 \nstart 后面一个,{{#each items}} 后面一个,{{{this}}} 后面一个(每次循环都会产生),{{/each}} 前面一个(每次循环都会产生)。

现在开始推测 Handlebars 的渲染行为,如果不做任何处理,这 4 个 \n 全部渲染为真实换行,期望输出应该是:

start\n
\n
this\n
\n
this\n
\n
this\n

每个元素前后各有一个换行,元素之间会出现空行。

第二步,在第一步基础上,如果单独去掉每个元素左侧的换行。也就是 {{#each items}}{{{this}}} 之间的那个 \n。期望输出如下:

start\n
this\n
this\n
this\n

元素之间的空行消失了,每个元素各占一行,最后一个元素右边仍然有 \n

第三步,在第一步基础上,如果单独去掉每个元素右侧的换行。也就是 {{{this}}}{{/each}} 之间的那个 \n。期望输出如下:

start\n
\n
this
\n
this
\n
this

由于this右边没\n,所以没有实际的换行渲染,期望输出应如下:

start\n
\n
this\n
this\n
this

元素之间的空行消失了,每个元素各占一行,第一个元素左边仍然有 \n

第四步,执行脚本,查看 Handlebars 的实际输出是什么:

start\n
this\n
this\n
this

和第二步中列出的原始 \n 位置对比,最后一个元素右侧的 \n 也没了。由此推测 Handlebars 的 each 有两条自动行为:自动去掉每个元素左侧的换行,以及自动去掉最后一个元素右侧的换行。具体实现原理需要查看 Handlebars 源码,暂不深究,但这两条行为在写模板时是必须清楚的。

实际行为总结:
Handlebars 自动去掉 {{#each}} 标签后的第一个换行,不需要任何额外处理。Handlebars 自动去掉最后一个元素右侧的换行,不需要 {{~/each}} 参与。

空数组的情况:当 items 为空数组时,{{#each items}} 块整体不渲染,但开始标签前的换行仍然存在。所以如果 beginLines 未配置(为空数组),docker run--name 之间不会出现多余空行。

项目结构

以下用一个 Docker 运行命令生成工具作为完整示例。项目结构如下:

generate-content/
├── bin/
│   └── generate.js          # 入口脚本
├── src/
│   └── plopfile.js          # Plop 配置(生成器注册)
├── docker/                  # 一个生成模块
│   ├── services.json        # 数据源
│   └── template/
│       └── docker.hbs       # Handlebars 模板
├── output/                  # 生成结果输出目录
└── package.json

每个"生成模块"是一个独立目录,包含 services.jsontemplate/ 目录。入口脚本会自动扫描根目录下的所有模块。

package.json

{
  "name": "generate-content",
  "version": "1.0.0",
  "description": "代码/文件生成工具(Plop 实现)",
  "scripts": {
    "generate": "node bin/generate.js"
  },
  "devDependencies": {
    "plop": "^4.0.1"
  }
}

唯一的运行时依赖就是 plop

入口脚本 bin/generate.js

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const rootDir = path.resolve(__dirname, '..');

/** 扫描根目录,找到所有模块子目录 */
function listModules() {
  return fs.readdirSync(rootDir, { withFileTypes: true })
    .filter(d => d.isDirectory())
    // 排除系统目录
    .filter(d => !['node_modules', 'src', 'output', 'bin'].includes(d.name))
    // 模块目录的特征:包含 services.json 或 template/ 子目录
    .filter(d => {
      const dir = path.join(rootDir, d.name);
      return fs.existsSync(path.join(dir, 'services.json'))
          || fs.existsSync(path.join(dir, 'template'));
    })
    .map(d => d.name)
    .sort();
}

/** 打印可用模块列表 */
function printModules(modules) {
  console.log('\n可用模块:');
  modules.forEach(m => console.log(`  ${m}`));
  console.log('\n用法: npm run generate -- <模块名>');
  console.log();
}

const moduleArg = process.argv[2];
const modules = listModules();

// 无参数 → 列出模块
if (!moduleArg) {
  printModules(modules);
  process.exit(0);
}

// 校验模块是否存在
if (!modules.includes(moduleArg)) {
  console.error(`\n未知模块: ${moduleArg}`);
  printModules(modules);
  process.exit(1);
}

// 有参数 → 代理给 Plop 执行对应模块
const plopfile = path.join(rootDir, 'src/plopfile.js');
execSync(`npx plop --plopfile "${plopfile}" ${moduleArg}`, {
  cwd: rootDir,
  stdio: 'inherit'
});

入口脚本的职责是模块发现和命令代理。启动时扫描根目录下所有子目录,通过两个特征识别模块目录:包含 services.json 或包含 template/ 子目录。同时排除 node_modulessrcoutputbin 等系统目录。

无参数运行时列出所有可用模块,有参数时校验模块名是否存在,然后代理给 Plop 执行。这意味着新增一个生成模块只需要在根目录建一个文件夹,放入 services.jsontemplate/ 即可,入口脚本无需任何修改。

Plop 配置 src/plopfile.js

const path = require('path');
const fs = require('fs');

module.exports = function (plop) {

  // ===== 通用 Helper =====
  plop.setHelper('getDirectory', function (fullPath) {
    const normalize = fullPath.replace(/\\/g, '/');
    const index = normalize.lastIndexOf('/');
    return index !== -1 ? normalize.substring(0, index) : normalize;
  });

  // ===== Docker 模块 =====
  plop.setGenerator('docker', {
    description: '生成 Docker 运行命令文档',
    prompts: [],
    actions: () => {
      const moduleDir = path.resolve(__dirname, '../docker');
      const templateDir = path.join(moduleDir, 'template');
      const configPath = path.join(moduleDir, 'services.json');
      const outputDir = path.resolve(__dirname, '../output');
      const services = JSON.parse(fs.readFileSync(configPath, 'utf-8'));

      return services.map(item => {
        const data = { ...item.data };
        if (!data.imageName) data.imageName = data.name;

        data.pathMapEntries = Object.entries(data.pathMap).map(([key, value]) => {
          const linuxPath = key.startsWith(data.volumeRootPath)
            ? key
            : `${data.volumeRootPath}/${data.name}${key}`;
          return { key, value, linuxPath };
        });

        return {
          type: 'add',
          path: path.join(outputDir, `${data.name}.md`),
          templateFile: path.join(templateDir, 'docker.hbs'),
          data,
          force: true
        };
      });
    }
  });
};

plopfile.js 做三件事:

注册 Handlebars Helper。getDirectory 从一个完整路径中提取目录部分,模板中用于从挂载路径推导出需要 mkdir 的父目录。

定义 Generator。prompts 为空数组,因为数据全部来自 JSON 配置文件,不需要交互式输入。actions 返回一个数组,每个元素对应一个输出文件。

数据预处理。如果配置中没有指定 imageName,自动用 name 字段填充。将 pathMap(卷映射的原始配置)转换为 pathMapEntries 数组,每一项包含宿主机路径 key、容器路径 value、以及补全后的 linuxPath。补全规则是:如果 key 已经以 volumeRootPath 开头则保持不变,否则自动拼接 volumeRootPath/name/key。这样模板中不需要做路径拼接逻辑,直接遍历 pathMapEntries 即可。

数据源 docker/services.json

[
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "name": "redis",
      "version": "5.0",
      "ports": ["6379"],
      "pathMap": {"/data": "/data"}
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "name": "nginx",
      "version": "1.20.0",
      "ports": ["80"],
      "pathMap": {
        "/etc/nginx/nginx.conf": "/etc/nginx/nginx.conf",
        "/etc/nginx/conf.d": "/etc/nginx/conf.d",
        "/usr/share/nginx/html": "/usr/share/nginx/html",
        "/var/log/nginx": "/var/log/nginx"
      }
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "name": "mysql",
      "version": "5.7",
      "ports": ["3306"],
      "pathMap": {
        "/etc/mysql/conf.d": "/etc/mysql/conf.d",
        "/var/lib/mysql": "/var/lib/mysql",
        "/etc/mysql/sql": "/etc/mysql/sql"
      },
      "beginLines": ["-e MYSQL_ROOT_PASSWORD=\"root\" \\"],
      "endLines": ["--character-set-server=utf8mb4 \\", "--collation-server=utf8mb4_unicode_ci"]
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "nacos/nacos-server",
      "name": "nacos",
      "version": "1.2.1",
      "ports": ["8848"],
      "pathMap": {
        "/home/nacos/conf": "/home/nacos/conf",
        "/home/nacos/logs": "/home/nacos/logs",
        "/home/nacos/data": "/home/nacos/data"
      },
      "beginLines": ["-e MODE=standalone \\"]
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "bladex/sentinel-dashboard",
      "name": "sentinel",
      "version": "1.7.1",
      "ports": ["8858"],
      "pathMap": {}
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "openzipkin/zipkin",
      "name": "zipkin",
      "version": "2.19.3",
      "ports": ["9411"],
      "pathMap": {},
      "beginLines": ["--env STORAGE_TYPE=elasticsearch \\", "--env ES_HOSTS=192.168.65.89:9200 \\"]
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "seataio/seata-server",
      "name": "seata",
      "version": "1.2.0",
      "ports": ["8091"],
      "pathMap": {"/seata-server": "/seata-server"}
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "name": "rabbitmq",
      "version": "3.8-management",
      "ports": ["15672", "5672"],
      "pathMap": {
        "/var/lib/rabbitmq/mnesia": "/var/lib/rabbitmq/mnesia",
        "/var/log/rabbitmq": "/var/log/rabbitmq",
        "/etc/rabbitmq": "/etc/rabbitmq"
      }
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "minio/minio",
      "name": "minio",
      "version": "3.8-management",
      "ports": ["9000", "9001"],
      "pathMap": {
        "/data": "/data",
        "/var/log/rabbitmq": "/var/log/rabbitmq",
        "/etc/rabbitmq": "/etc/rabbitmq"
      },
      "beginLines": ["-e \"MINIO_ROOT_USER=minio-access-key\" \\", "-e \"MINIO_ROOT_PASSWORD=minio-secret-key\" \\"],
      "endLines": ["server /data \\", "--console-address \":9001\""]
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "elasticsearch",
      "name": "elasticsearch",
      "version": "7.8.0",
      "ports": ["9200", "9300"],
      "pathMap": {
        "/usr/share/elasticsearch/data": "/usr/share/elasticsearch/data",
        "/usr/share/elasticsearch/config": "/usr/share/elasticsearch/config",
        "/usr/share/elasticsearch/plugins": "/usr/share/elasticsearch/plugins"
      },
      "beginLines": ["-e \"discovery.type=single-node\" \\", "-e ES_JAVA_OPTS=\"-Xms512m -Xmx512m\" \\"]
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "logstash",
      "name": "logstash",
      "version": "7.8.0",
      "ports": [],
      "pathMap": {"/usr/share/logstash": "/usr/share/logstash"}
    }
  },
  {
    "data": {
      "volumeRootPath": "/usr/local/docker-volume",
      "imageName": "kibana",
      "name": "kibana",
      "version": "7.8.0",
      "ports": ["5601"],
      "pathMap": {"/usr/share/kibana/config": "/usr/share/kibana/config"},
      "beginLines": ["-e ELASTICSEARCH_URL=http://192.168.65.89:9200 \\"]
    }
  }
]

每条配置的字段说明:

volumeRootPath:宿主机卷根目录,所有挂载路径的前缀。

name:服务名称,同时用作容器名和输出文件名。

imageName:Docker 镜像全名,省略时 plopfile.js 自动用 name 填充。

version:镜像标签。

ports:需要暴露的端口列表。

pathMap:容器路径到宿主机路径的映射关系。

beginLinesdocker run 命令中镜像名之前的额外参数(环境变量等)。

endLinesdocker run 命令中镜像名之后的追加参数(启动命令等)。

Handlebars 模板 docker/template/docker.hbs

```shell
docker pull {{imageName}}:{{version}}
docker run \
{{#each beginLines}}
{{{this}}}
{{/each}}
{{#each ports}}
-p {{this}}:{{this}} \
{{/each}}
--name {{name}} \
-d {{imageName}}:{{version}}
rm -rf {{volumeRootPath}}/{{name}}
{{#each pathMapEntries}}
mkdir -p {{getDirectory linuxPath}}
docker cp {{../name}}:{{value}} {{getDirectory linuxPath}}
{{/each}}
docker stop {{name}} && docker rm {{name}}
docker run \
{{#each beginLines}}
{{{this}}}
{{/each}}
{{#each pathMapEntries}}
-v {{linuxPath}}:{{value}} \
{{/each}}
{{#each ports}}
-p {{this}}:{{this}} \
{{/each}}
--name {{name}} \
-d {{imageName}}:{{version}}
{{~#if endLines}} \
{{/if}}
{{#each endLines}}
{{{this}}}
{{/each}}
```

```shell
docker logs -tf {{name}} --tail 20

docker restart {{name}}

docker exec -it {{name}} /bin/bash

docker stop {{name}} && docker rm {{name}}
```

模板生成的文档包含两段 shell 代码块。

第一段是完整的部署流程。先用临时容器把默认配置文件 cp 到宿主机(mkdir -p 创建目录 → docker cp 拷贝 → 停掉临时容器),再用正式挂载参数启动最终容器。这个两步流程是 Docker 卷挂载的标准做法——直接挂载空目录会覆盖容器内的默认配置。

第二段是运维命令速查:查看日志、重启、进入容器、销毁容器。

模板中的循环块对应数据字段:beginLines 输出环境变量参数,ports 输出端口映射,pathMapEntries 输出卷挂载,endLines 输出追加参数。getDirectoryplopfile.js 中注册的自定义 Helper,从完整路径中提取目录部分。

调用链

execSync (bin/generate.js:151)
  plop.run (node_modules/plop/src/plop.js)
    actions.<computed> (src/plopfile.js:180)
    Array.map (src/plopfile.js:187)
    NodePlop.addMany (node_modules/node-plop/dist/plop.js)
    Handlebars.render (docker/template/docker.hbs)

execSync 是入口脚本 generate.js 的最后一行,通过子进程将模块名代理给 Plop CLI,这是整个生成流程的起点。

actions.<computed>plopfile.js 中 Generator 的 actions 函数,负责读取 services.json、预处理数据(路径补全、imageName 填充),并为每个服务构造一个输出任务。

Handlebars.render 是最终的模板渲染步骤,将预处理后的数据填入 docker.hbs 模板,输出到 output/{service}.md

扩展新模块

这套方案不限于 Docker 命令生成。任何"根据 JSON 数据批量生成文件"的场景都适用——配置文件、项目脚手架、API 文档等。

新增模块的步骤:

在根目录创建模块文件夹,如 my-module/

创建 my-module/services.json,定义数据数组

创建 my-module/template/ 目录,放入 .hbs 模板文件

plopfile.js 中注册一个新的 generator(复制 docker 的注册代码,修改路径和数据处理逻辑即可)

执行 npm run generate -- my-module 生成文件

posted @ 2026-07-28 22:46  减瓦~  阅读(4)  评论(0)    收藏  举报