脚手架-快速启动搭建项目的工具

128 阅读11分钟

这个文章主要是介绍搭建一个非常简陋的脚手架模版,在此之前呢, 我们可以先通过两个面试题来简单了解脚手架设计的一些概念型的东西:

面试题一:脚手架是一个独立实体。设计脚手架的时候,如何做内容和实体的拆分?

以vue脚手架为例: 1.在使用之前需要npm intall vue-cli 2.需要运行vue-cli里面的create去把我们远端的模版下载下来

这其中分为两大块:

a.内容:

内容就是vue-cli,也就是本地的功能的全集:

1.所有功能的函数的集合。比如:init,create,或者预留的help等函数。

2.类库。 除了函数功能集合之外,vue-cli运行时需要的类库也应当集合在内容中,比如:在用户执行完create时,需要根据用户的create方法的参数,从远端拉取模版,那么这个时候就需要网络模块(发起请求,请求的对象是远端)和git模块(通过调用git的接口从远端拉取到对应的模版)。

3.本地内容和远端仓库的连接和认证。 本地内容和远端仓库的连接和认证应当做在内容中(因为远端仓库的地址肯定是在本地写死的,根据这个写死的地址,我们才知道去对应的地方拉)。

b.模版(webpack|mino-webpack|...):

远端模版应当包含所有最终在使用者仓库中呈现和使用的文件。 比如:用户在运行vue-cli中运行create命令时,它在create之后所返回的内容是一个新建的一个vue项目的初始文件夹。这个模版就是远端的模版,这个文件夹或者说文件集合,里面不会包含在vue-cli里面的类库。

面试题二(追问):我们该如何打通脚手架的这两块呢?

这其中涉及的主要有两点:

a.通路是如何搭建的?如何把我们脚手架的模版放在远端,由本地可以拉到呢?

这个很简单,一般通过git就可以解决了。

b.有没有做通知化处理/过程化处理?

类似于git,如果用户对代码仓库有更新操作,一般都会有更新的通知或者记录。 那么对于脚手架来说,我们也需要在特定的阶段触发特定的操作的时候进行一些记录或者通知。

脚手架本质上就是一个快速搭建项目的工具。 那么我们的目标就明确了。 我们需要做的事情就一个:通过命令可以直接创建项目。 比如:我们在执行命令: vue create ${name}之后可以直接得到一个vue初始项目的文件夹。

OK,那么我们接下来就以vue-cli来搭建一个简陋版的脚手架吧。

第一步:处理依赖

由上面的面试题可以知道,我们的脚手架想要搭建起来,是需要再安装一些依赖来帮我们处理一些事情的,那么我们的脚手架需要处理哪些依赖呢?这里先简单列举几个:

1.npm i path路径查找工具,用于处理文件路径,例如路径解析,路径合并,路径快速查找,路径的快速跳转等。

2.npm i chalk@4.1.0用于做文件的高亮,建议可以用4.1.0的版本

3.npm i fs-extra用于操作文件

4.npm i inqu文件的问询交互

5.npm i commander用于做命令的传递,命令的获取、命令的参数传递等。

6.npm i axios用于请求远端。

7.npm i download-git-repo请求到远端之后,需要将远端模版下载到本地。

第二步:处理工程入口

当我们有了这些依赖之后,我们就得去处理我们的工程入口了。还是以vue-cli为例,我们希望我们可以通过在命令行输入vue create ${name}命令之后直接创建一个vue项目。

那么首先我们要做的就是让命令行认识我们的命令了。

1.初始化主命令

1.初始化npmnpm init,因为我们目前的环境是依赖的npm。 npm init可以给我们当前项目生成一个package.json。

2.新建主命令,进行配置项的修改。

怎么新建一个命令行可以识别的命令呢? 我们可以在项目中新建一个bin文件夹,这个bin就代表我们主命令的入口。

image.png

接下来我们可以在bin文件夹下可以先随便新建一个cli.js。

在这个js中写入这么一行代码 #!usr/bin/env node,这句话代表我们当前的文件在node环境下使系统执行,系统文件在node环境下执行。

image.png

当然,想要通过我们的命令去执行bin下的cli.js文件。还需要在package.json里面新增一项bin

image.png

修改为我们对应的cli.js的路径即可。

那么到这里我们就相当于完成了主命令的注册。接下来我们需要将我们的主命令和配置项进行关联。

3.关联主命令和配置项。

在本地时,我们可以通过npm link来关联我们的项目的执行入口和整个路径。

执行完这个命令之后会自动生成一个package-lock.json。

image.png

这个时候就代表我们的主命令关联已经完成了

那么我们回到我们的package.json。

image.png

因为我的package.json里面的name是cli。所以我们可以直接在我们的终端执行cli命令。 这个命令可以通过我们的配置去查找我们的主入口文件bin/cli.js。然后去执行js里面的内容。

image.png

那么到了这一步我们就可以通过我们的主命令cli去执行我们的主文件bin/cli.js里面的内容。我们的执行工程化入口就完成了。

提示:package.json中的main不一定要写成跟bin一样,这里bin代表的是执行cli命令时,需要执行的逻辑,只是针对这个命令而言。而main代表的是执行完这个命令的逻辑之后,第一个执行的文件。因为我们这里的cli的功能只是执行。并没有页面去执行,所以这个截图里都写成了通过你一个。


我们再回顾一下我们的vue-cli的执行过程

1.vue init通过用户输入去获取参数。

2.vue create app完成脚手架的能力提供。

对比我们的cli来说,现在cli命令是已经可以执行了。那么我们要怎么样去实现一个类似于vue init 这种的cli init或者cli create app命令呢?


第三步:加入命令的交互

现在终端已经认识我们的cli命令了。 现在我们要做的就是再让终端去认识我们的cli initcli create app命令。

这个时候就需要借助我们的交互好帮手commander了。 npm i commander.

哈哈,在此之前,我们还是可以先简单认识一下commander的几个api

//创建一个create命令
.command('create <app-name>')
//<app-name>代表给这个命令传的参数。


//第二种给命令传递参数的方式。
.argument('app-name') 
//app-name同样也是要传递的参数

//这两种方式可以串行
.command('create').argument('app-name')

/*
提示:
这里的命令参数有几种表达方式:
<> 表示必选参数
[]表示可选参数
. 代表长参数(类似于扩展运算符,表示传的一个或者多个参数)

*/


//解析用户传入的参数。
.parse(process.argv)


//选项,给用户提供建议
.options('-f,--force','对选项的解释')
/*
接收两个参数:
第一个参数‘-f,--force'表示选项,这里有两个选项,-f和--force
绑定选项之后我们就可以在命令之后加上-f或者--force来达到不同的效果,
可以仿照git push -f这种形式,做一个自己的-f ,--force的命令.

第二个参数表示对第一个参数的简介。
*/


//处理函数--通过命令获取到相应的执行信号之后,所执行的核心逻辑
.command('create <app-name>).action()

//action()代表 create命令执行的核心逻辑。

OK,我们接下来开始正式注册我们自己的命令。

cli.js文件:

#! /usr/bin/env node

//系统在node环境下执行

//主系统执行部分

const program = require('commander')

// 定义命令

program
.command('create <app-name>')
.description('简介')
.option('-f,--force','如果输入-f或者--force就代表覆盖')
.action((name,options)=>{
  console.log('打印',name,options)
})

//.descrption可以对这个create app命令进行一个简单说明


program.parse(process.argv)
//注:解析用户命令参数要放在最后,这样解析用户命令的操作可以进行全局的统一处理。

到这里,我们的cli create app-name命令就注册好了,打开命令行看看效果。

image.png

第四步:建立我们自己的模板

一般来说,我们脚手架都是为了方便去创建一个项目。比如说vue-cli,它更多的像是给一个vue的项目的模版,然后我们再在这个模版里面去进行填充。

OK,那么为了达到类似vue-cli的效果,我们这一步也可以先来做一个简单的模版。

1.准备模板:本地+上传仓库

例如我们可以在本地建一个简单的html:

image.png

假设这个html是我们的模版的话。那我们接下来就需要将这个模版放在远端的git仓库中。这样的话,用户在执行命令的时候就可以直接去我们的远端下载对应的模版(模版所需的依赖是可以不用上传的,如果不上传话就需要让用户自己去下载对应的依赖。)

例如:

image.png

那么这个时候,用户只需要打通远端的仓库和本地的项目。那么就可以完成我们的脚手架的下载和更新啦。

2.获取组织的模板。

根据git上的路径信息,我们可以通过 api.github.com/orgs/mycli/… 来获取组织下所有的模版,

3.创建模版指令

上面我们已经创建了一个主文件cli.js了。 我们的creat指令的入口也是写在了这个主文件里面。 在触发我们的指令之后,会执行我们.action里面的逻辑。

ok,所以我们接下来创建模版的时机和要执行的操作就是在这个action的回调里面。

这里为了方便指令的管理,我们可以单独再创建一个类库lib,然后将create的指令的执行逻辑单独抽离出来放在里面。

image.png

接下来我们梳理一下我们在create中要做的几件事:

1.读取命令行,接收用户要创建的项目名以及配置参数。

2.类似于vue-cli,我们需要执行create app-name命令的时候创建一个名为app-name的目录文件夹。

提示:有时候存在我们新创建的文件夹有重名的情况,那么我们就需要根据用户的选择去判断是否需要覆盖原有的文件夹。

3.生成模版,这里我们可以将生成模版的逻辑抽象成一个generator类的形式来做,根据用户传递的不同参数来new出不同的生成器实例。

const path = require('path')
const fs = require('fx-extra')
const inquirer = require('inquirer')//提供类似于弹窗的效果供用户做选择
const Generator = require('./generator')//生成模版的逻辑,这里也进行了一个抽离。

//抛出一个方法用于创建模块。
//接收用户要创建的项目名以及配置参数。
module.export  = async function(name,options){
    const cws = process.cwd()//命令行所在目录
    const targetAir = path.join(cwd,name)//创建目录地址

    //判断是否存在相同的文件夹
    if(fs.existSync(targetAir)){

        //如果执行的是create app-name -force ,就强制覆盖。
        if(options.force){
            await fs.remove(targetAir)
        }else{
            //增加反馈,提供用户选择---是否要强制覆盖?
            let {
                action
            } = await inquirer.prompt([{
                name:'action',
                type:'list',
                message:'目标路径文件名以及存在',
                choices:[{
                    name:'覆盖',
                    value:'overwrite'
                },{
                    name:'取消',
                    value:false
                }]
            }])

            if(!action){
                return
            }else{
                //移除原有文件
                await fs.remove(targetAir)
                console.log('removing....')
            }
        }

        const generator = new Generator(name,targetAir)
        generator.create()
    }
}

ok,那么我们接下来进入比较繁琐的生成器部分,同样的我们可以多分成几步来做: 1.generate类的构造函数

const downloadGitRepo = require('download-git-repo') //方便我们从代码仓库中下载代码


class Generator{
    constructor(name,targetDir){
        
        this.name = name//将用户传入的项目名挂在实例上
        
        this.targetDir = targetDir //创建位置

        this.downloadGitRepo =  util.promisify(downloadGitRepo)
        //下载代码应该是一个异步操作,所以这里对downloadGitRepo做一个promise化。
    }
    }

2.定义核心创建逻辑,create函数。

async create (){
    const repo = await this.getRepo()//获取模版
    const tag = await this.getTag(repo)//获取版本
    
    await this.download(repo,tag)
    
    console.log(`cd ${chalk.cyan(this.name)}`)
}

由这里我们可以看到,完成一个合理的生成器去生成模版大概的流程就是:

获取模版->获取版本->从远端下载对应模版

Ok,我们一步步来,

3.封装loading

因为获取模版,获取版本等信息都是从远端进行获取的,这些显然是一些异步的操作,有可能有些操作会耗费一定时间,那么我们可以在做一些异步的时候提供一个loading的提示状态。 这里需要借助一个依赖 ora来达到loading的效果。

const ora = require('ora')//在用户拉取的时候展示loading状态


async function wrapLoading(fn,message,...args){//展示loading效果
    const spinner = ora(message)

    spinner.start() //开始loading

    try{
        const result = await fn(...args)

        spinner.succeed()
        return result
    }catch(error){
        spinner.fail('拉取失败')
    }
}

4.获取模版:

//获取模版
    async getRepo(){
        //1.远程拉取模版数据
        const repoList = await wrapLoading(getRepoList,'等待模版下载....')//

        if(!repoList) return 

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

        //2.提供给用户选择,由用户选择一个模版来下载
        const {repo} = await inquirer.prompt({ //等待用户的操作也可以算是一个异步,只有用户做出操作了,我们才能拿到结果。
            name:'repo',
            type:'list',
            choices:repos,
            message:"请选择一个模版进行创建"
        })

        return repo;
    }

获取模版的操作比较简单,从远程拉取模版数据,也就是getRepoList,这个方法后续会说到,作用是通过网络请求获取远程模版数据。当然模版可以有很多种,那么这里可以将名称提炼出来由用户进行选择,我们只需要拉取用户指定的模版即可。 这一步拿到的结果其实是用户最终选择的模版名称。

5.获取版本

一般比较正式的脚手架可能会有多个版本,那么我们在生成的时候也需要提供给用户进行选择或者指定。这一步的操作其实是为了拿到用户指定的版本号。当然用户有可能不会指定版本号,这个时候版本号就是空的。

   //获取版本   
 getTag(repo){
     //1.拉取对应的tag列表
     const tags = await wrapLoading(getTagList,'等待版本信息拉取...')


     if(!tags) return 

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

     return tagsList[0]
     
 }

其实实现上跟获取模版信息差不多,这里就简单偷个懒嘿嘿。

6.下载模版.

下载模版的操作其实更简单,就是调用三方的download-git-repo来实现远端下载代码, 这里我们可以简单认识一下downloadGitRepo。

downloadGitRepo(repository, destination): 表示从远端的git repository下载代码到本地的destination目录中。

所以我们这一步的操作就是拼接模版的远程git地址,和本地目录的地址,然后再执行download方法传入这两个地址就可以了。

download(repo,tag){

const requestUrl = `EcourseZone/${repo}${!tag?'':'#'+tag}`

await wrapLoading(
        this.downloadGitRepo,
        'waiting. template donwloading',
        requestUrl,
        path.resolve(process.cwd(),this.targetDir)
         )
}

基本到这里我们的生成模版的流程就差不多完成了,当然细心的朋友可以发现我们这里似乎还有两个方法并没有得到解释。

getRepoList:远程获取模版信息

getTagList:远程获取版本信息

实际上,在一些比较正式的脚手架中,会单独将涉及网络请求的方法单独的封装到一个新的http.js文件里面。

getRepoList:

function getRepoList(){
      return axios.get('https://api.github.com/orgs/mycli/repos')
}

getTagList

function getTagList(repo){
        return axios.get(`https://api.github.com/orgs/mycli/${repo}/tags`)
}

其实这两个方法本质上就是通过git的api来拿到对应的模版信息和版本信息。

完整代码:

generator.js

const { getRepoList,getTagList }  = require('./http')//将与网络相关的操作进行抽离

const downloadGitRepo = require('download-git-repo') //方便我们从代码仓库中下载代码
const util = require('util')
const ora = require('ora')//在用户拉取的时候展示loading状态
const { default: inquirer } = require('inquirer')



class Generator{
    constructor(name,targetDir){
        
        this.name = name//将用户传入的项目名挂在实例上
        
        this.targetDir = targetDir //创建位置

        this.downloadGitRepo =  util.promisify(downloadGitRepo)
        //下载代码应该是一个异步操作,所以这里对downloadGitRepo做一个promise化。
    }

    //获取模版
    async getRepo(){
        //1.远程拉取模版数据
        const repoList = await wrapLoading(getRepoList,'等待模版下载....')//

        if(!repoList) return 

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

        //2.提供给用户选择,由用户选择一个模版来下载
        const {repo} = await inquirer.prompt({ //等待用户的操作也可以算是一个异步,只有用户做出操作了,我们才能拿到结果。
            name:'repo',
            type:'list',
            choices:repos,
            message:"请选择一个模版进行创建"
        })

        return repo;
    }

    //获取版本   
    getTag(repo){
        //1.拉取对应的tag列表
        const tags = await wrapLoading(getTagList,'等待版本信息拉取...')


        if(!tags) return 

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

        return tagsList[0]
        
    }

    //下载模版

    download(){
        const requestUrl = `FEcourseZone/${repo}${!tag?'':'#'+tag}`

        await wrapLoading(
            this.downloadGitRepo,
            'waiting template downloading',
            requestUrl,
            path.resolve(process.cwd(),this.targetDir)
        )
    }

    //核心创建逻辑
    async create(){
        const repo =await this.getRepo()
        const tag = await this.getTag(repo)
        

    }
}

async function wrapLoading(fn,message,...args){//展示loading效果
    const spinner = ora(message)

    spinner.start() //开始loading

    try{
        const result = await fn(...args)

        spinner.succeed()
        return result
    }catch(error){
        spinner.fail('拉取失败')
    }
}


module.export = Generator

http.js

const axios = require(axios)

function getRepoList(){
    return axios.get('https://api.github.com/orgs/mycli/repos')
}

function getTagList(repo){
    return axios.get(`https://api.github.com/orgs/mycli/${repo}/tags`)
}

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

module.exports = {
    getRepoList,
    getTagList
}

总结:

1.脚手架本质上就是一个快速搭建项目的工具。由用户在命令行输入命令,传递参数,来生成对应的初始化项目。

2.脚手架是一个独立实体,设计脚手架的时候,需要进行内容和实体的拆分:

 内容:本地功能的全集:包括类库,所有功能的函数集合,本地内容和远端仓库的认证等。
 
 模版:包含所有最终在使用者仓库中呈现和使用的文件。
 
 搭建通路:通过git来完成模版的拉取和更新

 设计脚手架的时候最好再加上更新通知/过程化处理。

3.设计简陋的脚手架的步骤:

(1)处理依赖,处理脚手架所需要的依赖。

(2)处理工程入口,初始化npm -> 新建主命令、修改配置项 -> 关联主命令和配置项。

(3)加入命令交互,通过commander依赖创建create命令 -> 定义命令参数 ->通过命令获取到执行信号后,解析参数并执行对应逻辑。

(4)准备自己的模版。

  • a.创建本地模版+上传git仓库

  • b.通过api.github.com/orgs/${项目名称…可以获取到对应地址的项目模版。

  • c.填充create命令的逻辑: 接收用户要创建的项目名及对应参数,判断是否存在同名文件夹,根据用户选择来做覆盖或者取消操作。

  • d.生成本地模版(generater.js):通过网络请求从git远程拉取模版信息+版本信息,然后根据模版信息和版本信息进行远程git地址拼接,再利用download-git-repo讲远程模版下载到本地,(这里的download-git-repo需要封装成promise)。