导航
导航
文章目录
  1. 开始
    1. 初始化项目
    2. 依赖
  2. 初探
    1. 1、创建一个用于运行命令的脚本
    2. 2、添加命令
    3. 3、全局运行命令调试
  3. 脚手架开发
    1. 1、效果
    2. 2、准备项目模版
    3. 3、初始Command
    4. 4、定义Command命令
    5. 5、处理生成项目结构逻辑
      1. 5.1、思路
      2. 5.2、checkAppName
      3. 5.3、checkEmpty
      4. 5.4、checkExist
      5. 5.5、downloadAndGenerate
  4. 发布到npm
    1. 更新包
  5. 优化
    1. 1、添加小面板
    2. 2、检查包的线上版本与本地版本
    3. 3、README.md添加徽标
  6. 深入inquirer.js
    1. 1、基本用法
    2. 2、参数详解
    3. 3、实例
  7. 感谢

动手开发一个自己的项目脚手架

前言:随着前端工程化的不断深入,同时 Node 给前端开发带来了很大的改变,促进了前端开发的自动化,越来越多的人选择使用脚手架来从零到一搭建自己的项目。其中最熟悉的就是vue-cli和create-react-app,它们可以帮助我们初始化配置、生成项目结构、自动安装依赖等等,最后我们一行指令即可运行项目开始开发,或者进行项目构建(build)。在实际的开发过程中,我们可能会有自己的特定需求,那么我们就得学会如何开发一个Node命令行工具。

在前面的文章 动手搭建react开发环境系列 中,结尾处我们说到,既然我们的项目结构搭建好了,但不能每次开发都来手动复制项目结构,所以我们就要通过执行命令就生成我们需要的项目结构。

我们的初步设想是,在指定目录下执行一个命令(假设为create)

1
hzzly create demo

就会生成一个目录名为 demo 的项目,里面包含有我们所需的基础项目结构。

开始

初始化项目

1
2
3
4
mkdir hzzly-cli
cd hzzly-cli
mkdir bin lib
npm init -y

依赖

1
yarn add commander chalk boxen fs-extra inquirer ora update-notifier download-git-repo rimraf
  • commander 一款重量轻,表现力和强大的命令行框架
  • chalk 用于打印彩色的信息
  • boxen 创建小“面板”
  • inquirer 交互式命令行用户界面的集合
  • ora 用于创建 spinner,添加下载模板 loading 效果
  • update-notifier 用于检查包的线上版本与本地版本
  • download-git-repo 从节点下载并提取git存储库

初探

1、创建一个用于运行命令的脚本

1
2
3
// bin/hzzly.js
#! /usr/bin/env node
console.log("hello world ~");

执行

1
node bin/hzzly.js

不出意外的话能够看到输出了 hello world ~,当然这不是我们想要的结果,我们是要直接运行 hzzly 命令就能输出 hello world ~

🔥Tip: 主入口文件的最上方添加代码 #! /usr/bin/env node, 表明这是一个可执行的应用

2、添加命令

1
2
3
4
5
6
7
// package.json
{
// ...
"bin": {
"hzzly": "bin/hzzly.js"
},
}

这里我们指定 hzzly 命令的执行文件为 bin/hzzly.js。

3、全局运行命令调试

在项目目录下运行:

1
2
3
npm install . -g
// 或
npm link

这样就可以使用 hzzly 命令了。

到此,一个本地的 npm 命令行工具就已经成功完成了,接下来我们就来完善具体的功能。

脚手架开发

1、效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Usage: hzzly <command> [options] <app-name> [folder-name]

Options:
-v, --version output the version number
-c, --clone use git clone
-h, --help output usage information

Commands:
setup run remote setup commands
create generate a new project from a react template
check check test

Examples:

# create a new react project
$ hzzly create demo

2、准备项目模版

脚手架是帮助我们快速生成一套既定的项目架构、文件、配置,而最常见的做法的就是先写好一套项目框架模版,等到脚手架要生成项目时,则将这套模版拷贝到目标目录下。

  • 一种是直接放在本地
  • 另一种是托管在 github 上

这里我们选择托管在 github,然后通过download-git-repo下载到指定目录。我准备了一个项目模版,之后就会用它来作为脚手架生成的项目结构。

3、初始Command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// bin/hzzly.js
const program = require('commander');
const chalk = require("chalk");
const pkg = require('../package.json');

program
.version(pkg.version)
.usage('<command> [options] <app-name> [folder-name]')
.option("-c, --clone", "use git clone")
.on("--help", () => {
console.log();
console.log("Examples:");
console.log();
console.log(
chalk.gray(" # create a new react project")
);
console.log(" $ hzzly create demo");
console.log();
});
program.parse(process.argv)

这样,当我们执行 hzzly 命令时就会有如下效果:

1
2
3
4
5
6
7
8
9
10
11
Usage: hzzly <command> [options] <app-name>

Options:
-V, --version output the version number
-c, --clone use git clone
-h, --help output usage information

Examples:

# create a new react project
$ hzzly create demo

接下来就可以去定义我们的 Commands 了。

4、定义Command命令

program.parse(process.argv) 前面去定义我们的command命令

为什么要在它前面去定义命令呢?

parse 用于解析process.argv,设置options以及触发commands

1
2
3
4
5
6
7
8
9
10
11
12
// bin/hzzly.js
// 同上...
program
.command('create')
.description('generate a new project from a template')
.option("-c, --clone", "use git clone")
.action((appName, option) => {
// 获得了参数,可以在这里做响应的业务处理
console.log(`指令 create 后面跟的参数值: ${appName}`);
console.log(option);
});
// 同上
  • command 定义命令行指令
  • description 命令描述,它会在help里面展示
  • option 定义参数。它接受四个参数,在第一个参数中,它可输入短名字 -a和长名字–name ,使用 | 或者 , 分隔,在命令行里使用时,这两个是等价的,区别是后者可以在程序里通过回调获取到;第二个为描述, 会在 help 信息里展示出来;第三个参数为回调函数,他接收的参数为一个string,有时候我们需要一个命令行创建多个模块,就需要一个回调来处理;第四个参数为默认值
  • action 注册一个 callback 函数

接下来就是处理生成项目模板的逻辑了,继续。

5、处理生成项目结构逻辑

5.1、思路

  • 1、输入 vue create 提示输入项目文件夹名称
  • 2、输入 vue create . 表示在当前目录构建项目,但要给个提示(是否确定要在所在目录生成项目,其它文件将被删除)
  • 3、输入 vue create app 表示在当前目录生成一个 app 的目录并在此目录构建项目,当有相同的目录时也要提示(当前目录已存在,是否继续构建)

好了,思路有了就 so easy 了

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
// bin/hzzly.js
// 同上...
program
.command('create')
.description('generate a new project from a template')
.option("-c, --clone", "use git clone")
.action((appName) => {
// // 获得了参数,可以在这里做响应的业务处理
// console.log(`指令 create 后面跟的参数值: ${appName}`);
// 判断是否有传appName
if (typeof appName === 'string') {
// 判断是否有相同 appName 目录
checkAppName(appName);
} else {
// 没有传appName的话提示用户输入
const opts = [{
type: 'input',
name: 'appName',
message: 'Please enter the app name for your project:',
validate: appName => {
if (!appName) {
return '⚠️ app name must not be null!';
}
return true;
}
}];
// inquirer命令行交互工具
inquirer.prompt(opts).then(({ appName }) => {
if (appName) {
// 输入完之后判断是否有相同 appName 目录
checkAppName(appName);
}
})
}
});
// 同上

5.2、checkAppName

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
// 处理项目名称
function checkAppName(appName) {
// 获取绝对路径
const to = path.resolve(appName);
// 判断是否在当前目录构建
if (appName === '.') {
// 判断当前目录是否为空
checkEmpty(to)
} else if (checkExist(to)) {
// 如果传入的 appName 在当前目录已存在
inquirer.prompt([{
type: 'confirm',
message: 'Target directory exists. Continue?',
name: 'ok',
}]).then(answers => {
if (answers.ok) {
// 回答是的话删除已存在的目录并下载模板构建项目
rm(appName)
downloadAndGenerate(REACT_TPL, to, appName)
}
})
} else {
// 如果以上情况都不是就直接下载模板构建项目
downloadAndGenerate(REACT_TPL, to, appName)
}
}

5.3、checkEmpty

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 判断目录是否为空
function checkEmpty(path, appName) {
const dirFiles = fs.readdirSync(path);
if (dirFiles.length > 0) {
inquirer.prompt([{
type: 'confirm',
name: 'ok',
message: 'Target directory is not empty and will overwritten. Continue?',
}]).then(answers => {
if (answers.ok) {
fs.emptyDirSync(path)
downloadAndGenerate(REACT_TPL, path, appName)
}
})
} else {
downloadAndGenerate(REACT_TPL, path, appName)
}
}

5.4、checkExist

1
2
3
4
// 判断目录是否已存在
function checkExist(path) {
return fs.pathExistsSync(path);
}

5.5、downloadAndGenerate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 下载模板构建项目
function downloadAndGenerate(template, tmp) {
// 生成下载loading
const spinner = ora("downloading template");
const clone = program.clone || false;
spinner.start();
download(template, tmp, { clone }, err => {
spinner.stop();
if (err) {
console.error(
chalk.red(
"Failed to download repo " + template + ": " + err.message.trim()
)
);
process.exit(1)
}
// 下载完成后提示用户操作
console.log(`To get started:\n\n cd ${tmp}\n npm install\n npm run dev\n\nDocumentation can be found at https://github.com/hzzly`);
});
}

看到这,开发阶段就可告一段落了,我们已经可以通过 hzzly create <app-name> 命令行构建项目目录的步骤,接下来就是发布到npm给其他人使用。

发布到npm

到目前为止,我们开发的 hzzly 还是在本地的,现在就该将其发布到 npm 上了。

1、首先 注册一个账号

2、在终端执行

1
npm login

输入用户名、密码和邮箱便可将本地机器与 npm 连接起来了。

3、修改package.json

1
2
3
4
5
6
{
// ...
"files": [
"bin/"
],
}

添加 files 属性指定哪些文件提交到 npm,这样可以减少包的大小。

4、发布

1
npm publish

更新包

首先修改 package.json 配置文件中的 version 字段,比如这里我从 1.0.0 改成 1.0.1(只能大于当前版本),然后修改脚手架,最后再次

1
npm publish

优化

1、添加小面板

boxen

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
const boxen = require('boxen');

const BOXEN_OPTS = {
padding: 1,
margin: 1,
align: 'center',
borderColor: '#678491',
borderStyle: 'round'
};

function initializing(pkg) {
const messages = [];
messages.push(
`🔥 Welcome to use hzzly-cli ${chalk.grey(`v${pkg.version}`)}`
);
messages.push(
chalk.grey('https://github.com/hzzly/hzzly-cli')
);
messages.push(
chalk.grey('https://www.npmjs.com/package/hzzly-cli')
)
console.log(boxen(messages.join('\n'), BOXEN_OPTS));
}

program
.command('create')
.description('generate a new project from a template')
.option("-c, --clone", "use git clone")
.action((appName) => {
// 调用小面板
initializing(pkg)
// ...
})

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
const updateNotifier = require('update-notifier');

function checkVersion(pkg) {
console.log();
console.log('🛠️ Checking your hzzly-cli version...');

const notifier = updateNotifier({
pkg,
updateCheckInterval: 0
});

const update = notifier.update;
if (update) {
const messages = [];
messages.push(`Update available ${chalk.grey(update.current)}${chalk.green(update.latest)}`)
messages.push(`Run ${chalk.cyan(`npm i -g ${pkg.name}`)} to update`)
console.log(boxen(messages.join('\n'), { ...BOXEN_OPTS, borderColor: '#fae191' }));
console.log('🛠️ Finish checking your hzzly-cli. CAUTION ↑↑', '⚠️');
}
else {
console.log('🛠️ Finish checking your hzzly-cli. OK', chalk.green('✔'));
}
}

function initializing(pkg) {
// ...
checkVersion(pkg)
}

3、README.md添加徽标

推荐自动生成徽标网站 shields.io

NPM version

MIT Licence

深入inquirer.js

创建脚手架的时候我们会发现很多脚手架都需要我们和命令行频繁交互,就像我们使用npm init的时候一样,那么是如何实现和命令行交互的呢?此时inquirer.js闪亮登场。

1、基本用法

1
2
3
4
5
const inquirer = require('inquirer');
inquirer.prompt([/* opts */])
.then((answers) => {
// Use answers for... whatever!!
})

2、参数详解

  • type:表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor;
  • name: 存储当前问题回答的变量;
  • message:问题的描述;
  • default:默认值;
  • choices:列表选项,在某些type下可用,并且包含一个分隔符(separator);
  • validate:对用户的回答进行校验;
  • filter:对用户的回答进行过滤处理,返回处理后的值;
  • transformer:对用户回答的显示效果进行处理(如:修改回答的字体或背景颜色),但不会影响最终的答案的内容;
  • when:根据前面问题的回答,判断当前问题是否需要被回答;
  • pageSize:修改某些type类型下的渲染行数;
  • prefix:修改message默认前缀;
  • suffix:修改message默认后缀。

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
const opts = [
{
type: 'input',
message: '姓名',
name: 'name'
}, {
type: 'input',
message: '手机号',
name: 'phone',
validate: val => {
if (val.match(/\d{11}/g)) {
return true
}
return '请输入11位数字'
}
}, {
type: 'confirm',
message: '是否参加本次考核?',
name: 'assess'
}, {
type: 'confirm',
message: '是否同意本次考核须知?',
name: 'notice',
when: answers => {
return answers.assess
}
}, {
type: 'list',
message: '欢迎来到本次考核,请选择语言:',
name: 'eductionBg',
choices: [
"js",
"java",
"php"
],
filter: val => {
// 将选择的内容后面加语言
return val + '语言'
}
}, {
type: 'rawlist',
message: '请选择你喜欢逛的社区:',
name: 'game',
choices: [
"掘金",
"github",
]
}, {
type: 'expand',
message: '请选择你喜欢的水果:',
name: 'fruit',
choices: [
{
key: "a",
name: "Apple",
value: "apple"
},
{
key: "O",
name: "Orange",
value: "orange"
},
{
key: "p",
name: "Pear",
value: "pear"
}
]
}, {
type: 'checkbox',
message: '请选择你喜欢的颜色:',
name: 'color',
choices: [
{
name: "red"
},
new inquirer.Separator(), // 添加分隔符
{
name: "blur",
checked: true // 默认选中
},
{
name: "green"
},
new inquirer.Separator("--- 分隔符 ---"), // 自定义分隔符
{
name: "yellow"
}
]
}, {
type: 'password',
message: '请输入你的密码:',
name: 'pwd'
}
]

inquirer.prompt(opts).then(answers=>{
console.log(answers);
})

inquirer

代码已上传至我的GitHub,欢迎 Star、Fork

感谢

Nodejs 制作命令行工具

用一次就会爱上的cli工具开发

commander.js