从零实现一个自己的CLI脚手架工具

114 阅读6分钟

前言

关注微信公众号:乘风破浪的前端 回复:cli获取代码

只要提到脚手架你就会想到vue-cli、create-react-app、dva-cli……他们的特点就是专一!但是在公司中你会发现有以下问题!

  • 业务类型多

  • 多次造轮子,项目升级等问题

  • 公司代码规范无法统一

很多时候我们开发时需要新建项目,把已有的项目代码复制一遍,保留基础能力。(但是这个过程非常的琐碎而耗时)。那我们可以自己定制化模版,自己实现一个属于自己的脚手架,来解决这些问题。 在自己开发cli前,那肯定要先看一下那些优秀的cli是如何实现的!虽然不是第一个吃螃蟹的,那也要想想怎么吃更好^_^#

# 必备模块

commander: 可执行命令行工具
chalk: 一个第三方模块,可以画出更好看的ui
inquirer: 用户与命令行交互的工具
fs-extra: 文件管理的三方库,一般不直接使用原生的fs
path: 路径管理模块
ora: 实现命令行的loading效果
download-git-repo: 通过模版名称和版本号可以把项目下载下来,不支持promise

# 从零实现一个cli脚手架

配置初始环境

新建一个空文件夹conglin, npm init -y初始化package.josn文件\

既然是脚手架,假设脚手架的名称叫conglin,我希望别人在用的时候可以在命令行直接输入,conglin create xxxx 来安装脚手架。

创建可执行脚本文件

一般node会把可执行的文件放在bin文件夹里,在bin目录下新建文件conglin,输入#! /usr/bin/env node,表示我要用node来执行 

我先写一个console,待会看看能不能在终端打印出来

图片

配置package.json中的bin属性

图片

npm link 链接到本地环境

但是这还不够,比如create-react-app为什么能执行,是因为它在全局的npm下载中,我们也需要这样。本地开发临时放到全局下,以后发布到线上就可以直接用了

图片

测试一下,在终端直接输入conglin

图片

歪瑞顾的,我们写在bin文件夹里的console被执行了

commander

基本上所有的命令行工具都是用commander来写的,安装commander

npm install commander

看看commander怎么去用

路径:bin/conglin

#! /usr/bin/env node
// 引入可执行命令 commander
const program = require('commander')
// 解析用户执行命令传入的参数
program.parse(process.argv// 举例:vue create xxx 中的create xxx就是要解析的参数

写下了这两行命令之后,我们就能执行conglin --help

图片

因为没有任何配置,所以usage里面不太对,我们继续完善

#! /usr/bin/env node
// 配置可执行命令 commander
const program = require('commander')
program    
.version(`conglin-cli@${require('../package.json').version}`)    
.usage(`<command> [option]`)

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

现在version和help就有内容了

图片

接下来我们要通过commander完成三个核心内容\

创建项目

#! /usr/bin/env node

// 配置可执行命令 commander
const program = require('commander')
program    
.command(`create <app-name>`)    
.description(`create a new project`)    
.option('-f --force', 'overwrite target directory if it exists') // 万一重名了,强制创建  
.action((name, cmd) => {        
     console.log(name, cmd);        //name 项目名称 cmd 输入的命令
     })
     
program    
.version(`conglin-cli@${require('../package.json').version}`)    
.usage(`<command> [option]`)// 解析用户执行命令传入的参数program.parse(process.argv)

执行conglin create app --force\

图片

配置命令

现在创建命令就写好了,但是我还希望再有一个配置命令,有添加、修改、删除

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, cmd) => {        console.log(value, cmd);    })

image.png

ui界面

program    
.command('ui')    
.description('start and open conglin-cli ui')    
.option('-p, --port <port>', 'port used for the UI Server')    
.action((cmd) => {        console.log(cmd);    })

但是还缺了一个信息提示,比如我们在执行vue  --help的时候,会有以下提示信息,告诉我们可以针对某一个命令去查询,比如vue create --help\

图片

我希望我的命令也有,那么可以在用户打印--help的时候提示一下

program.on('--help', function () {    
console.log('Run conglin-cli <command> -help show details');})

图片

但是我们的提示信息还比较难看,可以美化一下

  1. 前后加上空的console.log
  2. 使用第三方工具chalk(粉笔)美化字体颜色
program.on('--help', function () {    
console.log();    
console.log(`Run ${chalk.blueBright('conglin-cli <command> -help')} show details`);  
console.log();
})

看下效果

图片

这样是不是顺眼多了

注意:chalk 插件版本 5以上不支持 es Module,可以安装版本4,

npm install chalk@4.1.0

小结:commander就是配置命令行工具,配置参数,然后解析用户的,参数没有其他的功能,都是相同的流程。

# 拉取模版

但是,所有的功能不可能都放在一个文件里去写,每调一个功能就调用一个模块去实现,比如调用create功能,就调用create模块\

新建一个文件夹lib-->新建文件create

image.png

路径:bin/conglin

program    
.command(`create <app-name>`)    
.description(`create a new project`)    
.option('-f --force', 'overwrite target directory if it exists') // 万一重名了,强制创建模式    
.action((name, cmd) => {        
   require('../lib/create.js')(name, cmd)    
})

路径:lib/create.js

// 里面可能有很多异步的操作,所以包装成async函数
module.exports = async function (projectName, options) {    
console.log(projectName);}

测试一下

图片

相当于从我们的入口里面,分发到不同的源码目录里去

创建项目:create模块开发

1、如果创建项目的时候,发生了重名的情况

路径:lib/create.js

const path = require('path')
const fs = require('fs-extra')
const Inquirer = require('inquirer');
const Creator = require('./Creator.JS');
// 里面可能有很多异步的操作,所以包装成async函数
module.exports = async function (projectName, options) {    
const cwd = process.cwd(); // 获取当前命令执行时的工作目录
    const targetDir = path.join(cwd, projectName) // 目标目录
// 创建项目的时候,判断是否发生了重名的情况
    if (fs.existsSync(targetDir)) {        
    if (options.force) { // 如果强制创建,删除已有的            
    await fs.remove(targetDir) } 
    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 }                
    ] }])            
    console.log(action);           
    if (!action) { return   }
    else if (action === 'overwrite') {                
    await fs.remove(targetDir)            }        
    }  
    }    
    // 创建项目,单独封装了一个类  
    const creator = new Creator(projectName, targetDir)    
    creator.create() // 开始创建项目
    }

2、创建项目

路径:lib/request.js

// 通过axios来获取结果
const axios = require('axios')
axios.interceptors.response.use(res => {    return res.data})

async function fetchRepoList() {    // 可以通过配置问价,拉取不同的仓库对应的用户下的文件    
return axios.get('https://api.github.com/orgs/conglin-cli/repos')
}
module.exports = {    
fetchRepoList 
}

路径:lib/creator.js

主要关注create方法

const {fetchRepoList} = require('./request.js')
const Inquirer = require('inquirer')
const ora = require('ora')
async function sleep(n) {    
     return new Promise((resolve, reject) => {        
     setTimeout(resolve, n)    
     })}
     // 只做了一个等待的loadingasync 
     function wrapLoading(fn, message) {    
     const spinner = ora(message)    
     spinner.start() // 开启加载    
     try {        
     let repos = await fn()        
     spinner.succeed() // 成功        
     return repos    } 
     catch (error) {        
     // 失败了重新抓取        
     spinner.fail('request failed, refetch ...')        
     await sleep(1000)        
     return wrapLoading(fn, message)    
     }}
     
     class Creator {    
     constructor(projectName, targetDir) { 
     // new 的时候回调用构造函数        
         this.name = projectName        
         this.target = targetDir    
     }
    async fetchRepo() {        
    let repos = await wrapLoading(fetchRepoList, 'waiting fetch template') 
    if (!repos) return        
    repos = repos.map((item) => item.name)        
    const {repo} = await Inquirer.prompt({            
    name: 'repo',            
    type: 'list',            
    choices: repos,            
    message: 'please choose a template to create project' })        
    console.log(repo);
    }
    // 真实开始创建了    
    // 采用远程拉取的方式 github    
    async create() {        
       // 1) 先去啦去当前组织下的模版        
       let repo = await this.fetchRepo()        
    }}
module.exports = Creator

终端输入conglin create xxx

图片

可以看到,已经成功把github上的模版拉取了下来\

图片

图片

# 下载资源

1、获取版本号

路径:lib/request.js

使用fetchTagList方法获取版本号

// 通过axios来获取结果
const axios = require('axios')
axios.interceptors.response.use(res => {    return res.data})

async function fetchRepoList() {    
// 可以通过配置问价,拉取不同的仓库对应的用户下的文件    
return axios.get('https://api.github.com/orgs/conglin-cli/repos')}

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

module.exports = {    
fetchRepoList,    
fetchTagList
}

路径:lib/creator.js

在create方法里添加fetchTag方法,用来获取项目的所有版本

const { fetchRepoList, fetchTagList} = require('./request.js')
const Inquirer = require('inquirer')
const ora = require('ora')
async function sleep(n) {    
return new Promise((resolve, reject) => {    setTimeout(resolve, n)   
})}
// 只做了一个等待的loadingasync 
function wrapLoading(fn, message, ...args) {   
const spinner = ora(message)    
spinner.start() // 开启加载   
try {        
let repos = await fn(...args)        
spinner.succeed() // 成功        
return repos    } 
catch (error) {        
// 失败了重新抓取        
spinner.fail('request failed, refetch ...')        
await sleep(1000)        
return wrapLoading(fn, message, ...args)    }}

class Creator {    
constructor(projectName, targetDir) { 
// new 的时候回调用构造函数        
this.name = projectName        
this.target = targetDir    }
    async fetchRepo() {        
    let repos = await wrapLoading(fetchRepoList, 'waiting fetch template')        
    if (!repos) return        
    repos = repos.map((item) => item.name)        
    const {repo } = await Inquirer.prompt({            
    name: 'repo',            
    type: 'list',            
    choices: repos,            
    message: 'please choose a template to create project'  })        
    console.log(repo);        
    return repo    
    }    
    async fetchTag(repo) {  
    let tags = await wrapLoading(fetchTagList, 'waitting fetch tag', repo)                    console.log('tags', tags);        
    if (!tags) return        
    tags = tags.map(item => item.name)       
    const {tag} = await Inquirer.prompt({            
    name: 'tag',            
    type: 'list',            
    choices: tags,            
    message: 'please choose a tag to create project'        })        
    console.log(tag);
    }    
    async download() {
    }
    // 真实开始创建了    
    // 采用远程拉取的方式 github    
    async create() {        
    // 1) 先去啦去当前组织下的模版        
    let repo = await this.fetchRepo()        
    // 2)再通过模版找到版本号        
    let tag = await this.fetchTag(repo)
        //3) 下载        
        // let downloadUrl = await this.download(repo, tag)        
        // 4)编译模版   
        }}
module.exports = Creator

执行 conglin create xxx

图片

选择vue-template

图片

可以看到,vue-template下的版本号已经被我们获取了。

2、下载模版

npm install download-git-repo
// 通过模版名称和版本号可以把项目下载下来

现在有了版本号和模版名称,我们就可以去下载项目啦

路径:lib/creator.js

虽然代码很长,但是只需要关注download方法

const {    fetchRepoList,    fetchTagList} = require('./request.js')
const Inquirer = require('inquirer')
const ora = require('ora')
const downloadGitRepo = require('download-git-repo') // 不支持promise
const util = require('util') // node自带的方法
const path = require('path')

async function sleep(n) {    
return new Promise((resolve, reject) => {        
setTimeout(resolve, n)    
})}
// 只做了一个等待的loading
async function wrapLoading(fn, message, ...args) {    
const spinner = ora(message)    
spinner.start() // 开启加载    
try {        let repos = await fn(...args)        
spinner.succeed() // 成功        
return repos    } 
catch (error) {        
// 失败了重新抓取        
spinner.fail('request failed, refetch ...')        
await sleep(1000)        
return wrapLoading(fn, message, ...args)    
}}

class Creator {    
constructor(projectName, targetDir) { 
// new 的时候回调用构造函数        
this.name = projectName        
this.target = targetDir        
this.downloadGitRepo = util.promisify(downloadGitRepo) // 把downloadGitRepo转成promise方法    }

    async fetchRepo() {        
    let repos = await wrapLoading(fetchRepoList, 'waiting fetch template')        
    if (!repos) return        
    repos = repos.map((item) => item.name)        
    const { repo} = await Inquirer.prompt({            
    name: 'repo',            
    type: 'list',            
    choices: repos,            
    message: 'please choose a template to create project' })        
    console.log(repo);   
    return repo    }    
    async fetchTag(repo) {       
    let tags = await wrapLoading(fetchTagList, 'waitting fetch tag', repo)
    if (!tags) return        
    tags = tags.map(item => item.name)        
    const {            tag        } = await Inquirer.prompt({           
    name: 'tag',            
    type: 'list',            
    choices: tags,            
    message: 'please choose a tag to create project'        })        
    return tag    
    }    
    async download(repo, tag) {        
    // 1、需要拼接出下载路径        
    let requestUrl = `conglin-cli/${repo}${tag?'#'+tag:''}`        
    // 2、把资源下载到某个路径上(后续可以增加缓存功能, 应该下载到系统目录中,稍后可以再使用ejs handlebar 去渲染模版,最后生成结果再写入)        
    await this.downloadGitRepo(requestUrl, path.resolve(process.cwd(), `${repo}@${tag}`))        
    return this.target    }
    // 真实开始创建了   
    // 采用远程拉取的方式 github    
    async create() {        
    // 1) 先去啦去当前组织下的模版        
    let repo = await this.fetchRepo()        
    // 2)再通过模版找到版本号        
    let tag = await this.fetchTag(repo)
        //3) 下载        
        await this.download(repo, tag)        
        // 4)编译模版    }}
module.exports = Creator

这样项目就被我们下载下来了,当然,这只是比较基础的功能实现,后期可以根据需要做很多的优化

总结

这是一个从空文件夹开始的cli练习项目,感兴趣的小伙伴可以从头开始跟着写,其实cli工具并不难,就是通过各种插件搭积木就可以了,所以也不用感觉到造轮子的有多高大上。

我们这个cli项目的流程为:

  1. 使用commander开发可执行命令行
  2. 下载当前组织下的模版
  3. 获取当前模版下的版本号
  4. 下载资源

图片

\

图片

完整目录\

图片

关注微信公众号:乘风破浪的前端 回复:cli就可以获取代码