构建前端脚手架/cli知识

Posted by cl9000 on September 23, 2021

那些疯狂到认为自己能够改变世界的人,才能真正改变世界。——<史蒂夫·乔布斯>

介绍

脚手架的作用就是创建项目的基本结构并提供项目规范和约定

  • 组织结构
  • 开发范式
  • 模块依赖
  • 工具配置
  • 代码基础

目前工作中常用的脚手架有 vue-cli、create-react-app、angular-cli、yoeman,express-generator 等,都是通过简单的初始化命令,快速搭建一个完整的项目的结构。
脚手架是我们经常使用的工具,也是团队提效的重要手段。

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

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

例如 在使用 vue-cli 创建 Vue 项目时。
【vue-cli 文档】- https://cli.vuejs.org/zh/guide/creating-a-project.html

脚手架工具库

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

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

详细可以查看具体说明文档

脚手架 v1 (简单版本)

1. 创建项目

先创建一个简单的 Node-Cli 结构

1
2
3
4
5
6
7
8
9
10
11
12
cl9000-cli          
├─ bin
│ └─ cli.js # 启动文件
├─ lib
│ └─ constants.js
│ └─ create.js
│ └─ generator.js
│ └─ http.js
├─ .npmrc
├─ LICENSE
├─ package.json
└─ README.md

配置文件 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
30
31
32
33
34
35
36
{
"name": "cl9000-cli",
"version": "1.0.0",
"description": "simple vue cli",
"main": "index.js",
"bin": {
"cl": "./bin/cli.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"files": [
"bin",
"lib"
],
"author": {
"name": "cl9000",
"email": "cl9000@126.com"
},
"keywords": [
"cl9000-cli",
"cl",
"脚手架"
],
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"chalk": "^4.1.2",
"commander": "^8.1.0",
"download-git-repo": "^3.0.2",
"figlet": "^1.5.2",
"fs-extra": "^10.0.0",
"inquirer": "^8.1.2",
"ora": "^5.4.0"
}
}

简单编辑一下 cli.js

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

console.log('cl9000-cli doing')

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

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

up to date in 1.327s
found 0 vulnerabilities

/usr/local/bin/cl -> /usr/local/lib/node_modules/cl9000-cli/bin/cli.js
/usr/local/lib/node_modules/cl9000-cli -> /Users/Desktop/cli/cl9000-cli

测试一下

1
2
~/Desktop/cli/cl9000-cli ->cl
cl9000-cli doing # 打印内容

我们得到了想要的打印内容。继续下一步。

2. 创建脚手架启动命令

安装依赖

1
2
3
4
5
6
7
8
$ npm install commander --save
$ npm install chalk --save
$ npm install figlet --save
$ npm install path --save
$ npm install fs-extra --save
$ npm install inquirer --save
$ npm install ora --save
$ npm install download-git-repo --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
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
#! /usr/bin/env node
console.log('cl9000-cli doing')

const program = require('commander')
const chalk = require('chalk')
const figlet = require('figlet')

// 定义命令和参数

// 配置版本号信息
program
.version(`v${require('../package.json').version}`)
.usage('<command> [option]')

// 创建 命令
program
.command('create <app-name>')
.description('create a new project')
// -f or --force 为强制创建,如果创建的目录存在则直接覆盖
.option('-f, --force', 'overwrite target directory if it exist')
.action((name, options) => {
// 打印执行结果
console.log('name:', name, 'options:', options)
// 在 create.js 中执行创建任务
require('../lib/create.js')(name, options)
})

// 配置 config 命令
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)
})

// 配置 ui 命令
program
.command('ui')
.description('start add open cl9000-cli ui')
.option('-p, --port <port>', 'Port used for the UI Server')
.action((option) => {
console.log(option)
})

// 监听 --help 执行
program
.on('--help', () => {
// 使用 figlet 绘制 Logo
console.log('\r\n' + figlet.textSync('cl9000', {
font: 'Ghost',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 80,
whitespaceBreak: true
}));
// 新增说明信息
console.log(`\r\nRun ${chalk.cyan(`cl <command> --help`)} for detailed usage of given command\r\n`)
})

// 解析用户执行命令传入参数
program.parse(process.argv);

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

询问创建并进行覆盖

编辑 create.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
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
// lib/create.js

const path = require('path')
// fs-extra 是对 fs 模块的扩展,支持 promise 语法
const fs = require('fs-extra')
const inquirer = require('inquirer')
const Generator = require('./generator')

module.exports = async function (name, options) {
// 验证是否正常取到值
console.log('>>> create.js', 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, targetAir);

// 开始创建项目
generator.create()
}

如何获取模版信息

模板上传到远程仓库

lib 目录下创建 constants.js 静态常量文件

1
2
3
// lib/constants.js
exports.REPO_ORGS = 'https://api.github.com/orgs/cl9000-org/';
exports.REPOS_ORG = 'https://api.github.com/repos/cl9000-org/';

lib 目录下创建一个 http.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
31
32
// lib/http.js

// 通过 axios 处理请求
const axios = require('axios')
const { REPO_ORGS, REPOS_ORG } = require('./constants');

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


/**
* 获取模板列表
* @returns Promise
*/
async function getRepoList() {
return axios.get(REPO_ORGS + 'repos')
}

/**
* 获取版本信息
* @param {string} repo 模板名称
* @returns Promise
*/
async function getTagList(repo) {
return axios.get(`${REPOS_ORG}${repo}/tags`)
}

module.exports = {
getRepoList,
getTagList
}

新建一个 Generator.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
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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// lib/generator.js

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

const util = require('util')
const path = require('path')
const downloadGitRepo = require('download-git-repo') // 不支持 Promise

// 添加加载动画
async function wrapLoading(fn, message, ...args) {
// 使用 ora 初始化,传入提示信息 message
const spinner = ora(message);
// 开始加载动画
spinner.start();

try {
// 执行传入方法 fn
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;

// 对 download-git-repo 进行 promise 化改造
this.downloadGitRepo = util.promisify(downloadGitRepo);
}

// 获取用户选择的模板
// 1)从远程拉取模板数据
// 2)用户选择自己新下载的模板名称
// 3)return 用户选择的名称
async getRepo() {
// 1)从远程拉取模板数据
const repoList = await wrapLoading(getRepoList, 'waiting fetch template');
if (!repoList) return;

// 过滤我们需要的模板名称
const repos = repoList.map(item => item.name);

// 2)用户选择自己新下载的模板名称
const { repo } = await inquirer.prompt({
name: 'repo',
type: 'list',
choices: repos,
message: 'Please choose a template to create project'
})

// 3)return 用户选择的名称
return repo;
}

// 获取用户选择的版本
// 1)基于 repo 结果,远程拉取对应的 tag 列表
// 2)用户选择自己需要下载的 tag
// 3)return 用户选择的 tag
async getTag(repo) {
// 1)基于 repo 结果,远程拉取对应的 tag 列表
const tags = await wrapLoading(getTagList, 'waiting fetch tag', repo);
if (!tags) return;

// 过滤我们需要的 tag 名称
const tagsList = tags.map(item => item.name);

// 2)用户选择自己需要下载的 tag
const { tag } = await inquirer.prompt({
name: 'tag',
type: 'list',
choices: tagsList,
message: 'Place choose a tag to create project'
})

// 3)return 用户选择的 tag
return tag
}

// 下载远程模板
// 1)拼接下载地址
// 2)调用下载方法
async download(repo, tag) {

// 1)拼接下载地址
const requestUrl = `cl9000-org/${repo}${tag ? '#' + tag : ''}`;

// 2)调用下载方法
await wrapLoading(
this.downloadGitRepo, // 远程下载方法
'waiting download template', // 加载提示信息
requestUrl, // 参数1: 下载地址
path.resolve(process.cwd(), this.targetDir)) // 参数2: 创建位置
}

// 核心创建逻辑
// 1)获取模板名称
// 2)获取 tag 名称
// 3)下载模板到模板目录
async create() {
// 1)获取模板名称
const repo = await this.getRepo()

// 2) 获取 tag 名称
const tag = await this.getTag(repo)
console.log('用户选择了,repo=' + repo + ',tag=' + tag)

// 3)下载模板到模板目录
await this.download(repo, tag)

// 4)模板使用提示
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;

测试一下,终端执行命令 cl create my-project

脚手架 v2 (扩展版本)

参考



支付宝打赏 微信打赏

赞赏一下 坚持原创技术分享,您的支持将鼓励我继续创作!