写一个脚手架?

前端脚手架是怎么来的

说起脚手架,大家肯定都用过,vue-cli , create-react-app等,它们虽然是基于不同的技术,但功能可以总结为一句话:

  • 通过命令行交互的方式进行项目框架的搭建。

既然都用过,那你有没有想过,这样的一个工具,具体是怎么做出来的呢,怎么能实现一个属于自己的脚手架?

ask.jpg

现在开始,淦!

我们先来看下脚手架工具的使用流程

  1. 在命令行输入你的脚手架命令
  2. 命令行会对你进行一些询问
  3. 根据提出的问题进行选择,或者输入内容回答
  4. 根据你的回答,将项目结构初始化到你的目标目录

现在,正式开始,开发一个名叫 cli 的脚手架工具

首先,初始化一个项目目录

npm init -y

目录有了,怎么添加命令呢?

绑定全局命令

这个时候,打开控制台,输入 cli ,回车

image.png

因为我们的命令还没有添加到全局,所以这里会报错。

package.json 文件想必大家都很熟悉,在它包含的众多字段中,有一个字段 bin,没错,就是它:

"bin": {
  "cli": "bin/app"
},
复制代码

bin 项用来指定各个内部命令对应的可执行文件的位置。

上面代码指定,cli 命令对应的可执行文件为 bin 子目录下的app文件

若包发到npm上, 在执行全局安装(npm install cli -g)时,会将bin中的命令自动添加到全局

接着创建对应的入口文件:在根目录下创建bin子目录,然后创建app文件, 并写入一行我们非常熟悉的内容:

console.log('hello word')
复制代码

那我们在编码过程中想要测试,难道每次都要发布到 npm 然后重新全局安装吗?当然不是,这个时候,你需要了解 npm 的另外一个命令:

npm link

这个命令可以将你当前目录下的 package.json 中的 bin 字段所定义的命令自动添加到全局。

这个时候,控制台执行 cli,发现报错变了:

360软件小助手截图20210525135314.jpg

这是因为你的命令虽然添加上了,对应的入口文件也找到了,但是这个入口文件用什么解析器解析是不知道的,也就是系统并不知道这个文件是要通过node来解析。

解绑全局命令,重新绑定

执行 npm unlink 将命令解绑,然后在入口文件 app 添加一行代码:

#!/usr/bin/env node
复制代码

这行代码可以用来告诉系统当前文件是以node解析器来执行。

注意这行代码一定要放到文件第一行

#!/usr/bin/env node
console.log('hello word')
复制代码

执行 npm link 重新绑定命令

执行 cli 命令:

image.png

这时,命令对应的入口文件已经可以成功执行了

src=http___inews.gtimg.com_newsapp_bt_0_13440182087_1000&refer=http___inews.gtimg.jpg

编写入口文件

现在,我们要为脚手架添加一些可执行的交互命令

首先来了解几个必须的工具:

  1. commander

用来定义交互命令

// 信息声明
program
  .version(require('../package').version) // 指定命令版本
  .usage('<command> [options]') // 指定命令用法

// 定义命令
program
  .command('create <name> [template]')	// 创建 create 命令,可指定一个必选参数 name 和一个可选参数 template
  .description('基于模板创建一个项目结构')	// 命令的描述语
  .option('-s, --select', `选择一个模板`)
  .option('-t, --template <template>', `指定一个模板`)
  .action(async (name, template, options) => {	// 获取到相关参数
    // do smoething
    console.log('name:', name)
    console.log('template:', template)
    console.log('options:', options)
  })

// 解析命令
program.parse(process.argv)
复制代码

命令行执行 cli,可以看到定义的相关信息

image.png

执行 cli create app1 temp1 -s -t temp1, 可以获取到相关参数:

image.png

这时,就可以通过这些参数进行创建项目的逻辑代码编写

编写各命令对应的执行文件

根目录下创建 lib 子目录,用来存放各命令对应的执行文件,然后创建 Create.js

// Create.js

class Create {
    constructor(name, template, options) {
        console.log('name:', name)
        console.log('template:', template)
        console.log('options:', options)
    }
}

module.exports = Create
复制代码

app 中引入并执行

...
program
  .command('create <name> [template]')
  ...
  .action(async (name, template, options) => {
    new Create(name, template, options);
  })
...
复制代码

执行 cli create app1 temp1 -s -t temp1, 可以进入到 Create.js

  1. chalk

定义终端输入的样式

// Create.js

const chalk = require('chalk')

class Create {
    constructor(name, template, options) {
        console.log('name:', chalk.red(name))	// 终端中 app1 红色显示
        console.log('template:', template)
        console.log('options:', options)                            
    }
}

module.exports = Create
复制代码

image.png

  1. inquirer

命令行交互工具

// Create.js

const chalk = require('chalk')
const inquirer = require('inquirer')
const fs = require('fs')


class Create {
    constructor(name, template, options) {
        this.name = name
        this.template = template
        this.options = options
        this.init()                 
    }

    async init() {
        const isExists = await this.fsAccess(this.name)

        if (isExists) { // 如果模板目录存在,则需要进行选择操作
            const { action } = await inquirer.prompt([
                {
                    type: 'list',
                    name: 'action',
                    message: `Target directory ${chalk.cyan(this.name)} already exists. Pick an action:`,
                    choices: [
                        { name: 'Overwrite', value: 'overwrite' },
                        { name: 'Rename', value: 'rename' },
                        { name: 'Cancel', value: false }
                    ]
                }
            ])
            console.log(action) 
        }
        
    }

    // 判断目录是否存在
    fsAccess(path) {
        return new Promise(resolve => {
            fs.access(path, (err) => {
                if (!err) {
                    resolve(true)
                } else {
                    resolve(false)
                }
            })
        })

    }
}

module.exports = Create
复制代码

image.png

  1. ora

一个命令行loading小工具

  1. fs-extra

fs扩展工具

这里我们的项目模板采用从远程地址拉取的方式,当然,也可以将模板存放到脚手架中,直接进行复制操作

const ora = require('ora')
const child_process = require('child_process');
const templatePath= 'https://github.com/jquery/jquery.git'

// 拉取项目模板
cloneTemplate() {
      const spinner = ora("template clone... \n");	// 生成loading效果
      spinner.start();
      child_process.exec(`git clone ${templatePath} ${this.name}`, (err) => {	// 通过新开子进程的方式执行shell命令,拉取项目模板
          if (err) {
              spinner.fail()
              console.log(chalk.cyan('download error'))
          } else {
              spinner.succeed()
              console.log(chalk.green('download success'))
          }
      })
  }
复制代码

完整代码

// Create.js

const chalk = require('chalk')	// 终端样式定义
const inquirer = require('inquirer')	// 命令行交互
const fs = require('fs-extra')	// 文件操作拓展
const ora = require('ora')	// 终端loading效果
const child_process = require('child_process');	// 子进程
const templatePath= 'https://github.com/jquery/jquery.git'	// 模板地址



class Create {
    constructor(name, template, options) {
        this.name = name
        this.template = template
        this.options = options
        this.init()                 
    }

    async init() {
        const isExists = await this.fsAccess(this.name)	// 判断目标目录是否已存在

        if (isExists) { // 如果目标目录存在,则需要进行选择操作
            const { action } = await inquirer.prompt([
                {
                    type: 'list',
                    name: 'action',
                    message: `Target directory ${chalk.cyan(this.name)} already exists. Pick an action:`,
                    choices: [
                        { name: 'Overwrite', value: 'overwrite' },	// 重新目录
                        { name: 'Rename', value: 'rename' },	// 修改目标目录名称
                        { name: 'Cancel', value: false }	// 取消操作
                    ]
                }
            ])
            if (!action) {	// 选择 Cancel, 直接退出进程
                process.exit()
            } else if (action === 'overwrite') {	// 选择 Overwrite, 删除已有的目标目录
                await fs.remove(this.name)
                this.cloneTemplate();
            } else {	// 选择 Rename, 需要重新输入目标目录
                const { name } = await inquirer.prompt([
                    {
                        type: 'input',
                        name: 'name',
                        message: `please input the new app name:`
                    }
                ])
                this.name = name;
                this.init();
            }
        } else {
            this.cloneTemplate();
        }
        
    }

    // 判断目录是否存在
    fsAccess(path) {
        return new Promise(resolve => {
            fs.access(path, (err) => {
                if (!err) {
                    resolve(true)
                } else {
                    resolve(false)
                }
            })
        })
    }

    // 拉取项目模板
    cloneTemplate() {
        const spinner = ora("template clone... \n");
        spinner.start();
        child_process.exec(`git clone ${templatePath} ${this.name}`, (err) => {
            if (err) {
                spinner.fail()
                console.log(chalk.cyan('download error'))
            } else {
                spinner.succeed()
                console.log(chalk.green('download success'))
            }
        })
    }
}

module.exports = Create
复制代码

结语

这样,一个简单的脚手架工具就可以使用了,接下来,可以发布到npm进行全局安装

当然,这只是一个非常简单的demo,但是可以基于这个简单的demo,了解到一个脚手架的基本实现方式,然后根据实际需求,开发出一个适用于自己项目的通用脚手架

END

本文到此结束,有什么问题欢迎各位大佬留言

image.png

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享