教你从0开始搭建一个NodeJs前端脚手架
“深入浅出Node.js前端脚手架:解析commander.js + inquirer.js + fs的核心组合”
在现代前端工程化体系中,脚手架工具已成为提升开发效率、规范团队协作的利器。本文将以实际项目为例,深入解析如何用 Node.js 结合 commander.js、inquirer.js 与 fs 模块,打造高效、可扩展的前端组件自动化脚手架。
背景
最近在参与团队低代码平台建设的过程中,发现了一个之前忽略的点:为了丰富项目搭建场景,团队准备进行一批业务组件的集中交付,用于在低代码平台的左侧物料平台加载。虽然前期就定义了组件开发规范,但由于每个人的开发习惯不同,某个组件名称少了个横线或者样式类名多了一个下划线的事情经常发生,导致在平台中读取这些组件的基本变量时发生异常。
为了对每个团队成员的代码进行强制规范,我决定开发一个脚手架工具:使用方只需要运行脚手架命令,输入组件名称及含义等基础信息,脚手架工具在指定目录下自动创建规范组件模版,开发人员只需关注业务逻辑的实现即可。这样大大降低了组件出错率和问题排查时间,完美解决与平台的兼容、交互等问题。
一、理由:为什么选择 Node.js + commander.js + inquirer.js + fs
- Node.js 提供了跨平台的 JavaScript 运行环境,天然适合前端工程师开发 CLI 工具。
- commander.js 是业界主流的命令行参数解析库,支持多命令、参数校验、版本管理等,极大提升 CLI 的可维护性和扩展性。
- inquirer.js 提供了丰富的交互式命令行体验,支持输入、选择、确认等多种交互方式,极大提升用户体验。
- fs(文件系统)模块让我们可以灵活地读写文件、操作目录,是自动化生成、维护项目结构的基础。
二、核心架构与代码解读
1. 命令行参数解析:commander.js
const { program } = require("commander");
program
.version("0.0.5", "-v --version")
.arguments("<create>")
.action(() => {
// ...创建逻辑
});
program.command("build").action(() => {
// ...打包逻辑
});
program.parse(process.argv);
通过链式 API,我们可以轻松扩展很多命令,比如版本管理、初始化命令、其他参数等,满足复杂的工程需求。
2. 交互式输入:inquirer.js
const inquirer = require("inquirer");
const enterItems = [
{ type: "input", name: "name", message: "请输入组件名(如 HsgtList):" },
{ type: "input", name: "title", message: "请输入组件标题(如 沪深港通-列表):" },
];
inquirer.prompt(enterItems).then(res => {
// ...根据用户输入生成组件
});
能与开发者进行友好的交互,这种方式极大降低了新手的上手门槛,也让脚手架更具“人性化”。
3. 文件与目录操作:fs
fs 模块是 Node.js 的原生能力,负责所有文件、目录的读写与管理。例如:
const fs = require("fs");
const path = require("path");
// 读取模板并生成新文件
let content = fs.readFileSync(path.join(__dirname, "template", c), "utf8");
content = content.replace(/pluginName/g, pluginName).replace(/className/g, className);
fs.writeFileSync(newPath, content);
// 创建目录
fs.mkdirSync(pluginDirPath, { recursive: true });
通过 fs,我们可以实现“模板驱动开发”,批量生成标准化的组件目录、核心文件、Demo、图片等。
三、总结
- 职责清晰,易于维护:commander 负责命令解析,inquirer 负责交互,fs 负责文件操作。
- 脚手架工具命令的可扩展性较强:新增命令、参数、模板都非常简单,适合团队持续演进。
- 对开发人员比较友好:CLI 交互体验好,错误提示清晰,降低了团队协作和新成员上手成本。
- 模板集中管理,变量占位符灵活替换,降低重复劳动,提升了开发效率。
Node.js + commander.js + inquirer.js + fs 的组合,是前端工程师打造自动化脚手架的黄金搭档。通过合理的架构设计与工程实践,我们不仅提升了开发效率,更为团队协作和项目交付打下了坚实基础。
希望本文的实战经验能为你的前端工程化之路提供参考。如有更深入的 CLI 工具开发、自动化实践,欢迎交流探讨!
最后附上完整源码:
/*
* Node.js 前端组件自动化脚手架主入口
* 主要功能:组件创建、自动模板生成、菜单维护、资源打包
* 技术栈:commander.js + inquirer.js + fs + ora + chalk
*/
const fs = require("fs");
const path = require("path");
const inquirer = require("inquirer"); // 交互式命令行输入
const chalk = require("chalk"); // 终端彩色输出
const { program } = require("commander"); // 命令行参数解析
const ora = require("ora"); // 终端 loading 效果
const spinner = ora("waiting...");
const html2Image = require("node-html-to-image"); // HTML 转图片
const ROOT_PATH = process.cwd(); // 当前项目根目录
const pageList = ["index.vue", "index.scss", "props.ts"];
// 组件创建时的交互式输入项
const enterItems = [
{
type: "input",
name: "name",
message: "Please enter your component name ( examples: HsgtList):",
},
{
type: "input",
name: "title",
message: "Please enter your component title ( examples: 沪深港通-列表):",
},
];
// 驼峰转短横线(如 HsgtList -> hsgt-list)
function toDashCase(str) {
return str.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '');
}
// 短横线转驼峰(如 hsgt-list -> HsgtList)
function toCamelCase(str) {
return str.replace(/(-\w)/g, function (match) {
return match[1].toUpperCase();
});
}
// 组件创建命令
program
.version("0.1.1", "-v --version")
.arguments("<create>")
.action(() => {
inquirer.prompt(enterItems).then(async (res) => {
spinner.start();
// 1. 校验组件名
if (res.name === "") {
spinner.stop();
console.log(chalk.yellow("Please enter your component name !"));
return;
}
// 2. 规范化组件名和样式类名
const componentName = res.name;
let pluginName = "", className = "";
if (componentName.indexOf('-') > -1) {
pluginName = toCamelCase(componentName);
className = componentName;
} else {
pluginName = componentName;
className = toDashCase(componentName);
}
// 3. 检查同名目录是否存在
const PAGE_PATH = path.join("src", "plugins", pluginName); // path.join拼接路径 - 保证跨平台兼容
const pluginDirPath = path.join(ROOT_PATH, PAGE_PATH);
if (fs.existsSync(pluginDirPath)) {
spinner.stop();
console.log(
chalk.yellow("This componentPath has already been existed !")
);
return;
}
// 4. 创建组件主目录
try {
fs.mkdirSync(pluginDirPath, { recursive: true });
} catch (err) {
spinner.stop();
console.log(chalk.red(err));
return;
}
// 5. 生成核心模板文件(vue/scss/ts)
/*
注意:这里的template文件夹是你自己定义的代码模版,
1-模版代码结构简单,可置于本地;
2-模版代码复杂,可以参考vue-cli的实现方式,置于指定服务器上,在执行时需要先从服务器拉取代码再创建
*/
pageList.forEach(c => {
let content = fs.readFileSync(path.join(__dirname, "template", c), "utf8");
let newPath = path.join(pluginDirPath, c);
if (!fs.existsSync(newPath)) {
// 替换模板变量
content = content.replace(/pluginName/g, pluginName).replace(/className/g, className);
fs.writeFileSync(newPath, content);
}
})
// 6. 生成 demo 目录及文件
let demoContent = fs.readFileSync(path.join(__dirname, "template", "demo.vue"), "utf8");
let demoDir = path.join(pluginDirPath, "demo");
let demoPath = path.join(demoDir, 'index.vue');
try {
fs.mkdirSync(demoDir, { recursive: true });
} catch (err) {
spinner.stop();
console.log(chalk.red(err));
return;
}
if (!fs.existsSync(demoPath)) {
demoContent = demoContent.replace(/pluginName/g, pluginName).replace(/className/g, className);
fs.writeFileSync(demoPath, demoContent);
}
// 7. 维护组件菜单(menuList.json)
const menuListPath = path.join(ROOT_PATH, 'menuList.json');
let originMenuList = fs.readFileSync(menuListPath, "utf8");
if (!originMenuList.trim()) originMenuList = '[]';
const originMenuArr = JSON.parse(originMenuList);
originMenuArr.push({ name: pluginName, title: res.title || pluginName });
const newMenuListJson = JSON.stringify(originMenuArr);
fs.writeFile(menuListPath, newMenuListJson, (err) => {
if (err) {
spinner.stop();
console.log(chalk.red(err));
}
});
// 8. 生成 images 目录及组件预览图片
let imagesDir = path.join(pluginDirPath, "images");
try {
fs.mkdirSync(imagesDir, { recursive: true });
} catch (err) {
spinner.stop();
console.log(chalk.red(err));
return;
}
const title = res.title || pluginName;
const imagePath = path.join(imagesDir, `${className}.png`);
// 生成 SVG 预览图
const htmlContent = `
<div id="plugin-image" style=" width:100%;height: 400px;display: flex;flex-direction: column;">
<div style="font-size: 18px;line-height: 20px;font-weight: 600;padding: 20px 0;text-align: center;">${title}</div>
<div style=" flex: 1;background: #f8f8f8;display: flex;justify-content: center;align-items: center;">
<svg
version="1.0"
xmlns="http://www.w3.org/2000/svg"
width="200px"
height="200px"
viewBox="0 0 200.000000 200.000000"
preserveAspectRatio="xMidYMid meet"
>
<g
transform="translate(0.000000,200.000000) scale(0.100000,-0.100000)"
fill="#E8302E"
stroke="none"
>
<path
d="M980 1790 c-11 -11 -23 -34 -27 -50 l-5 -30 -220 0 c-245 0 -276 -6 -298 -61 -12 -27 -10 -80 5 -134 5 -17 -9 -28 -92 -70 -123 -63 -236 -136 -257 -166 -30 -43 -21 -89 33 -169 l49 -72 -35 -77 c-108 -232 -140 -327 -128 -381 4 -16 19 -40 34 -52 15 -13 129 -58 256 -102 127 -44 311 -108 410 -142 98 -35 180 -62 182 -61 11 11 -192 109 -511 248 -93 40 -188 87 -210 104 -37 29 -41 35 -40 76 0 29 17 82 48 157 78 183 49 189 282 -54 111 -115 220 -229 243 -254 l42 -45 -27 45 c-80 132 -193 284 -368 495 -171 205 -177 218 -127 264 32 30 249 146 257 138 12 -12 162 -447 219 -637 l26 -85 -6 70 c-7 88 -42 260 -106 528 -60 253 -65 286 -46 311 12 16 34 20 158 28 79 4 159 6 177 3 33 -6 33 -7 27 -48 -27 -171 -84 -450 -151 -747 -4 -19 -3 -21 4 -10 17 26 91 248 148 445 77 267 123 401 140 416 8 7 31 12 50 11 38 -2 354 -81 367 -92 14 -12 -343 -466 -483 -614 -60 -65 -62 -67 -20 -36 25 18 173 161 329 317 200 200 290 283 306 283 13 0 51 -26 91 -61 80 -71 214 -173 257 -196 22 -11 3 17 -75 109 -57 67 -130 155 -161 194 -32 40 -71 77 -87 84 -35 15 -76 6 -91 -19 -11 -17 -18 -15 -121 25 -167 66 -371 134 -401 134 -15 0 -36 -9 -47 -20z"
/>
<path
d="M1536 1114 c-3 -9 -81 -135 -172 -280 -91 -145 -170 -277 -175 -293 -14 -38 3 -100 38 -142 66 -78 192 -80 251 -4 24 31 36 18 27 -28 -11 -47 -39 -97 -75 -134 l-32 -33 148 0 148 0 -31 32 c-40 41 -72 112 -73 158 l0 34 40 -39 c39 -39 43 -40 108 -40 79 0 120 23 152 85 33 64 27 106 -29 196 -27 43 -105 171 -175 283 -69 112 -126 208 -126 213 0 14 -18 9 -24 -8z"
/>
</g>
</svg>
</div>
</div>
`;
html2Image({ html: htmlContent, puppeteerArgs: { args: ['--no-sandbox'], defaultViewport: { width: 375, height: 400 } } })
.then(image => {
fs.writeFileSync(imagePath, image);
spinner.stop();
console.log(chalk.green(`Created ${pluginName} component successfully !`));
})
.catch(err => {
spinner.stop();
console.log(chalk.red(err));
return;
});
});
});
// 组件打包命令
program.command("build").action(() => {
spinner.start();
// 1. 清理 dist 目录
const distDir = path.join(ROOT_PATH, 'dist');
if (fs.existsSync(distDir)) {
try {
fs.rmdirSync(distDir, { recursive: true });
} catch (err) {
spinner.stop();
console.log(chalk.red(err));
return;
}
}
// 2. 创建 dist/plugins 目录
try {
const distPluginsDir = path.join(ROOT_PATH, 'dist', 'plugins');
fs.mkdirSync(distPluginsDir, { recursive: true });
} catch (err) {
spinner.stop();
console.log(chalk.red(err));
return;
}
// 3. 复制 package.json
const packageJsonPath = path.join(ROOT_PATH, 'package.json');
const distPackageJsonPath = path.join(distDir, 'package.json');
fs.copyFileSync(packageJsonPath, distPackageJsonPath);
// 4. 遍历 plugins 目录,复制各插件资源
const pluginsDir = path.join(ROOT_PATH, 'src', 'plugins');
const items = fs.readdirSync(pluginsDir);
items.forEach(item => {
const itemPath = path.join(pluginsDir, item);
// 只处理目录
if (fs.lstatSync(itemPath).isDirectory()) {
const distItemPath = path.join(distDir, 'plugins', item);
if (!fs.existsSync(distItemPath)) {
fs.mkdirSync(distItemPath, { recursive: true });
}
// 复制核心文件
const indexPath = path.join(itemPath, 'index.vue');
if (fs.existsSync(indexPath)) {
fs.copyFileSync(indexPath, path.join(distItemPath, 'index.vue'));
}
const scssPath = path.join(itemPath, 'index.scss');
if (fs.existsSync(scssPath)) {
fs.copyFileSync(scssPath, path.join(distItemPath, 'index.scss'));
}
const propsPath = path.join(itemPath, 'props.ts');
if (fs.existsSync(propsPath)) {
fs.copyFileSync(propsPath, path.join(distItemPath, 'props.ts'));
}
// 复制 images 目录
const distImagesPath = path.join(distDir, 'plugins', item, 'images');
if (!fs.existsSync(distImagesPath)) {
fs.mkdirSync(distImagesPath, { recursive: true });
}
const imagesPath = path.join(itemPath, 'images');
fs.readdir(imagesPath, (err, files) => {
if (err) {
spinner.stop();
console.log(chalk.red(err));
return;
}
files.forEach((file) => {
const sourceFile = path.join(imagesPath, file);
const distFile = path.join(distImagesPath, file);
fs.copyFileSync(sourceFile, distFile);
});
});
// 复制 utils 目录(如存在)
const utilsPath = path.join(itemPath, 'utils');
if (fs.existsSync(utilsPath)) {
const distUtilsPath = path.join(distDir, 'plugins', item, 'utils');
if (!fs.existsSync(distUtilsPath)) {
fs.mkdirSync(distUtilsPath, { recursive: true });
}
fs.readdir(utilsPath, (err, files) => {
if (err) {
spinner.stop();
console.log(chalk.red(err));
return;
}
files.forEach((v) => {
const sourceFile = path.join(utilsPath, v);
const distFile = path.join(distUtilsPath, v);
fs.copyFileSync(sourceFile, distFile);
});
});
}
}
});
// 5. 打包完成提示
setTimeout(() => {
spinner.stop();
console.log(chalk.green(`Build plugins project to dist folder successfully !`));
}, 1000);
});
// 启动命令行参数解析
program.parse(process.argv);

浙公网安备 33010602011771号