Skip to main content

搭建自己的脚手架

搭建自己的脚手架

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

.-') _  ('-. .-.             _  .-')                    .-') _             
( OO) )( OO ) / ( ( -O ) ( OO ) )
,(_)----. ,--. ,--. ,--. ,--. ,------. .-'),-----. ,--./ ,--,' ,----.
| | | | | | | | | | | /`. '( OO' .-. '| \ | |\ ' .-./-')
'--. / | .| | | | | .-') | / | |/ | | | || | | )| |_( O- )
(_/ / | | | |_|( OO )| |_.' |_) | || || . |/ | | .--, \
/ /___ | .-. | | | | `-' /| . '.' \ | | | || |\ | (| | '. (_/
| || | | |(' '-'(_.-' | |\ \ `' '-' '| | \ | | '--' |
`--------'`--' `--' `-----' `--' '--' `-----' `--' `--' `------'

需要实现哪些基本功能:

通过 zr create <name> 命令启动项目

询问用户需要选择需要下载的模板

远程拉取模板文件

搭建步骤拆解:

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

创建项目

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

zhurong-cli           
├─ bin
│ └─ cli.js # 启动文件
├─ README.md
└─ package.json

配置脚手架启动文件

{
"name": "zhurong-cli",
"version": "1.0.0",
"description": "simple vue cli",
"main": "index.js",
"bin": {
"zr": "./bin/cli.js" // 配置启动文件路径,zr 为别名
},
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"author": {
"name": "T-Roc",
"email": "lxp_work@163.com"
},
"license": "MIT"
}

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

#! /usr/bin/env node

console.log('zhurong-cli working ~')

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

~/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

完成之后,接着测试一下

~/Desktop/cli/zhurong-cli ->zr
zhurong-cli working ~ # 打印内容

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

创建脚手架启动命令

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

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

现在开始吧

安装依赖

npm install commander --save

安装完成之后

创建命令

打开cli.js进行编辑

#! /usr/bin/env node

const program = require('commander')

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)
})

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

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

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

~/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>,接着执行一下这个命令

~/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 }

成功拿到命令行输入信息

执行命令

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

// lib/create.js

module.exports = async function (name, options) {
// 验证是否正常取到值
console.log('>>> create.js', name, options)
}

在 cli.js 中使用 create.js

// bin/cli.js

......
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)
})
......

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

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

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

如果存在

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

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

# fs-extra 是对 fs 模块的扩展,支持 promise 
$ npm install fs-extra --save

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

// lib/create.js

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 {
// TODO:询问用户是否确定要覆盖
}
}
}

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

创建更多命令

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

// bin/cli.js

// 配置 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 roc-cli ui')
.option('-p, --port <port>', 'Port used for the UI Server')
.action((option) => {
console.log(option)
})

完善帮助信息

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

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

// bin/cli.js

program
// 监听 --help 执行
.on('--help', () => {
// 新增说明信息
console.log(`\r\nRun ${chalk.cyan(`zr <command> --help`)} for detailed usage of given command\r\n`)
})

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

// bin/cli.js

program
.on('--help', () => {
// 使用 figlet 绘制 Logo
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打印出来的是个什么样子

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

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

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

接下来我们要做的:

上一步遗留:

  • 询问用户是否覆盖已存在的目录
  • 用户选择模板
  • 用户选择版本
  • 获取下载模板的链接
  • 询问是否覆盖已存在的目录

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

如果目录已存在

{ force: false }时, 询问用户是否需要覆盖

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

首选来安装一下 inquirer

npm install inquirer --save

然后询问用户是否进行 Overwrite

// lib/create.js

const path = require('path')

// fs-extra 是对 fs 模块的扩展,支持 promise 语法
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)
}
}
}
}

我们来测试一下:

在当前目录,即命令行中显示的目录下手动创建2个目录,这里随便取名为my-projectmy-project2

执行zr create my-project,效果如下

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

caution

为什么这里只做移除?

因为后面获取到模板地址后,下载的时候会直接创建项目目录

如何获取模版信息

模板我已经上传到远程仓库

vue3.0-template版本信息

vue-template版本信息

github提供了

api.github.com/orgs/zhuron…接口获取模板信息

api.github.com/repos/zhuro…接口获取版本信息

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

// lib/http.js

// 通过 axios 处理请求
const axios = require('axios')

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


/**
* 获取模板列表
* @returns Promise
*/
async function getRepoList() {
return axios.get('https://api.github.com/orgs/zhurong-cli/repos')
}

/**
* 获取版本信息
* @param {string} repo 模板名称
* @returns Promise
*/
async function getTagList(repo) {
return axios.get(`https://api.github.com/repos/zhurong-cli/${repo}/tags`)
}

module.exports = {
getRepoList,
getTagList
}

用户选择模板

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

// lib/Generator.js

class Generator {
constructor (name, targetDir){
// 目录名称
this.name = name;
// 创建位置
this.targetDir = targetDir;
}

// 核心创建逻辑
create(){

}
}

module.exports = Generator;

create.js中引入Generator

// lib/create.js

...
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()
}

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

// lib/Generator.js

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

// 添加加载动画
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;
}

// 获取用户选择的模板
// 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)获取模板名称
// 2)获取 tag 名称
// 3)下载模板到模板目录
async create(){

// 1)获取模板名称
const repo = await this.getRepo()

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

module.exports = Generator;

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

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

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

用户选择版本

过程和选择模版仓库类似

// lib/generator.js

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

// 添加加载动画
async function wrapLoading(fn, message, ...args) {
...
}

class Generator {
constructor (name, targetDir){
// 目录名称
this.name = name;
// 创建位置
this.targetDir = targetDir;
}

// 获取用户选择的模板
// 1)从远程拉取模板数据
// 2)用户选择自己新下载的模板名称
// 3)return 用户选择的名称

async getRepo() {
...
}

// 获取用户选择的版本
// 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)获取 tag 名称
// 3)下载模板到模板目录
async create(){

// 1)获取模板名称
const repo = await this.getRepo()

// 2) 获取 tag 名称
const tag = await this.getTag(repo)

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

module.exports = Generator;

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

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

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

下载远程模板

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

安装依赖与promise

npm install download-git-repo --save

进行promise化处理

// lib/Generator.js

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

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

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

...
}

核心下载功能

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

// lib/Generator.js

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

// 添加加载动画
async function wrapLoading(fn, message, ...args) {
...
}

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

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

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

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

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

// 核心创建逻辑
// 1)获取模板名称
// 2)获取 tag 名称
// 3)下载模板到模板目录
// 4)模板使用提示
async create(){

// 1)获取模板名称
const repo = await this.getRepo()

// 2) 获取 tag 名称
const tag = await this.getTag(repo)

// 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;

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

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

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

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

发布项目

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

第一步,在git上建好仓库

第二步,完善package.json中的配置

{
"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": "kinda",
"email": "1142704468@qq.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"
}
}

第三步,使用npm publish进行发布,更新到时候,注意修改版本号

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

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