从零构建自己的脚手架
简介
什么是CLI
CLI 全称是 Command Line Interface,是一类通过命令行交互的终端工具。日常工作中常用的脚手架有 vue-cli、create-react-app、angular-cli 等,都是通过简单的初始化命令,完成内容的快速构建。
为什么需要CLI
CLI 可以帮助我们更高效地操作计算机系统,我们可以将有规律可循的、重复的、繁琐的、模板化的工作,集成到CLI工具中。
-
GUI:更侧重易用性,用户通过点击图形界面,完成相关配置
-
CLI:更侧重操作效率,通过命令组合自动化操作、批量操作等
脚手架的简单雏形
脚手架就是在启动的时候询问一些简单的问题,并且通过用户回答的结果去渲染对应的模板文件,例如我们在使用 vue-cli创建一个 vue 项目时的时候 👇
step1:运行创建命令
vue create hello-world
step2:询问用户问题
step3:生成符合用户需求的项目文件
参考上面的流程我们可以自己来 搭建一个简单的脚手架雏形。
1. 在命令行启动 cli
目标: 实现在命令行执行 my-node-cli 来启动我们的脚手架
1.1 新建项目目录 my-node-cli
mkdir my-node-cli
cd my-node-cli
npm init
1.2 新建程序入口文件 cli.js
$ touch cli.js # 新建 cli.js 文件
在 package.json 文件中指定入口文件为 cli.js 👇
{
"name": "my-node-cli",
"version": "1.0.0",
"description": "",
"main": "cli.js",
"bin": "cli.js", // 手动添加入口文件为 cli.js
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
此时项目目录结构:
my-node-cli
├─ cli.js
└─ package.json
打开 cli.js 进行编辑
文件头部必须有 #!/usr/bin/env node,这是头部声明代码,用来告诉系统使用 NodeJS 执行脚本;如不声明,默认按shell去解析执行。
#! /usr/bin/env node
console.log('my-node-cli working~')
1.3 npm link 链接到全局
npm link
执行完成 ✅
我们就可以来测试了,在命令行中输入 my-node-cli 执行一下
这里我们就看到命令行中打印了:【my-node-cli working~】,此时最简单的一个demo就完成了👏
2. 询问用户信息
实现与询问用户信息的功能需要引入 inquirer.js 👉 文档看这里
npm install inquirer@8.2.5 --dev // 版本要低于9,否则报语法错误,详见【遇到的问题】
接着我们在 cli.js 来设置我们的问题
#! /usr/bin/env node
// #! 用于指定脚本的解释程序,Node CLI 应用入口文件必须要有这样的文件头
const inquirer = require('inquirer');
inquirer.prompt([
{
type: 'input', //type: input, number, confirm, list, checkbox ...
name: 'name', // key 名
message: 'Your name', // 提示信息
default: 'my-node-cli' // 默认值
}
]).then(answers => {
console.log(answers); // 打印互动的输入结果
})
在命令行输入 my-node-cli 看一下执行结果
这里我们就拿到了用户输入的项目名称 { name: ‘hello-ranran’ }, 👌
3. 生成对应的文件
3.1 新建模版文件夹
mkdir templates # 创建模版文件夹
3.2 新建 index.html 和 common.css 两个简单的示例文件
// index.html
<!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>
<!-- ejs 语法 -->
<%= name %>
</title>
</head>
<body>
<h1><%= name %></h1>
</body>
</html>
/* common.css */
body {
margin: 20px auto;
background-color: azure;
}
此时的代码结构:
my-node-cli
├─ templates
│ ├─ common.css
│ └─ index.html
├─ cli.js
├─ package-lock.json
└─ package.json
3.3 接着完善文件生成逻辑
这里借助 ejs 模版引擎将用户输入的数据渲染到模版文件上
npm install ejs --save
完善后到 cli.js 👇
#! /usr/bin/env node
// #! 用于指定脚本的解释程序,Node CLI 应用入口文件必须要有这样的文件头
const inquirer = require('inquirer');
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
inquirer
.prompt([
{
type: 'input', //type: input, number, confirm, list, checkbox ...
name: 'name', // key 名
message: 'Your name', // 提示信息
default: 'my-node-cli' // 默认值
}
])
.then((answers) => {
const destUrl = path.join(__dirname, 'templates'); // 模版文件目录
const cwdUrl = process.cwd(); // 生成文件目录,process.cwd() 对应控制台所在目录
// 从模版目录中读取文件
fs.readdir(destUrl, (err, files) => {
if (err) throw err;
// 使用 ejs 渲染对应的模版文件
files.forEach((file) => {
// renderFile(模版文件地址,传入渲染数据)
ejs.renderFile(path.join(destUrl, file), answers).then((data) => {
// 生成 ejs 处理后的模版文件
fs.writeFileSync(path.join(cwdUrl, file), data);
});
});
});
});
同样,在控制台执行一下 my-node-cli ,此时 index.html、common.css 已经成功创建 ✔
我们打印一下当前的目录结构 👇
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 文件看一下
用户输入的 { name: ‘my-app’ } 已经添加到了生成的文件中了 ✌️
热门脚手架工具库
实际生产中搭建一个脚手架或者阅读其他脚手架源码的时候需要了解下面这些工具库 👇
名称 | 简介 |
---|---|
commander | 命令行自定义指令 |
inquirer | 命令行询问用户问题,记录回答结果 |
chalk | 控制台输出内容样式美化 |
ora | 控制台 loading 样式 |
figlet | 控制台打印 logo |
cli-table3 | 控制台输出表格 |
download-git-repo | 下载远程模版 |
fs-extra | 系统fs模块的扩展,提供了更多便利的 API,并继承了fs模块的 API |
cross-spawn | 支持跨平台调用系统上的命令 |
重点介绍下面这些,其他工具可以查看说明文档
1. commander 自定义命令行指令(在线文档)
安装依赖
npm install commander
完善cli.js代码
#! /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 链接到全局
-
执行 npm link 将应用 my-node-cli 链接到全局
-
完成之后,在命令行中执行 my-node-cli
看下命令行的输出内容
这个时候就有了 my-node-cli命令使用的说明信息
2. chalk 命令行美化工具(在线文档)
安装依赖
npm install chalk@4.1.2
完善cli.js代码
#! /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));
// 使用RGB颜色输出
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();
npm link 链接到全局
-
执行 npm link 将应用 my-node-cli 链接到全局
-
完成之后,在命令行中执行 my-node-cli create my-app
看下命令行的输出内容
具体的样式对照表如下
3. inquirer 命令行交互工具(在线文档)
inquirer 在脚手架工具中的使用频率是非常高的,在上文脚手架的简单雏形中,我们已经使用到了,这里就不过多介绍了。
4. ora 命令行 loading 动效(在线文档)
安装依赖
npm install ora@5.x
完善cli.js代码
#! /usr/bin/env node
const ora = require('ora');
// 自定义文本信息
const message = 'Loading...';
// 初始化
const spinner = ora(message);
// 开始加载动画
spinner.start();
setTimeout(() => {
// 修改动画样式
// Type: string
// Default: 'cyan'
// Values: 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray'
spinner.color = 'red';
spinner.text = 'Loading rainbows';
setTimeout(() => {
// 加载状态修改
spinner.stop(); // 停止
spinner.succeed('Loading succeed'); // 成功 ✔
// spinner.fail(text?); 失败 ✖
// spinner.warn(text?); 提示 ⚠
// spinner.info(text?); 信息 ℹ
}, 2000);
}, 2000);
npm link 链接到全局
-
执行 npm link 将应用 my-node-cli 链接到全局
-
完成之后,在命令行中执行 my-node-cli
看下命令行的输出内容
请至钉钉文档查看附件《未命名_副本.mov》
5. cross-spawn 跨平台shell工具(在线文档)
安装依赖
npm install cross-spawn
完善cli.js代码
#! /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'));
}
});
看下命令行的输出内容
成功安装 👍
搭建自己的脚手架
需要实现的功能
-
通过 ranran-cli create 命令启动项目
-
如果重名则询问用户是否进行覆盖
-
远程拉取模板文件
搭建步骤拆解
-
创建项目
-
创建脚手架启动命令(使用 commander)
-
如果重名则询问用户是否进行覆盖
-
下载远程模板(使用 download-git-repo)
代码实现
目录结构
package.json
{
"name": "ranran-cli",
"version": "1.0.0",
"description": "simple vue cli",
"main": "index.js",
"bin": {
"ranran-cli": "./bin/cli.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"files": [
"bin",
"lib"
],
"author": "ranran",
"keywords": [
"ranran-cli",
"脚手架"
],
"license": "ISC",
"dependencies": {
"chalk": "^4.1.2",
"commander": "^10.0.1",
"cross-spawn": "^7.0.3",
"download-git-repo": "^3.0.2",
"ejs": "^3.1.9",
"figlet": "^1.6.0",
"fs-extra": "^11.1.1",
"inquirer": "^8.2.5",
"ora": "^5.4.1"
}
}
脚手架启动文件****cli.js
#! /usr/bin/env node
const program = require('commander');
const figlet = require('figlet');
program
.command('create <app-name>')
.description('create a new project')
.option('-f, --force', 'overwrite target directory if it exist') // 是否强制创建,当文件夹已经存在
.action((name, options) => {
// 在 create.js 中执行创建任务
require('../lib/create.js')(name, options);
});
program.on('--help', () => {
console.log(
'\r\n' +
figlet.textSync('ranran', {
font: 'Ghost',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 80,
whitespaceBreak: true
})
);
});
// 解析用户执行命令传入参数
program.parse(process.argv);
创建 lib 文件夹并在文件夹下创建 create.js
const path = require('path');
// fs-extra 是对 fs 模块的扩展,支持 promise 语法
const fs = require('fs-extra');
const inquirer = require('inquirer');
const Generator = require('./utils');
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);
}
}
}
// 创建项目
const generator = new Generator(name);
// 开始创建项目
generator.create();
};
创建 lib 文件夹并在文件夹下创建 utils.js
const downloadGitRepo = require('download-git-repo');
const ora = require('ora');
const chalk = require('chalk');
const bsInitOriginUrl = '@git/xx.xxx.x.x:xx/xx.git';
const bsInitUrl = 'gitlab:xx.xxx.x.x:xx/xx#dev';
// 项目模板远程下载
const downloadTemplate = async (ProjectName, api) => {
return new Promise((resolve, reject) => {
downloadGitRepo(api, ProjectName, { clone: true }, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
};
class Generator {
constructor(name) {
// 目录名称
this.name = name;
}
async download() {
let loading = ora().start(`Start cloning template... ${chalk.yellow(bsInitOriginUrl)}`);
await downloadTemplate(this.name, bsInitUrl);
setTimeout(() => {
loading.succeed(`\r\nSuccessfully created project ${chalk.cyan(this.name)}`);
console.log(`\r\n cd ${chalk.cyan(this.name)}`);
console.log(' npm run dev\r\n');
}, 2000);
}
// 下载模板到模板目录
async create() {
// 下载模板到模板目录
await this.download();
}
}
module.exports = Generator;
遇到的问题
使用my-node-cli报错
原因:发现安装的是看了一下【inquirer】的版本号是9以上的
解决方法:降【inquirer】的版本到8.2.5
npm i inquirer@8.2.5
npm link只需要执行一次
代码更改后,不需要重新执行npm link
如果是修改了执行命令的别名,则需要重新执行npm link
删除软链接
更改入口文件后重新进行npm link报错,已修改入口文件地址。
使用npm unlink、npm link --force均无效。
解决方法:找到npm软链的目录,删除相应的文件,有两处都需要删干净。
重新 npm link后生效。
参考文档
https://juejin.cn/post/6966119324478079007#heading-38
https://juejin.cn/post/7178666619135066170#heading-21