前端工程化之实现一个简单的脚手架工具

3,210 阅读10分钟

我记得一开始学vue的时候,直接导入vue.js文件来学,后来用了vue-cli,就一直用着了,最近突然想起vue-cli是怎么实现的,所以就去看了一下脚手架的搭建

就我现在所会的搭建方法,

  1. 将模板放到github仓库中,通过执行命令来将github上的代码拉取到本地
  2. 使用yeoman-generator,将模板放在generator中,通过yo来将generator中的模板拉取到本地

所以简单来说,就是将代码放到某个地方,在需要的时候将代码拉取到本地,但除了将相关文件拉取到本地外,用过脚手架的同学肯定知道,在初始化的时候,脚手架会提出一些问题,根据我们输入选择的不同,最终的配置也有所不同,所以脚手架并非简单的git clone和yo <name>就可以解决的

我自己分别写了两个简单的脚手架,scav-cli和generator-wxfile,对应上面的两种方法,因为一开始写generator时只是为了写一个generator,所以名字直接那样写,暂时也没去改
下面写的内容是一开始的版本,后面我都对两个进行了更新,所以下载使用的时候还是直接看我写的相应的文档
scav-cli是将vue相关的模板使用git clone拉取到本地的

generator-wxfile是通过yo命令,将模板中的文件拿到当前创建的项目目录的

scav-cli


这里我要用到几个npm包

inquirer:用于向用户提出问题和获取回答 chalk:改变命令行打印内容的样式 child_process:用于执行命令行的指令 commander:用于定义自己的命令

初始化脚手架

这里首先初始化自己的脚手架

mkdir scav-cli
cd scav-cli
npm init

初始化之后,在package.json中把相应的开发依赖写入

"dependencies": {
    "chalk": "^3.0.0",
    "child_process": "^1.0.2",
    "commander": "^4.1.1",
    "inquirer": "^7.0.4"
},
npm i

将相关的npm包下载到node_modules中,接下来就开始脚手架的相关搭建,在bin文件夹中创建scav.js文件用于定义相关命令

定义相关命令

首先将定义脚手架的文件路径,将相关的依赖包引入,定义脚手架的版本

#!/usr/bin/env node --harmony
'use strict'
// 定义脚手架的文件路径,__dirname是当前文件所在的路径
process.env.NODE_PATH = __dirname + '/../node_modules/'

const program = require('commander')

// 获取package.json中的version来做为项目的版本号 
program.version(require('../package').version)
// 定义脚手架的用法,在program.help方法中会显示
program.usage('<command>')

给脚手架定义初始化的命令

/*
command为执行的命令
description为命令的描述
alias为简写
action为命令相应的操作
*/
program
    .command('init')
    .description('init a vue-based project')
    .alias('i')
    .action(()=>{
        console.log('我是初始化的方法')
    })
   
// program.parse(arguments)会处理参数,没有被使用的选项会被存放在program.args数组中
program.parse(process.argv)

在根目录下执行下面的命令

node bin/scav init

出现如图情况就说明配置成功

在这里插入图片描述
为了在全局使用,在package.json中添加bin相关配置

"bin": {
    "scav": "bin/scav.js"
},

然后使用npm link链接到全局,就可以直接在命令行使用scav init了,如图

在这里插入图片描述
在最后添加命令没有被定义的操作

// 如果有选项被放在program.args,即没有被program.parse处理,则默认使用program.help()将npm包可以执行的命令打印出来
// 可以通过program.on('--help', function(){})来自定义help
if (program.args.length) {
    program.help()
}

我们可以在判断之前将program.args打印出来

console.log('program.args: ', program.args);

init执行情况
git帮助提示
可以看到,init被处理了,所以program.args里没有init,而ttt没被定义,没有被处理,所以被写入program.args中

编写具体操作

接下来就详细定义相关的初始化方法了,我们在根目录下创建一个command文件夹用来存放命令相关操作的文件,创建一个init.js来写初始化相关操作

接下来编写init.js,引入相关的依赖包,导出一个方法供scav.js调用

// init.js
const inquirer = require('inquirer')
const chalk =require('chalk')
const {exec} = require('child_process')
chalk.level = 3 // 设置chalk等级为3

module.exports = ()=>{
    console.log(chalk.green('开始初始化文件'))
    console.log(chalk.gray('初始化中...'))
    console.log(chalk.green('初始化完成'))
}

在init.js文件中调用init.js文件

program
    .command('init')
    .description('init a vue-based project')
    .alias('i')
    .action(()=>{
        require('../command/init.js')()
    })

再执行初始化命令试试,如图就成功了

初始化文件命令行图片
接下来使用inquirer来提问用户项目名,如果项目名为空,则给出提示并让用户重新输入

module.exports = ()=>{
    console.log(chalk.green('开始初始化文件'))
    inquirer.prompt([{
        type:'input', // 问题类型为填空题
        message:'your  projectName:', // 问题描述
        name:'projectName', // 问题对应的属性
        validate:(val)=>{ // 对输入的值做判断
            if(val===""){
                return chalk.red('项目名不能为空,请重新输入')
            }
            return true
        }
    }]).then(answer=>{
        console.log(chalk.gray('初始化中...'))
        console.log(chalk.green('初始化完成'))
    })
}

效果如图

在这里插入图片描述
接下来通过child_process中的exec来执行git clone命令

module.exports = ()=>{
    inquirer.prompt([{
        type:'input', // 问题类型为填空题
        message:'your  projectName:', // 问题描述
        name:'projectName', // 问题答案对应的属性,用户输入的内容被存储在then方法中第一个参数对应的该属性中
        validate:(val)=>{ // 对输入的值做判断
            if(val===""){
                return chalk.red('项目名不能为空,请重新输入')
            }
            return true
        }
    }]).then(answer=>{ // 通过用户的输入进行各种操作
        console.log(chalk.green('开始初始化文件\n'))
        console.log(chalk.gray('初始化中...'))
        const gitUrl = 'https://github.com/QZEming/vue-temp.git'
        exec(`git clone ${gitUrl} ${answer.projectName}`,(error,stdout,stderr)=>{
            if (error) { // 当有错误时打印出错误并退出操作
                console.log(chalk.red(error))
                process.exit()
            }
            console.log(chalk.green('初始化完成'))
            process.exit() // 退出这次命令行操作
        })
    })
}

出现如图情况就配置成功了

初始化过程
发现本地多了一个文件夹
新建文件

修改生成的文件

但是打开package.json,发现name是vue-temp,这可不符合我们的预期,所以我们要引入一个fs模块,通过fs模块来读写这个package.json,将之前输入的项目名写入,通过process.cwd()来获取当前命令行执行的路径,代码如下

const fs = require('fs')
module.exports = ()=>{
    inquirer.prompt([{
        type:'input', // 问题类型为填空题
        message:'your  projectName:', // 问题描述
        name:'projectName', // 问题答案对应的属性,用户输入的内容被存储在then方法中第一个参数对应的该属性中
        validate:(val)=>{ // 对输入的值做判断
            if(val===""){
                return chalk.red('项目名不能为空,请重新输入')
            }
            return true
        }
    }]).then(answer=>{ // 通过用户的输入进行各种操作
        console.log(chalk.green('开始初始化文件\n'))
        console.log(chalk.gray('初始化中...'))
        const gitUrl = 'https://github.com/QZEming/vue-temp.git'
        exec(`git clone ${gitUrl} ${answer.projectName}`,(error,stdout,stderr)=>{ // 克隆模板并进入项目根目录
            if (error) { // 当有错误时打印出错误并退出操作
                console.log(chalk.red('拷贝文件失败'))
                process.exit()
            }
            fs.readFile(`${process.cwd()}/${answer.projectName}/package.json`,(err,data)=>{
                if(error){
                    console.log(chalk.red('读取文件失败'))
                    process.exit()
                }
                data= JSON.parse(data.toString())
                data.name = answer.projectName
                fs.writeFile(`${process.cwd()}/${answer.projectName}/package.json`,JSON.stringify(data,"","\t"),err=>{
                    if(err){
                        console.log(chalk.red('写入文件失败'))
                        process.exit()
                    }
                    console.log(chalk.green('初始化完成'))
                    process.exit() // 退出这次命令行操作
                })
            })
        })
    })
}

打开package.json发现与我们输入的项目名一样即修改成功

npm发布的相关内容可见前端工程化 发布一个自动生成微信开发文件的npm包 我已经发布了这个脚手架到npm上,相关代码放在github上,也可以npm i scav-cli -g试试相关的内容

generator-wxfile


基本功能

在这里,我使用yeoman实现了重复文件的生成,而在这里我使用plop来实现这个功能,使用yeoman来将一开始的文件拉到本地,要完成的有以下几个功能

  1. 询问用户构建的项目名,默认为文件夹名,写入project.config.json文件中
  2. 询问用户构建时使用的APPID,这里的APPID只是写入project.config.json文件
  3. 询问用户构建项目时默认的页面的文件名,构建相应的文件夹和文件,在app.json中写入相应的路由
  4. 询问用户是否启用plop来快速构建重复文件,是的话就将plopfile.js和模板文件加入到项目中
  5. 通过plop工具将重复文件写入pages文件夹中,在app.json中写入相应的路由

文件目录

基本的文件目录如下图,这里plop-temp和tempPage里面的文件一样,除了替换文本的语法不同,tempPage文件中的替换文本是使用yeoman写入的,所以是使用ejs语法,采用<%= prop%>的语法,而在plop-temp文件夹中的文件是在采用plop工具的时候使用的模板工具,使用{{prop}}的语法来代替

文件目录
这里templates的文件基本上是我使用微信开发者工具简单初始化后拿过来做为初始化文件模板的,也可以直接去我的github上把文件拉下来看看

yeoman实现初始化过程


询问用户

上面说到了,我要实现向用户询问的功能,这里可以使用inquire这个npm包,但是因为yeoman里面已经有这个功能了,所以我就直接使用yeoman-generator的prompt方法了

首先要引入yeoman-generator包,导出一个类,在prompting方法中调用this.prompt方法,代码如下

const Generator = require("yeoman-generator")

module.exports = class extends Generator{
    prompting(){
        return this.prompt([{ // 询问用户要创建的项目名称
            type:"input",
            name:"projectName",
            message:"your project name is",
            default:this.appname // 项目所在文件夹的名称
        },{ // 询问用户的appID是多少
            type:"input",
            name:"appID",
            message:"your appID is"
        },{ // 询问用户初始化的第一个页面名称是什么
            type:"input",
            name:"pageName",
            message:"the initialized page name is",
            default:"index" // 默认创建index页面
        },{ // 询问用户是否使用plop工具
            type:"confirm",
            name:"isPlop",
            message:"do you use plop",
            default:true // 默认使用
        }]).then(answer=>{
            this.answer = answer // 将回答放到answer属性中
        })
    }
}

根据用户的输入写入文件

上面我们获取用户的输入到this.answer中,在writing中我们通过这些答案来修改对应写入的页面的名字,上面获取的pageName就是我们初始化时构建的page中文件夹的名字,以及该文件夹下js、json、wxss、wxml文件的名字,而且要根据上面获取的isPlop来判断是否将plop-temp文件夹的内容和plopfile.js文件加入到项目中,因为涉及到文件夹的创建,所以这里引入了fs模块,代码如下

const Generator = require("yeoman-generator")
const fs = require("fs")

module.exports = class extends Generator{
    prompting(){
        // ...
    }
    writing(){
        const answer = this.answer
        const pageName = answer.pageName
        // 处理页面模板文件
        let tempPageFiles = ["tempPage.js","tempPage.json","tempPage.wxml","tempPage.wxss"]
            .map(path=>"tempPage/"+path)
        // 其他文件列表
        let tempOtherFiles = ["app.js","app.json","app.wxss","project.config.json","sitemap.json","package.json"]
        // 合并所有模板文件
        let tempFiles = [...tempPageFiles,...tempOtherFiles]
        // 处理页面输出文件
        let outputPageFile = [`${pageName}.js`,`${pageName}.json`,`${pageName}.wxml`,`${pageName}.wxss`]
            .map(path=>`pages/${pageName}/${path}`)
        // 合并所有输出文件
        let outputFiles = [...outputPageFile,...tempOtherFiles]
        if(answer.isPlop){ // 如果使用plop工具,则将相应的文件写入
            tempFiles=[...tempFiles,...["plop-temp","plopfile.js"]]
            outputFiles=[...outputFiles,...["plop-temp","plopfile.js"]]
        }
        // 创建文件夹,在文件夹创建完成后调用回调函数执行文件写入
        fs.mkdir(`pages/${pageName}`,'1',()=>{
            // 文件写入
            for(let i=0;i<tempFiles.length;i++){
                this.fs.copyTpl(this.templatePath(tempFiles[i]),this.destinationPath(outputFiles[i]),answer) 
            }
        }) 
    }
}

需要替换文本的文件

tempPage.js
要注意这里的tempPage.js是tempPage文件夹中的,在temp-plop文件夹中的要使用另一种替换语法
app.json
package.json
project.config.js

plop实现重复文件的写入


plopfile.js的编写

这里的plop工具要实现询问用户要新增的页面的名字,根据这个名字创建文件,将路由写入package.json文件中,因为plop工具本身不具备读写文件的功能,我这里又引入了fs模块,使用plop.setActionType来实现一个新的type,在setGenerator的actions中使用这个type的action 完整代码如下

const fs = require('fs')

module.exports = plop=>{
    plop.setActionType('changeRouter',(answers,config,plop)=>{
        fs.readFile('app.json',{},(err,data)=>{ // 读取app.json文件
            let d= JSON.parse(data.toString())
            d.pages.push(`pages/${answers.pageName}/${answers.pageName}`) // 将当前新添加的内容写入app.json
            d = JSON.stringify(d,"","\t")
            fs.writeFile('app.json',d,err=>{
                if(err)
                    throw err
            })
        })
    })
    plop.setGenerator('wxfile',{ // 这里的wxfile是一个自己设定的名字,在执行命令行的时候会用到
        description:'create the repeat wxfile', // 这里是对这个plop的功能描述
        prompts:[{
            type:'input', // 问题的类型
            name:'pageName', // 问题对应得到答案的变量名,可以在actions中使用该变量
            message:'your pageName is', // 在命令行中的问题
            default:'page' // 问题的默认答案
        }],
        actions:[{
            type:'add', // 操作类型,这里是添加文件
            path:'pages/{{pageName}}/{{pageName}}.json', // 添加的文件的路径
            templateFile:'plop-temp/tempPage.json' // 模板文件的路径
        },{
            type:'add', 
            path:'pages/{{pageName}}/{{pageName}}.js', 
            templateFile:'plop-temp/tempPage.js' 
        },{
            type:'add', 
            path:'pages/{{pageName}}/{{pageName}}.wxss', 
            templateFile:'plop-temp/tempPage.wxss' 
        },{
            type:'add', 
            path:'pages/{{pageName}}/{{pageName}}.wxml', 
            templateFile:'plop-temp/tempPage.wxml' 
        },{ // 修改app.json里面的路由
            type:'changeRouter'
        }]
    })
}

需要替換文本的文件

tempPage.js

使用方法

如果本地没有yo的话,就全局安装yo

npm i yo

接下来就执行

yo wxfile

根据问题就可以生成相应文件了 如果选择使用plop工具,本地没有plop的话,直接

npm i

就可以将plop做为开发依赖放到项目中,执行

plop wxfile

就可以使用相应功能了