从0到1系列第二辑,从命令行到脚手架

499 阅读2分钟

运行一行终端命令来创建脚手架,究竟经历了什么呢?

脚手架,必然运用到很多的命令行知识。本文会向大家介绍一个简单的vue脚手架从无到有的全过程,希望大家有所收获。

项目目录

|──bin
|    └──try.js
|──config
|    └──config.js
|──lib
|    ├──actions.js
|    ├──create.js
|    └──otherCommand.js
|──utils
|    └──terminal.js
|──templates
|    └──component.ejs

用到的库

命令行操控工具  --  commander
下载、克隆利器  --  download-git-repo 
命令行交互工具  --  inquirer
文件操作        --  fs-extra
地址操作        --  path

直接上手

1. 创建项目

npm init -y  // 初始化package.json

2. 创建执行文件,配置目录

2.1 创建/bin文件夹,配置命令的执行入口文件:/bin/try.js

文件的第一行必须是:

#! /usr/bin/env node

#! 称为shebang,增加这一行是为了指定:以 node 执行脚本文件。

2.2 在package.json内配置“bin”字段

为我们的第一个脚手架工具命名为:just-cli,键名对应的值指向的地址则是命令行的执行入口文件地址。

{
	"bin": {
        "just-cli": "./bin/try.js"
    },
}

3 链接命令至全局

终端执行 **npm link **,即可将配置的命令注册至全局。

npm link  // 取消链接: npm unlink

这时候,windows用户到npm安装目录查看,会发现我们多了这些文件:

  • just-cli
  • just-cli.cmd
  • just-cli.ps1

PS:mac用户可以到 /usr/local/lib/node_modules 查看。

4 配置版本号

4.1 首先安装包:commander
npm install commander --save
4.2 配置版本号命令:just-cli -V & just-cli -v
#! /usr/bin/env node

/* 
* 文件:/bin/try.js
*/

//引入 commander
const program = require('commander')

//版本号配置
program.version(`Version is ${require('../package.json').version}`, '-v, --version')//支持小写:‘-v’
    .description('手写一个脚手架')
    .usage('<command> [options]')

//解析参数,常置于定义命令之后
program.parse(process.argv);

到这里,你已经为 just-cli 配置了版本信息,这个版本信息是从脚手架项目的package.json中动态获取的,并支持以下两种,不区分大小写的查看版本信息的方式。

just-cli -v
just-cli -V

试试看打印你自己的脚手架版本信息吧!

5 第一个命令

定义一个形如:create name [options] 的命令,用于执行脚手架的初始化。

5.1 定义命令

首先我们定义这个命令,配置命令携带参数:name,添加命令描述,为其赋予 -f 的能力,指定执行函数:createAction

/* 
* 文件:/lib/create.js
* 命令:搭建脚手架
*  create  <name>
*/

const program = require('commander')
const {createAction} = require('./actions')
const create = function(){
    program.command('create <name>')
    .description('create a new project')
    .option('-f, --force', 'title to use before name')
    .action(createAction)
}
module.exports =create

然后在命令入口文件中引入上文的配置。

/* 
* 文件:/bin/try.js
*/

//......

//引入拆分的配置文件
const createConfig = require('../lib/create')

//添加拆分的配置:create <name>
createConfig()

//......
5.2 定义命令的执行内容
/* 
* 文件:/lib/actions.js
* 用途:定义命令的执行内容
*/
const open = require('open')
const { promisify } = require('util')

const download = promisify(require('download-git-repo'))

const { labAdress } = require('../config/lab-config')
const { spawnCommand } = require('../utils/terminal')



const createAction = async (project,options) => {
    console.log(`creating a project:${project},\noptions:\n${JSON.stringify(options)}`)
	
    //执行模板的克隆
    console.log(`downloading the templates documents`)
    await download(labAdress, project, { clone: true })

    // 执行命令 npm install
    // 判别命令在不同操作系统的类型,且windows系统会统一返回'win32',不管其本身操作系统是32/64位
    const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
    console.log(`spawn npm install:`)
    await spawnCommand(npm, ['install'], { cwd: `./${project}` })

    // 同步执行命令 npm run dev
    // 同步调用的原因:线程在项目运行后,会阻塞后续代码执行
    console.log(`spawn npm run dev`)
    spawnCommand(npm, ['run', 'dev'], { cwd: `./${project}` })

    // // 同步打开浏览器
    open('http://localhost:8080');

}
module.exports = {
    createAction
}

看看有哪些需要需要特别理解的地方:

  1. /config/config.js 文件储存了模板的下载地址,全局变量统一存储在这个文件。
  2. promisify 方法提供一个把同步方法转为异步方法的功能,避免回调地狱,很好用!
  3. 打开浏览器 通常不会在脚手架中配置,此处是为了直观地体现脚手架创建全流程。通常在模板的webpack中指定在打包完成后打开对应端口的网页。
  4. spawnCommand 是封装的child_process.spwan方法,封装内容如下。实际上,child_process的exec和spawn方法都能执行终端命令,child_process.spawn 适合用在处理大量数据返回的场景中,返回的数据大小没有限制,在此处下载场景下更适合。
/* 执行终端命令 */
const { spawn } = require('child_process')

//args参数包括:command、args、options
const spawnCommand = (...args) => {
    return new Promise((resolve, reject) => {
        const spawnProcess = spawn(...args)
        //复制控制台打印到主进程
        spawnProcess.stdout.pipe(process.stdout)
        spawnProcess.stderr.pipe(process.stderr)
        //监听进程的关闭,在执行完毕或error时触发
        spawnProcess.on('close', () => {
            resolve()
        })
    })
}
module.exports = {
    spawnCommand
}

6 设计交互逻辑

在上一节,实现了运用命令行创建脚手架模板,接下来,还需要对脚手架的创建过程进行优化,在命令的执行方法中添加必要的交互动作和逻辑判断。 引入 inquirer 工具,能很好地解决命令行交互的问题,交互和逻辑判断代码如下。

/* 
* 文件:/lib/actions.js
* 用途:定义命令的执行内容
*/
const open = require('open')
const path = require('path')
const fsextra = require('fs-extra')
const Inquirer = require('inquirer')
const { promisify } = require('util')
const download = promisify(require('download-git-repo'))

const { labAdress } = require('../config/lab-config')
const { spawnCommand } = require('../utils/terminal')
const { delDir } = require('../utils/common')

async function createAction(projectName, options) {
    console.log(`${projectName},\noptions:\n${JSON.stringify(options)}`)

    const cwd = process.cwd();// 获取当前命令执行时的工作目录  
    const targetDir = path.join(cwd, projectName);// 目标目录  
    console.log(targetDir)

    if (fsextra.existsSync(targetDir)) {
        if (options.force) {
            delDir(targetDir);
            console.log('删除原目录成功')
            create(projectName)
        } else {
            let { action } = await Inquirer.prompt([
                {
                    name: 'action',
                    type: 'list',
                    message: '目标文件夹已存在,请选择是否覆盖:',
                    choices: [
                        { name: '覆盖', value: true },
                        { name: '取消', value: false }
                    ]
                }
            ])
            if (!action) {
                console.log('取消操作')
                return
            } else {
                console.log(`\r\n正在删除原目录....`);
                delDir(targetDir)
                console.log('删除成功')
                create(projectName)
            }
        }
    } else {
        create(projectName)
    }
}

const create = async (projectName) => {
    // 见5.2小节内容
}
module.exports = {
    createAction
}

上文中的 delDir 方法是我们封装的一个向内递归遍历文件、删除文件夹的方法,定义如下:

// 引入fs模块
const fs = require('fs-extra');

function delDir(path) {
  // 读取文件夹中所有文件及文件夹
  const list = fs.readdirSync(path)
  list.forEach((item) => {
    // 拼接路径
    const url = path + '/' + item
    // 读取文件信息
    const stats = fs.statSync(url)
    // 判断是文件还是文件夹
    if (stats.isFile()) {
      // 当前为文件,则删除文件
      fs.unlinkSync(url)
    } else {
      // 当前为文件夹,则递归调用自身
      arguments.callee(url)
    }
  })
  // 删除空文件夹
  fs.rmdirSync(path)
}

module.exports={
    delDir
}

7 脚手架的其他功能

除了创建模板,脚手架还应该具备其他方便工作的指令,下面介绍如何运用脚手架命令添加一个新组件。 我们定义一个形如:

just-cli component <componentName>  -d <destination>

的命令,其中 -d 和 destination 参数为选填。我们需要以下四个步骤来完成这个命令的执行。

  1. 模板文件
  2. 交互判断:是否存在/是否覆盖/地址参数等
  3. 传入参数,编译模板
  4. 生成指定位置/默认位置的.vue文件

即刻着手! 首先我们定义这个命令,配置 -f 和 -d 能力,携带指定文件位置的参数:dest

/* 
* 文件:/lib/newComponent.js
* 定义命令:新增页面 
* page  <pageName>
*/

const program = require('commander')
const { addPageAction } = require('./actions')
const page = function () {
    program.command('page <name>')
        .description('add a new page')
        .option('-f, --force', 'spwan with force')
        .option('-d, --dest <dest>', '指定存储地址,例如:just-cli component <newPage> -d /studio/task')
        .action(addPageAction)
}
module.exports = page
7.1 模板文件

使用ejs模板实现模板的参数占位,一个预设的.vue文件模板如下:

<template>
    <div class="<%= data.lowerName %>">{{msg}}
        <h1>{{message}}</h1>
    </div>
</template>
<script>
    export default {
        name: '<%= data.name %>',
        props: {
            msg: String
        },
        components: {},
        mixins: [],
        data() {
            return {
                message: "<%= data.name %>"
            }
        },
        create() { },
        mounted() { },
        computed: {}
    }
</script>
<style>
    .<%=data.lowerName %> {}
</style>

注意:模板字符串也能实现这个功能。

7.2 交互判断

略,参考第6节

7.3 编译模板

模板的编译函数封装如下:

const ejs = require('ejs')
const path = require('path');

const compileEjs = (templateName, data) => {
    const templatePosition = `../templates/${templateName}`
    const templatePath = path.join(__dirname, templatePosition)
    return new Promise((resolve, reject) => {
        ejs.renderFile(templatePath, { data }, {}, (err, res) => {
            if (err) {
                console.log(err);
                reject(err)
                return
            }
            resolve(res)
        })
    })
}

其中,参数data中包含了要传入模板的参数,如: name。

7.4 生成指定位置/默认位置的.vue文件

文件的写入函数封装如下:

const fs = require('fs-extra');
const writeToFile = (path, templateContent) => {
    return fs.promises.writeFile(path, templateContent)
}

顺序完成这四个步骤,一个新增组件的命令就已经完成了!

------------------------------------------------------------

学到这里,小伙伴们已经可以将常用的项目配置通过上传代码仓库、运行脚手架来生成项目脚手架模板了,但这还只是脚手架的一点点,脚手架定制远不止这些内容,请大开想象,动手定制你专属的脚手架吧!