手写webpack脚手架的cli工具

日常吐槽

本来想搭建一个webpack脚手架的,于是在搭建的过程中不断地搜集相关资料。可最终的结果是,webpack脚手架没有搭建成,却写出个 CLI 小工具。其实,这也并不是没有原因的。现在流行的框架都推出了自己的脚手架工具,比如,Vue CLI,Create React App 等。脚手架和CLI往往如影随形,这也导致了两者在概念上的混淆。标题为什么这么拗口,其实是为了区分这两个概念。

我有一个想法

既然被带跑偏了,就只能在跑偏的路上越跑越远吧。

命令行界面(英语:Command-Line Interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(character user interface, CUI) ———— 维基百科

使用过 Vue CLI 的同学应该都知道,我们只需要在终端敲几个的命令就可以搭建一个 Vue 的脚手架。如果不使用 CLI 的话,每次创建项目时,都需要配置文件(比如webpack配置文件)、设计结构、技术栈选型等。如果每次从零开始去搭建项目就会很麻烦,所以我们可以把相同的东西抽离成脚手架。以后创建项目时,就可以直接把脚手架复制过来,并以此为基础搭建项目。

回过头来再看看我们手动搭建项目的过程,从每次从零开始搭建项目到脚手架的复用,这中间有了很大的进步。可即使是复制黏贴,我们依然觉得很麻烦,如果用命令行的方式来取代图形操作,我们就可以更懒一些了。

回到主题,我本来打算写的webpack脚手架是基于这样的一个想法。➡️ 现在大部分的前端工程,webpack作为打包工具已经成了标配了。而 webpack 的配置是大同小异的,完全可以剥离出一个通用的webpack配置,然后针对个别配置进行修改。本次希望最终实现一个基于webpack适用于不同前端模板(React、Vue、ES+)的脚手架。

现在脚手架有了,如何自动化去搭建一个项目呢?

  1. 复制或下载脚手架模板。(为了更灵活,上传到GitHub,或发布npm中)。

  2. 根据不同需求,在脚手架模板基础上重新配置webpack、package文件。

  3. 安装依赖。

以下代码可见GitHub

CLI 中的预备工作

首先了解一下 #!。文件开头要加上#! /usr/bin/env node

在计算领域中,Shebang(也称为 Hashbang )是一个由井号和叹号构成的字符序列 #! ,其出现在文本文件的第一行的前两个字符。 在文件中存在 Shebang 的情况下,类 Unix 操作系统的程序加载器会分析 Shebang 后的内容,将这些内容作为解释器指令,并调用该指令,并将载有 Shebang 的文件路径作为该解释器的参数。 ———— 维基百科
使用 #!/usr/bin/env 脚本解释器名称 是一种常见的在不同平台上都能正确找到解释器的办法。 ———— 维基百科

然后看看都用到了哪些东西(部分)。

1
npm install commander chalk fs-extra shelljs inquirer ora ejs --save
1
2
3
4
5
6
7
8
9
10
11
12
13
#! /usr/bin/env node

// multi-spa.js
const program = require('commander'); // 解析命令;
const chalk = require('chalk'); // 命令行界面输出美颜
const fs = require('fs-extra'); // fs的拓展;
const shell = require('shelljs'); // 重新包装了 child_process;
const inquirer = require('inquirer'); // 交互式问答;
const ora = require('ora'); // 输出样式美化;
const ejs = require('ejs'); // 模版引擎;
const path = require('path');
const currentPath = process.cwd();
let answersConfig = null;

命令的解析

类似与 Vue 的 vue init,我们也希望自己的 CLI 也能拥有类似的功能。

1
2
3
4
// package.json
"bin": {
"multi-spa-webpack": "./bin/multi-spa.js"
},

这样,我们就有了multi-spa-webpack的命令。如果我们想要全局使用,还需要执行下面命令。

1
npm link

接下来就要初始化multi-spa-webpack相关的命令了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// multi-spa.js
program
.command('init <项目路径> [选项]')
.description('指令说明:初始化项目')
.action(async (appName) => {
try {
let targetDir = path.resolve(currentPath, appName || '.');
if (fs.pathExistsSync(targetDir)) {
if (program.force) {
GenarateProject(appName); // 创建项目;
}
ora(chalk.red(`!当前目录下,${appName}已存在,请修改名称后重试`)).fail();
process.exit(1);
};
answersConfig = await getAnswers(appName);
GenarateProject(appName); // 创建项目;
} catch (error) {
ora(chalk.red(`项目创建失败:${error}`)).fail();
process.exit(1);
}
});
program
.arguments('<command>')
.action((cmd) => {
console.log();
console.log(chalk.red(`!命令未能解析 <${chalk.green(cmd)}>`));
console.log();
program.outputHelp();
console.log();
});
program.parse(process.argv);
if (program.args.length === 0) {
console.log();
console.log(chalk.red('!输入的命令有误'));
console.log();
chalk.cyan(program.help());
}

复制或下载模板

在执行multi-spa-webpack init spa-project后,就需要拷贝一份脚手架到本地了。至于脚手架从哪里来,可以放在 github 上(类似 Vue CLI)或 放在 CLI 对应的目录下(类似create-react-app)。
本文是采用的是从 github 获取脚手架模板的。但是常规的方式,只能下载整个项目,而对于不需要的文件夹或文件,也会同时下载,下载后,只能在本地中删除无关文件了。我这里是从源头上剔除无关文件的下载,这个方法可能会有一些局限性吧(sparse-checkout)。不过两者最终的目的是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// multi-spa.js
function DownTemplate(projectDir) {
const remote = 'https://github.com/yexiaochen/multi-spa-webpack-cli.git';
const { template } = answersConfig;
let downTemplateSpinner = ora(chalk.cyan('模板下载中...')).start();
return new Promise((resolve, reject) => {
shell.exec(`
mkdir ${projectDir}
cd ${projectDir}
git init
git remote add -f origin ${remote}
git config core.sparsecheckout true
echo "template/common" >> .git/info/sparse-checkout
echo "template/config" >> .git/info/sparse-checkout
echo "template/services" >> .git/info/sparse-checkout
echo "template/${template}" >> .git/info/sparse-checkout
echo ".gitignore" >> .git/info/sparse-checkout
echo "package.json" >> .git/info/sparse-checkout
git pull origin master
rm -rf .git
mv template/* ./
rm -rf template
`, (error) => {
if (error) {
downTemplateSpinner.stop()
ora(chalk.red(`模板下载失败:${error}`)).fail()
reject()
}
downTemplateSpinner.stop();
ora(chalk.cyan('模板下载成功')).succeed();
resolve();
})
})
}

重新生成配置文件

像 webpack、package 等配置文件,也都是包含在脚手架里的,不过这些配置还不能直接拿来用。我们还需要通过交互式问答,来针对性得在现有的基础上重新生成配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// multi-spa.js
function getAnswers(appName) {
const options = [
{
type: 'input',
name: 'name',
message: '项目名称',
default: appName,
},
{
type: 'input',
name: 'description',
message: '项目描述',
default: '单页面应用',
},
{
type: 'confirm',
name: 'eslint',
message: '是否启用 eslint+pretty',
default: true
},
{
name: 'cssPreprocessor',
type: 'list',
message: 'CSS 预处理器',
choices: [
"less",
"sass",
"none",
]
},
{
name: 'template',
type: 'list',
message: '选取模板',
choices: [
"react",
"vue",
"es"
]
},
];
return inquirer.prompt(options);
}

在获得特定的需求后,还要把这些数据注入到配置文件中。就是通过模板引擎把数据塞到模板里。这里使用的是 ejs 模版引擎。

1
2
3
4
5
6
<!-- webpack.common.ejs -->
<%= answers.cssPreprocessor == 'none'
? /\.css$/ : (answers.cssPreprocessor == 'less' ? /\.less$/ : /\.scss$/) %>

<%= answers.cssPreprocessor == 'none'
? '' : (answers.cssPreprocessor == 'less' ? 'less-loader' : 'sass-loader') %>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// multi-spa.js
async function GenarateWebpackConfig(targetDir) {
try {
const webpackConfigPath = path.resolve(`${currentPath}/${targetDir}/config`, 'webpack.common.ejs');
const webpackConfigTargetPath = path.resolve(`${currentPath}/${targetDir}/config`, 'webpack.common.js');
const webpackConfigSpinner = ora(chalk.cyan(`配置 webpack 文件...`)).start();
let webpackConfig = await fs.readFile(webpackConfigPath, 'utf8');
let generatedWebpackConfig = ejs.render(webpackConfig, { answers: answersConfig });
await Promise.all([
fs.writeFile(webpackConfigTargetPath, generatedWebpackConfig),
fs.remove(webpackConfigPath)
])
webpackConfigSpinner.stop();
ora(chalk.cyan(`配置 webpack 完成`)).succeed();
} catch (error) {
ora(chalk.red(`配置文件失败:${error}`)).fail();
process.exit(1);
}
}
async function GenaratePackageJson(projectDir) {
try {
const { name, description, cssPreprocessor } = answersConfig;
const packageJsonPath = path.resolve(`${currentPath}/${projectDir}`, 'package.json');
const packageJsonSpinner = ora(chalk.cyan('配置 package.json 文件...')).start();
let package = await fs.readJson(packageJsonPath);
package.name = name;
package.description = description;
if (cssPreprocessor == 'less') {
package.devDependencies = {
...package.devDependencies,
"less-loader": "^5.0.0"
}
}
if (cssPreprocessor == 'sass') {
package.devDependencies = {
...package.devDependencies,
"node-sass": "^4.12.0",
"sass-loader": "^7.1.0"
}
}
await fs.writeJson(packageJsonPath, package, { spaces: '\t' });
packageJsonSpinner.stop();
ora(chalk.cyan('package.json 配置完成')).succeed();
} catch (error) {
if (error) {
ora(chalk.red(`配置文件失败:${error}`)).fail();
process.exit(1);
};
}
}

安装依赖

其实配置文件生成后,CLI 就快接近尾声了。剩下就是安装依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// multi-spa.js
function InstallDependencies(targetDir) {
const installDependenciesSpinner = ora(chalk.cyan(`安装依赖中...`)).start();
return new Promise((resolve, reject) => {
shell.exec(`
cd ${targetDir}
npm i
`, (error) => {
if (error) {
installDependenciesSpinner.stop()
ora(chalk.red(`依赖安装失败:${error}`)).fail()
reject()
}
installDependenciesSpinner.stop();
ora(chalk.cyan('依赖安装完成')).succeed();
resolve();
})
})
}

小结

一个粗糙的CLI,就这么完成了。把以上几个方法包装一下,就是本次 CLI 的全部内容了。

  1. 拷贝脚手架。2. 重新生成配置文件。3安装依赖。
1
2
3
4
5
6
7
8
9
async function GenarateProject(targetDir) {
await DownTemplate(targetDir);
await Promise.all([GenaratePackageJson(targetDir).then(() => {
return InstallDependencies(targetDir);
}),
GenarateWebpackConfig(targetDir)
]);
ora(chalk.cyan('项目创建成功!')).succeed();
}

如果想要发布,需要登陆npm ,npm publish

------------- The End -------------
显示评论