Plop 代码生成实践:从模板到批量文件输出
Plop 是什么
Plop 是一个轻量级代码生成框架,核心能力只有一件事:读取模板 + 填充数据 → 输出文件。它基于 Handlebars 模板引擎,通过 JSON 数据驱动模板渲染,适合任何"根据数据批量生成结构化文件"的场景。
Plop 本身不提供 CLI 入口的绑定限制,可以自由嵌入到自定义脚本中。它的 Generator 机制允许在一个 plopfile 中注册多个生成器,每个生成器独立定义数据源、预处理逻辑和输出模板。
Handlebars 模板基础
Handlebars 使用双大括号 {{ }} 作为变量插值标记,三重大括号 {{{ }}} 表示不转义输出。核心控制结构:
{{#each array}} 遍历数组,内部用 {{{this}}} 引用当前元素。
{{#if value}} 条件判断,值为空数组、空字符串、null、undefined、false 时走 {{else}} 分支。
波浪号 ~ 用于控制空白。标签左侧加 ~(如 {{~#each}})吞掉左边的空白,右侧加 ~(如 {{#each~}})吞掉右边的空白。
Handlebars 换行控制
each 块在换行处理上有一些隐蔽行为,写模板时如果不清楚这些规则,生成的文件会出现多余空行或缺少换行。以下用数组 [1, 2, 3] 逐步演示,每个元素的渲染内容统一用 this 表示,用 \n 标记换行位置以便观察。
第一步,基础模板:
start
{{#each items}}
{{{this}}}
{{/each}}
把模板中的换行位置显式标出来:
start\n{{#each items}}\n{{{this}}}\n{{/each}}
模板里有 4 个 \n:start 后面一个,{{#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.json 和 template/ 目录。入口脚本会自动扫描根目录下的所有模块。
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_modules、src、output、bin 等系统目录。
无参数运行时列出所有可用模块,有参数时校验模块名是否存在,然后代理给 Plop 执行。这意味着新增一个生成模块只需要在根目录建一个文件夹,放入 services.json 和 template/ 即可,入口脚本无需任何修改。
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:容器路径到宿主机路径的映射关系。
beginLines:docker run 命令中镜像名之前的额外参数(环境变量等)。
endLines:docker 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 输出追加参数。getDirectory 是 plopfile.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 生成文件

浙公网安备 33010602011771号