从 0 构建自己的脚手架

原帖

搭建脚手架的目的就是快速的搭建项目的基本结构并提供项目规范和约定。目前日常工作中常用的脚手架有 vue-cli、create-react-app、angular-cli 等等,都是通过简单的初始化命令,完成内容的快速构建。

脚手架是我们经常使用的工具,也是团队提效的重要手段。所以系统性的掌握脚手架相关知识,对前端开发者来说是非常重要的,即使很多人今后不一定都会参与到各自部门或者公司的基建工作,但是系统性掌握好这个技能也可以方便我们后期的源码阅读。下面就一起来了解一下吧 😉

一、脚手架的简单雏形 🐣

脚手架就是在启动的时候询问一些简单的问题,并且通过用户回答的结果去渲染对应的模板文件,基本工作流程如下:

  1. 通过命令行交互询问用户问题
  2. 根据用户回答的结果生成文件

例如我们在使用 vue-cli 创建一个 vue 项目时的时候 👇

step1:运行创建命令

1
$ vue create hello-world

step2:询问用户问题

step3:生成符合用户需求的项目文件

1
2
3
4
5
6
7
8
9
10
11
12
13

vue-project
├─ index.html
├─ src
│ ├─ App.vue
│ ├─ assets
│ │ └─ logo.png
│ ├─ components
│ │ └─ HelloWorld.vue
│ ├─ main.js
│ └─ router
│ └─ index.js
└─ package.json

参考上面的流程我们可以自己来 搭建一个简单的脚手架雏形

1. 在命令行启动 cli

目标:   实现在命令行执行 my-node-cli 来启动我们的脚手架

1.1 新建项目目录 my-node-cli

1
2
3
$ mkdir my-node-cli
$ cd my-node-cli
$ npm init

1.2 新建程序入口文件 cli.js

1
$ touch cli.js

在 package.json 文件中指定入口文件为 cli.js 👇

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "my-node-cli",
"version": "1.0.0",
"description": "",
"main": "cli.js",
"bin": "cli.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}

此时项目目录结构:

1
2
3
my-node-cli
├─ cli.js
└─ package.json

打开 cli.js 进行编辑

1
2
3
#! /usr/bin/env node

console.log("my-node-cli working~");
1
$ npm link

执行完成 ✅

我们就可以来测试了,在命令行中输入 my-node-cli 执行一下

1
$ my-node-cli

这里我们就看到命令行中打印了

1
my-node-cli working~

完成 ✔,接下来

2. 询问用户信息

实现与询问用户信息的功能需要引入 inquirer.js 👉 文档看这里

1
$ npm install inquirer --dev

接着我们在 cli.js 来设置我们的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#! /usr/bin/env node

const inquirer = require("inquirer");

inquirer
.prompt([
{
type: "input",
name: "name",
message: "Your name",
default: "my-node-cli",
},
])
.then((answers) => {
console.log(answers);
});

在命令行输入 my-node-cli 看一下执行结果

这里我们就拿到了用户输入的项目名称 {name: 'my-app'}, 👌

3. 生成对应的文件

3.1 新建模版文件夹

1
$ mkdir templates

3.2 新建 index.html 和 common.css 两个简单的示例文件

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= name %></title>
</head>
<body>
<h1><%= name %></h1>
</body>
</html>
1
2
3
4
body {
margin: 20px auto;
background-color: azure;
}

此时的目录结构

1
2
3
4
5
6
7
my-node-cli
├─ templates
│ ├─ common.css
│ └─ index.html
├─ cli.js
├─ package-lock.json
└─ package.json

3.3 接着完善文件生成逻辑

这里借助 ejs 模版引擎将用户输入的数据渲染到模版文件上

1
npm install ejs --save

完善后到 cli.js 👇

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
#! /usr/bin/env node

const inquirer = require("inquirer");
const path = require("path");
const fs = require("fs");
const ejs = require("ejs");

inquirer
.prompt([
{
type: "input",
name: "name",
message: "Your name",
default: "my-node-cli",
},
])
.then((answers) => {
const destUrl = path.join(__dirname, "templates");

const cwdUrl = process.cwd();

fs.readdir(destUrl, (err, files) => {
if (err) throw err;
files.forEach((file) => {
ejs.renderFile(path.join(destUrl, file), answers).then((data) => {
fs.writeFileSync(path.join(cwdUrl, file), data);
});
});
});
});

同样,在控制台执行一下 my-node-cli ,此时 index.htmlcommon.css 已经成功创建 ✔

我们打印一下当前的目录结构 👇

1
2
3
4
5
6
7
8
9
my-node-cli
├─ templates
│ ├─ common.css
│ └─ index.html
├─ cli.js
├─ common.css .................... 生成对应的 common.css 文件
├─ index.html .................... 生成对应的 index.html 文件
├─ package-lock.json
└─ package.json

打开生成的 index.html 文件看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>my-app</title>
</head>

<body>
<h1>my-app</h1>
</body>
</html>

用户输入的 {name: 'my-app'} 已经添加到了生成的文件中了 ✌️

点此打开 👉 my-node-cli 源码地址

二、热门脚手架工具库 🔧

实际生产中搭建一个脚手架或者阅读其他脚手架源码的时候需要了解下面这些工具库 👇

名称简介
commander命令行自定义指令
inquirer命令行询问用户问题,记录回答结果
chalk控制台输出内容样式美化
ora控制台 loading 样式
figlet控制台打印 logo
easy-table控制台输出表格
download-git-repo下载远程模版
fs-extra系统 fs 模块的扩展,提供了更多便利的 API,并继承了 fs 模块的 API
cross-spawn支持跨平台调用系统上的命令

重点介绍下面这些,其他工具可以查看说明文档

1. commander 自定义命令行指令

更多用法 👉 中文文档

简单案例 👇

1.1 新建一个简单的 Node Cli 项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

{
"name": "my-vue",
"version": "1.0.0",
"description": "",
"bin": "./bin/cli.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "T-Roc",
"license": "ISC",
"devDependencies": {
"commander": "^7.2.0"
}
}

目录结构:

1
2
3
4
5
npms-demo
├─ bin
│ └─ cli.js
├─ package-lock.json
└─ package.json

1.3 引入 commander 编写代码

1
2

npm install commander

完善 bin.js 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
#! /usr/bin/env node

const program = require("commander");

program
.version("0.1.0")
.command("create <name>")
.description("create a new project")
.action((name) => {
console.log("project name is " + name);
});

program.parse();
  • 执行 npm link 将应用 my-vue 链接到全局
  • 完成之后,在命令行中执行 my-vue

看一下,命令行中的输出内容 👇

1
2
3
4
5
6
7
8
9
10
11
~/Desktop/cli/npms-demo ->my-vue

Usage: my-vue [options] [command]

Options:
-V, --version output the version number
-h, --help display help for command

Commands:
create <name> create a new project
help [command] display help for command

这个时候就有了 my-vue 命令使用的说明信息,在 Commands 下面出现了我们刚刚创建的 create 命令 create <name>,我们在命令行中运行一下

1
2
~/Desktop/cli/npms-demo ->my-vue create my-app
project name is my-app

这个时候控制台就打印出来 create 命令后面的 <name>my-app 👏

2. chalk 命令行美化工具

chalk(粉笔)可以美化我们在命令行中输出内容的样式,例如对重点信息添加颜色

2.1 安装依赖

1
npm install chalk

2.2 基本使用

在 npms-demo 项目中打开 bin/cli.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#! /usr/bin/env node

const program = require("commander");
const chalk = require("chalk");

program
.version("0.1.0")
.command("create <name>")
.description("create a new project")
.action((name) => {
console.log("project name is " + chalk.bold(name));

console.log("project name is " + chalk.cyan(name));
console.log("project name is " + chalk.green(name));

console.log("project name is " + chalk.bgRed(name));

console.log("project name is " + chalk.rgb(4, 156, 219).underline(name));
console.log("project name is " + chalk.hex("#049CDB").bold(name));
console.log("project name is " + chalk.bgHex("#049CDB").bold(name));
});

program.parse();

在命令行中运行项目 my-vue create my-app 看一下效果

具体的样式对照表如下 👇

3. inquirer 命令行交互工具

更多用法 👉 文档地址

inquirer 在脚手架工具中的使用频率是非常高的,其实在上文脚手架的简单雏形中,我们已经使用到了,这里就不过多介绍了。

4. ora 命令行 loading 动效

更多用法 👉 文档地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const message = "Loading unicorns";

const spinner = ora(message);

spinner.start();

setTimeout(() => {
spinner.color = "red";
spinner.text = "Loading rainbows";

setTimeout(() => {
spinner.stop();
spinner.succeed("Loading succeed");
}, 2000);
}, 2000);

命令行是输出效果如下

5. cross-spawn 跨平台 shell 工具

更多用法 👉 文档地址

在脚手架里面,可以用来自动执行 shell 命令,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#! /usr/bin/env node

const spawn = require("cross-spawn");
const chalk = require("chalk");

const dependencies = ["vue", "vuex", "vue-router"];

const child = spawn("npm", ["install", "-D"].concat(dependencies), {
stdio: "inherit",
});

child.on("close", function (code) {
if (code !== 0) {
console.log(chalk.red("Error occurred while installing dependencies!"));
process.exit(1);
} else {
console.log(chalk.cyan("Install finished"));
}
});

同样的在命令行执行一下 my-vue 看一下执行结果

成功安装 👍

三、搭建自己的脚手架 🏗

先给我们的脚手架起个名字吧,正好祝融号登陆了火星,不如就叫:zhurong-cli 😆

1
2
3
4
5
6
7
8
9
   .-') _  ('-. .-.             _  .-')                    .-') _
( OO) )( OO ) / ( \( -O ) ( OO ) )
,(_)----. ,--. ,--. ,--. ,--. ,------. .-'),-----. ,--./ ,--,' ,----.
| | | | | | | | | | | /`. '( OO' .-. '| \ | |\ ' .-./-')
'--. / | .| | | | | .-') | / | |/ | | | || \| | )| |_( O- )
(_/ / | | | |_|( OO )| |_.' |\_) | |\| || . |/ | | .--, \
/ /___ | .-. | | | | `-' /| . '.' \ | | | || |\ | (| | '. (_/
| || | | |(' '-'(_.-' | |\ \ `' '-' '| | \ | | '--' |
`--------'`--' `--' `-----' `--' '--' `-----' `--' `--' `------'

需要实现哪些基本功能:

  1. 通过 zr create <name> 命令启动项目
  2. 询问用户需要选择需要下载的模板
  3. 远程拉取模板文件

搭建步骤拆解:

  1. 创建项目
  2. 创建脚手架启动命令(使用 commander)
  3. 询问用户问题获取创建所需信息(使用 inquirer)
  4. 下载远程模板(使用 download-git-repo)
  5. 发布项目

1. 创建项目

参照前面的例子,先创建一个简单的 Node-Cli 结构

1
2
3
4
5
zhurong-cli
├─ bin
│ └─ cli.js
├─ README.md
└─ package.json

配置脚手架启动文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "zhurong-cli",
"version": "1.0.0",
"description": "simple vue cli",
"main": "index.js",
"bin": {
"zr": "./bin/cli.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": {
"name": "T-Roc",
"email": "lxp_work@163.com"
},
"license": "MIT"
}

简单编辑一下我们的 cli.js

1
2
3
#! /usr/bin/env node

console.log("zhurong-cli working ~");

为了方便开发调试,使用 npm link 链接到全局

1
2
3
4
5
6
7
8
~/Desktop/cli/zhurong-cli ->npm link
npm WARN zhurong-cli@1.0.0 No repository field.

up to date in 1.327s
found 0 vulnerabilities

/usr/local/bin/zr -> /usr/local/lib/node_modules/zhurong-cli/bin/cli.js
/usr/local/lib/node_modules/zhurong-cli -> /Users/Desktop/cli/zhurong-cli

完成之后,接着测试一下

1
2
~/Desktop/cli/zhurong-cli ->zr
zhurong-cli working ~

OK,得到了我们想要的打印内容,接下来

2. 创建脚手架启动命令

简单分析一下我们要怎么做?

  1. 首先我们要借助 commander 依赖去实现这个需求
  2. 参照 vue-cli 常用的命令有 create、config 等等,在最新版本中可以使用 vue ui 进行可视化创建
  3. 如果创建的存在,需要提示是否覆盖

现在开始吧 😉

2.1 安装依赖

1
$ npm install commander --save

安装完成之后 👇

2.2 创建命令

打开 cli.js 进行编辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#! /usr/bin/env node

const program = require("commander");

program

.command("create <app-name>")
.description("create a new project")

.option("-f, --force", "overwrite target directory if it exist")
.action((name, options) => {
console.log("name:", name, "options:", options);
});

program

.version(`v${require("../package.json").version}`)
.usage("<command> [option]");

program.parse(process.argv);

在命令行输入 zr,检查一下命令是否创建成功

1
2
3
4
5
6
7
8
9
10
~/Desktop/cli/zhurong-cli ->zr
Usage: zr <command> [option]

Options:
-V, --version output the version number
-h, --help display help for command

Commands:
create [options] <app-name> create a new project
help [command] display help for command

我们可以看到 Commands 下面已经有了 create [options] <app-name>,接着执行一下这个命令

1
2
3
4
5
6
7
8
9
10
11
~/Desktop/cli/zhurong-cli ->zr create
error: missing required argument 'app-name'

~/Desktop/cli/zhurong-cli ->zr create my-project
执行结果 >>> name: my-project options: {}

~/Desktop/cli/zhurong-cli ->zr create my-project -f
执行结果 >>> name: my-project options: { force: true }

~/Desktop/cli/zhurong-cli ->zr create my-project --force
执行结果 >>> name: my-project options: { force: true }

成功拿到命令行输入信息 👍

2.3 执行命令

创建 lib 文件夹并在文件夹下创建 create.js

1
2
3
module.exports = async function (name, options) {
console.log(">>> create.js", name, options);
};

在 cli.js 中使用 create.js

1
2
3
4
5
6
7
8
9
10
11
12


......
program
.command('create <app-name>')
.description('create a new project')
.option('-f, --force', 'overwrite target directory if it exist')
.action((name, options) => {

require('../lib/create.js')(name, options)
})
......

执行一下 zr create my-project,此时在 create.js 正常打印了我们出入的信息

1
2
3
~/Desktop/cli/zhurong-cli ->zr create my-project
>>> create.js
my-project {}

在创建目录的时候,需要思考一个问题:目录是否已经存在?

  1. 如果存在
    • {force: true} 时,直接移除原来的目录,直接创建
    • {force: false} 时 询问用户是否需要覆盖
  2. 如果不存在,直接创建

这里用到了 fs 的扩展工具 fs-extra,先来安装一下

1
2

$ npm install fs-extra --save

我们接着完善一下 create.js 内部的实现逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const path = require("path");
const fs = require("fs-extra");

module.exports = async function (name, options) {
const cwd = process.cwd();

const targetAir = path.join(cwd, name);

if (fs.existsSync(targetAir)) {
if (options.force) {
await fs.remove(targetAir);
} else {
}
}
};

询问部分的逻辑,我们将在下文继续完善

2.3 创建更多命令

如果想添加其他命令也是同样的处理方式,这里就不扩展说明了,示例如下 👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
program
.command("config [value]")
.description("inspect and modify the config")
.option("-g, --get <path>", "get value from option")
.option("-s, --set <path> <value>")
.option("-d, --delete <path>", "delete option from config")
.action((value, options) => {
console.log(value, options);
});

program
.command("ui")
.description("start add open roc-cli ui")
.option("-p, --port <port>", "Port used for the UI Server")
.action((option) => {
console.log(option);
});

2.4 完善帮助信息

我们先看一下 vue-cli 执行 –help 打印的信息

对比 zr --help 打印的结果,结尾处少了一条说明信息,这里我们做补充,重点需要注意说明信息是带有颜色的,这里就需要用到我们工具库里面的 chalk 来处理

1
2
3
4
5
6
7
program.on("--help", () => {
console.log(
`\r\nRun ${chalk.cyan(
`zr <command> --help`
)} for detailed usage of given command\r\n`
);
});

如果此时我们想给脚手架整个 Logo,工具库里的 figlet 就是干这个的 😎

1
2
3
4
5
6
7
8
9
10
11
12
13
14
program.on("--help", () => {
console.log(
"\r\n" +
figlet.textSync("zhurong", {
font: "Ghost",
horizontalLayout: "default",
verticalLayout: "default",
width: 80,
whitespaceBreak: true,
})
);

console.log(`\r\nRun ${chalk.cyan(`roc <command> --help`)} show details\r\n`);
});

我们再看看此时的 zr --help 打印出来的是个什么样子

看起来还是挺不错的,哈哈 😄

3. 询问用户问题获取创建所需信息

这里召唤我们的老朋友 inquirer,让他来帮我们解决命令行交互的问题

接下来我们要做的:

  1. 上一步遗留:询问用户是否覆盖已存在的目录
  2. 用户选择模板
  3. 用户选择版本
  4. 获取下载模板的链接

3.1 询问是否覆盖已存在的目录

这里解决上一步遗留的问题:

  1. 如果目录已存在
    • {force: false} 时 询问用户是否需要覆盖

逻辑实际上已经完成,这里补充一下询问的内容

首选来安装一下 inquirer

1
$ npm install inquirer --save

然后询问用户是否进行 Overwrite

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
const path = require("path");

const fs = require("fs-extra");
const inquirer = require("inquirer");

module.exports = async function (name, options) {
const cwd = process.cwd();

const targetAir = path.join(cwd, name);

if (fs.existsSync(targetAir)) {
if (options.force) {
await fs.remove(targetAir);
} else {
let { action } = await inquirer.prompt([
{
name: "action",
type: "list",
message: "Target directory already exists Pick an action:",
choices: [
{
name: "Overwrite",
value: "overwrite",
},
{
name: "Cancel",
value: false,
},
],
},
]);

if (!action) {
return;
} else if (action === "overwrite") {
console.log(`\r\nRemoving...`);
await fs.remove(targetAir);
}
}
}
};

我们来测试一下:

  1. 在当前目录,即命令行中显示的目录下手动创建 2 个目录,这里随便取名为 my-project 和 my-project2
  2. 执行 zr create my-project,效果如下

  1. 执行 zr create my-project2 --f,可以直接看到 my-project2 被移除

**⚠️****注意:为什么这里只做移除?** 因为后面获取到模板地址后,下载的时候会直接创建项目目录

3.2 如何获取模版信息

模板我已经上传到远程仓库:github.com/zhurong-cli

vue3.0-template 版本信息 👇

vue-template 版本信息 👇

github 提供了

我们在 lib 目录下创建一个 http.js 专门处理模板和版本信息的获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const axios = require("axios");

axios.interceptors.response.use((res) => {
return res.data;
});

async function getRepoList() {
return axios.get("https://api.github.com/orgs/zhurong-cli/repos");
}

async function getTagList(repo) {
return axios.get(`https://api.github.com/repos/zhurong-cli/${repo}/tags`);
}

module.exports = {
getRepoList,
getTagList,
};

3.3 用户选择模板

我们专门新建一个 Generator.js 来处理项目创建逻辑

1
2
3
4
5
6
7
8
9
10
11
class Generator {
constructor(name, targetDir) {
this.name = name;

this.targetDir = targetDir;
}

create() {}
}

module.exports = Generator;

在 create.js 中引入 Generator 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24


...
const Generator = require('./Generator')

module.exports = async function (name, options) {



const cwd = process.cwd();

const targetAir = path.join(cwd, name)


if (fs.existsSync(targetAir)) {
...
}


const generator = new Generator(name, targetAir);


generator.create()
}

接着来写询问用户选择模版都逻辑

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
const { getRepoList } = require("./http");
const ora = require("ora");
const inquirer = require("inquirer");

async function wrapLoading(fn, message, ...args) {
const spinner = ora(message);

spinner.start();

try {
const result = await fn(...args);

spinner.succeed();
return result;
} catch (error) {
spinner.fail("Request failed, refetch ...");
}
}

class Generator {
constructor(name, targetDir) {
this.name = name;

this.targetDir = targetDir;
}

async getRepo() {
const repoList = await wrapLoading(getRepoList, "waiting fetch template");
if (!repoList) return;

const repos = repoList.map((item) => item.name);

const { repo } = await inquirer.prompt({
name: "repo",
type: "list",
choices: repos,
message: "Please choose a template to create project",
});

return repo;
}

async create() {
const repo = await this.getRepo();

console.log("用户选择了,repo=" + repo);
}
}

module.exports = Generator;

测试一下,看看现在是个什么样子

我选择了默认的 vue-template,此时

成功拿到模板名称 repo 的结果 ✌️

3.4 用户选择版本

过程和 3.3 一样

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69


const { getRepoList, getTagList } = require('./http')
...


async function wrapLoading(fn, message, ...args) {
...
}

class Generator {
constructor (name, targetDir){

this.name = name;

this.targetDir = targetDir;
}






async getRepo() {
...
}






async getTag(repo) {

const tags = await wrapLoading(getTagList, 'waiting fetch tag', repo);
if (!tags) return;


const tagsList = tags.map(item => item.name);


const { tag } = await inquirer.prompt({
name: 'tag',
type: 'list',
choices: tagsList,
message: 'Place choose a tag to create project'
})


return tag
}





async create(){


const repo = await this.getRepo()


const tag = await this.getTag(repo)

console.log('用户选择了,repo=' + repo + ',tag='+ tag)
}
}

module.exports = Generator;

测试一下,执行 zr create my-project

选择好了之后,看看打印结果

到此询问的工作就结束了,可以进行模板下载了

4. 下载远程模板

下载远程模版需要使用 download-git-repo 工具包,实际上它也在我们上面列的工具菜单上,但是在使用它的时候,需要注意一个问题,就是它是不支持 promise的,所以我们这里需要使用 使用 util 模块中的 promisify 方法对其进行 promise 化

4.1 安装依赖与 promise 化

1
$ npm install download-git-repo --save

进行 promise 化处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16


...
const util = require('util')
const downloadGitRepo = require('download-git-repo')

class Generator {
constructor (name, targetDir){
...


this.downloadGitRepo = util.promisify(downloadGitRepo);
}

...
}

4.2 核心下载功能

接着,就是模板下载部分的逻辑了

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
51
52
53
54
55
56
57
58
59
60
61


...
const util = require('util')
const path = require('path')
const downloadGitRepo = require('download-git-repo')


async function wrapLoading(fn, message, ...args) {
...
}

class Generator {
constructor (name, targetDir){
...


this.downloadGitRepo = util.promisify(downloadGitRepo);
}
...




async download(repo, tag){


const requestUrl = `zhurong-cli/${repo}${tag?'#'+tag:''}`;


await wrapLoading(
this.downloadGitRepo,
'waiting download template',
requestUrl,
path.resolve(process.cwd(), this.targetDir))
}






async create(){


const repo = await this.getRepo()


const tag = await this.getTag(repo)


await this.download(repo, tag)


console.log(`\r\nSuccessfully created project ${chalk.cyan(this.name)}`)
console.log(`\r\n cd ${chalk.cyan(this.name)}`)
console.log(' npm run dev\r\n')
}
}

module.exports = Generator;

完成这块,一个简单的脚手架就完成了 ✅

来试一下效果如何,执行 zr create my-project

这个时候,我们就可以看到模板就已经创建好了 👏👏👏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
zhurong-cli
├─ bin
│ └─ cli.js
├─ lib
│ ├─ Generator.js
│ ├─ create.js
│ └─ http.js
├─ my-project .............. 我们创建的项目
│ ├─ public
│ │ ├─ favicon.ico
│ │ └─ index.html
│ ├─ src
│ │ ├─ assets
│ │ │ └─ logo.png
│ │ ├─ components
│ │ │ └─ HelloWorld.vue
│ │ ├─ App.vue
│ │ └─ main.js
│ ├─ README.md
│ ├─ babel.config.js
│ └─ package.json
├─ README.md
├─ package-lock.json
└─ package.json

5. 发布项目

上面都是在本地测试,实际在使用的时候,可能就需要发布到 npm 仓库,通过 npm 全局安装之后,直接到目标目录下面去创建项目,如何发布呢?

  1. 第一步,在 git 上建好仓库
  2. 第二步,完善 package.json 中的配置
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
{
"name": "zhurong-cli",
"version": "1.0.4",
"description": "",
"main": "index.js",
"bin": {
"zr": "./bin/cli.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"files": ["bin", "lib"],
"author": {
"name": "T-Roc",
"email": "lxp_work@163.com"
},
"keywords": ["zhurong-cli", "zr", "脚手架"],
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"chalk": "^4.1.1",
"commander": "^7.2.0",
"download-git-repo": "^3.0.2",
"figlet": "^1.5.0",
"fs-extra": "^10.0.0",
"inquirer": "^8.0.0",
"ora": "^5.4.0"
}
}
  1. 第三步,使用 npm publish 进行发布,更新到时候,注意修改版本号

这样就发布成功了,我们打开 npm 网站搜索一下 🔍

已经可以找到它了,这样我们就可以通过 npm 或者 yarn 全局安装使用了

点此打开 👉 zhurong-cli 源码地址

四、Yeoman:一个通用的脚手架系统

Yeoman 最初发布于 2012 年,它是一款高效、开源的 Web 应用脚手架(scaffolding)软件,意在精简软件的开发过程。脚手架软件用于实现项目中多种不同的工具和接口的协同使用,优化项目的生成过程。允许创建任何类型的应用程序(Web,Java,Python,C#等)。

Yeoman 实际上是三个工具的总和:

  • yo — 脚手架,自动生成工具
  • grunt、gulp — 构建工具
  • bower、npm — 包管理工具

使用 Yeoman 搭建脚手架非常简单,Yeoman 提供了 yeoman-generator 让我们快速生成一个脚手架模板,我们可以通过各类 Generator 实现任何类型的项目搭建,下面我们来试一下 🤓

1. Yeoman 基础使用

Yeoman 是一套构建系统,在这里我们搭建脚手架需要使用的就是 yo 👇

1.1 全局范围安装 yo

1
$ npm install yo --global

1.2 安装对应的 generator

yo 搭配不同 generator-xxx 可以创建对应的项目,例如 generator-webappgenerator-nodegenerator-vue 等等,这里我们使用 generator-node 来演示操作。

1
$ npm install generator-node --global

1.3 通过 yo 运行 generator

1
2
3
$ mkdir yo-project
$ cd yo-project
$ yo node

这样我们就通过 yo + generator-node 快捷搭建一个 node 项目,目录结构如下 👇

1
2
3
4
5
6
7
8
9
10
11
12
13
yo-project
├─ .editorconfig
├─ .eslintignore
├─ .travis.yml
├─ .yo-rc.json
├─ LICENSE
├─ README.md
├─ lib
│ ├─ __tests__
│ │ └─ testCli.test.js
│ └─ index.js
├─ package-lock.json
└─ package.json

如何查找自己需要的 generator 呢?我们可以去官网 generators 列表搜索 👉 点此进入

这种使用方式真的非常的简单方便,但是它的问题也很明显 –不够灵活,毕竟不同的团队在使用的技术栈上都有所差异,如果我们想搭建自己想要的项目结构要怎么处理呢? 接着往下看 👇

2. 自定义 Generator

自定义 Generator 实际上就是创建一个特定结构的 npm 包,这个特定的结构是这样的 👇

1
2
3
4
5
generator-xxx ............ 自定义项目目录
├─ generators ............ 生成器目录
│ └─ app ................ 默认生成器目录
│ └─ index.js ........ 默认生成器实现
└─ package.json .......... 模块包配置文件

或者这样的 👇

1
2
3
4
5
6
generator-xxx
├─ app
│ └─ index.js
├─ router
│ └─ index.js
└─ package.json

这里我们需要注意的是,项目的名称必须是 generator-<name> 格式,才可以正常被 yo 识别出来,例如上面举例使用的 generator-node。

2.1 创建项目

1
2
$ mkdir generator-simple
$ cd generator-simple

2.2 初始化 npm

1
$ npm init

一路 enter 之后我们就生成好了 package.json,不过我们还需要额外检查一下:

  • name 属性值须是 “generator-“
  • keyword 中必须包含 yeoman-generator
  • files 属性要指向项目的模板目录。

完成上面的工作之后我们看一下 package.json 是个什么样子

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "generator-simple",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["yeoman-generator"],
"files": ["generators"],
"author": "ITEM",
"license": "MIT"
}

⚠️ 注意:这里如果使用的是第二种目录结构,那么 package.json 中需要做点修改 🔧

1
2
3
{
"files": ["app", "router"]
}

2.3 安装 yeoman-generator

yeoman-generator 是 Yeoman 提供的一个 Generator 基类,让我们在创建自定义 Generator 的时候更加便捷。

1
$ npm install yeoman-generator --save

2.4 Generator 基类的使用说明

在介绍 Generator 基类之前,我们先来实现一个简单的 🌰

首先打开核心入口文件,编辑内容如下 👇

1
2
3
4
5
6
7
8
9
10
const Generator = require("yeoman-generator");

module.exports = class extends Generator {
method1() {
console.log("I am a custom method");
}
method2() {
console.log("I am a custom method2");
}
};

完成之后,我们通过 npm link 的方式把项目链接到全局

1
$ npm link

这样我们就可以在全局去访问到 generator-simple 项目了,我们来试一下

1
$ yo simple

看一下控制台的输出

1
2
I am a custom method1
I am a custom method2

OK,是我们想要的结果 😎

⚠️ 注意,如果运行yo simple 出现下面的错误

1
2
3
This generator (simple:app)
requires yeoman-environment at least 3.0.0, current version is 2.10.3,
try reinstalling latest version of 'yo' or use '--ignore-version-check' option

可以这样处理:

方案一

1
2
3
4
5
6
7
8

npm uninstall yeoman-generator


npm i yeoman-generator@4.13.0


yo simple

方案二

1
2
3
4
5

npm i -g yeoman-environment


yoe run simple

从上面的小 🌰 我们可以看到我们自定义方法是自动顺序执行,Generator 基类也提供了一些顺序执行的方法,类似于生命周期一样,我们看一下有哪些 👇

  1. initializing – 初始化方法(检查状态、获取配置等)
  2. prompting – 获取用户交互数据(this.prompt())
  3. configuring – 编辑和配置项目的配置文件
  4. default – 如果 Generator 内部还有不符合任意一个任务队列任务名的方法,将会被放在 default 这个任务下进行运行
  5. writing – 填充预置模板
  6. conflicts – 处理冲突(仅限内部使用)
  7. install – 进行依赖的安装(eg:npm,bower)
  8. end – 最后调用,做一些 clean 工作

2.5 开始我们的自定义 Generator

我们借助 Generator 提供的方法,我们对入口文件改造一下

1
2
3
4
5
6
7
const Generator = require("yeoman-generator");

module.exports = class extends Generator {
writing() {
this.fs.write(this.destinationPath("temp.txt"), Math.random().toString());
}
};

运行一下看看

1
$ yo simple

这个时候,控制台输出出 create temp.txt,我们打印一下目录结构

1
2
3
4
5
6
7
generator-simple
├─ generators
│ └─ app
│ └─ index.js
├─ package-lock.json
├─ package.json
└─ temp.txt .............. writing 中创建的文件

打开新创建的 temp.txt 瞅瞅

1
2
0.8115477932475306
复制代码

可以看到文件中写入了一串随机数。

在实际使用的时候,我们需要通过模板去创建多个文件,这个时候我们就需要这样处理 👇

首先,创建模板文件目录 ./generators/app/templates/,并在文件夹中新增一个模板文件 temp.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title><%= title %></title>
</head>
<body>
<% if (success) { %>
<h1>这里是模版文件<%= title %></h1>
<% } %>
</body>
</html>

然后,修改一下入口文件 👇

1
2
3
4
5
6
7
8
9
10
11
12
13
const Generator = require("yeoman-generator");

module.exports = class extends Generator {
writing() {
const tempPath = this.templatePath("temp.html");

const output = this.destinationPath("index.html");

const context = { title: "Hello ITEM ~", success: true };

this.fs.copyTpl(tempPath, output, context);
}
};

完成之后yo simple 运行一下,这样我们就在根目录下得到了 index.html,打开看看 🤓

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>Hello ITEM ~</title>
</head>
<body>
<h1>这里是模版文件Hello ITEM ~</h1>
</body>
</html>

ejs 写入的变量,都已经被数据成功替换了 ✌️

接下来,我们要如何通过命令行交互获取用户自定义的一些数据,例如:项目名称、版本号等等。

这个就需要借助 Generator 提供的 Promting 来处理命令行的一些交互

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


const Generator = require('yeoman-generator');

module.exports = class extends Generator {

prompting(){
return this.prompt([
{
type: 'input',
name: 'name',
message: 'Your project name',
default: this.appname
}
])
.then(answers => {
console.log(answers)
this.answers = answers
})
}

writing () {
......
}
};

保存之后,再运行 yo simple

我们看到命令行询问了 Your Project name ?,在用户输入完成之后,我们拿到了 anwsers,这样我们就可以在接下来的流程里面去使用这个结果。

1
2
3
4
5
6
7
8
9
10
11

...

writing () {
...

const context = { title: this.answers.name, success: true}

this.fs.copyTpl(tempPath, output, context)
}
...

再运行一下 yo simple,查看输出的 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>my-project</title>
</head>
<body>
<h1>这里是模版文件my-project</h1>
</body>
</html>

我们可以看到用户输入的内容 {name: 'my-project'} 已经显示在我们的 index.html 里面了 👌

点此打开 👉 generator-simple 源码地址

yeoman 就介绍到这里,接下来我们来看另外一款脚手架工具 – plop 👇

五、plop:一款小而美的脚手架工具

plop 小在体积轻量,美在简单易用

更多使用方法 👉 plop 使用文档

我们可以将其直接集成到项目中,解决一下重复性的活着需要标准化的创建工作,下面我们就来做个小案例,比如

我们已经约定好了组件的创建规范

  • 组件名称使用大驼峰
  • 样式需要单独拧出来写
  • 需要搭配说明文档

plop 的使用过程大致可以拆解为

  1. 安装 plop,新增配置文件 plopfile.js
  2. 编辑 plop 配置文件
  3. 创建模板文件
  4. 执行创建任务

下面进入 coding 环节

1. 安装 plop

首先用我们的 zhurong-cli 初始化一个 vue 项目

1
2
3
4
# 全局安装
$ npm install zhurong-cli -g
# 创建 vue 项目
$ zr create plop-demo

我们这里为了团队统一使用,plop 直接就集成到项目之中

1
$ npm install plop --save-dev

项目目录下面创建 plop 的配置文件 plopfile.js

2. 编辑 plop 配置文件

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
module.exports = (plop) => {
plop.setGenerator("component", {
description: "create a component",

prompts: [
{
type: "input",
name: "name",
message: "Your component name",
default: "MyComponent",
},
],

actions: [
{
type: "add",

path: "src/components/{{ properCase name }}/index.vue",

templateFile: "plop-templates/component.vue.hbs",
},
{
type: "add",
path: "src/components/{{ properCase name }}/index.scss",
templateFile: "plop-templates/component.scss.hbs",
},
{
type: "add",
path: "src/components/{{ properCase name }}/README.md",
templateFile: "plop-templates/README.md.hbs",
},
],
});
};

上面用到 properCase 方法将 name 转化为大驼峰,其他格式还包括 👇

  • camelCase: changeFormatToThis
  • snakeCase: change_format_to_this
  • dashCase/kebabCase: change-format-to-this
  • dotCase: change.format.to.this
  • pathCase: change/format/to/this
  • properCase/pascalCase: ChangeFormatToThis
  • lowerCase: change format to this
  • sentenceCase: Change format to this,
  • constantCase: CHANGE_FORMAT_TO_THIS
  • titleCase: Change Format To This

我们看到上面已经引用了模板文件,实际上我们还没创建,接着创建一下

3. 创建模板文件

项目文件夹下面创建 plop-templates 文件夹,里面创建对应的模板文件

1
2
3
4
plop-templates
├─ README.md.hbs ............... 说明文档模板
├─ component.scss.hbs .......... 组件样式模板
└─ component.vue.hbs ........... 组件模板

模板引擎我们用到是 Handlebars ,更多语法说明 👉 Handlebars 中文网

编辑 component.scss.hbs

1
.{{ dashCase name }} { }

编辑 component.vue.hbs

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div class="{{ dashCase name }}">{{ name }}</div>
</template>

<script>
export default {
name: "{{ properCase name }}",
};
</script>

<style lang="scss">
@import "./index.scss";
</style>

编辑 README.md.hbs

1
这里是组件 {{ name }} 的使用说明

补充说明:

  • 这里模板都是最简单实现,实际生产中可以根据需求丰富模板内容
  • 模板中的 dashCase、properCase 为变更 name 命令的显示规则,上文已经列表过
    • dashCase:变为横线链接 aa-bb-cc
    • properCase:变为大驼峰 AaBbCc
    • ...
  • Handlebars 中使用变量,用 {{}} 包裹

4. 执行创建任务

打开 package.json

1
2
3
4
5
6
7

...
"scripts": {
...
"plop": "plop"
},
...

此时我们就可以使用 npm run plop 来创建组件了

很快组件就创建完成了 ✅

此时看一下 components 文件夹下面

1
2
3
4
5
6
components
├─ MyApp
│ ├─ README.md
│ ├─ index.scss
│ └─ index.vue
└─ HelloWorld.vue

已经创建了 MyApp 的组件了,里面的文件我们也打开看看

打开 MyApp/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div class="my-app">my-app</div>
</template>

<script>
export default {
name: 'MyApp',
}
</script>

<style lang="scss">
@import "./index.scss";

</style>

打开 MyApp/index.scss

1
2
.my-app {
}

打开 MyApp/README.md

1
这里是组件 my-app 的使用说明

点此打开 👉 plop-demo 源码地址

六、写在最后

不知道大家看完这篇文章,学废了吗 😂

本篇文章整理了很久,希望对大家的学习有所帮助 😁

另外也希望大家可以 点赞 评论 关注 支持一下,您的支持就是写作的动力 😘

预告一下,下一篇将带来 👉 打包与构建工具相关的知识体系


参考文章:

github.com/CodeLittleP…
cli.vuejs.org/zh/guide/cr…
yeoman.io/authoring/i…
www.jianshu.com/p/93211004c…
https://juejin.cn/post/6966119324478079007#heading-30


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!