教你搭建一个简易的脚手架,让你摸鱼更方便

643 阅读7分钟

前言:最近接手了一个项目,工期赶、需求多。但是其实很多需求,页面的逻辑结构都是非常类似的。只需cv一下,改动一下即可,但是不想做cv战士的我就想,为什么不写一个简易的脚手架工具呢?这样以后再碰到类似的需求时,只需在控制台敲几行命令,便能快速生成对应的页面。这样余下的时间,用来学习(摸鱼),岂不美哉?

于是,我就撸起袖子开干了,这里放上最终的npm包地址 yyds-cli(传到npm上的好处是不仅可以随时随地下载下来使用,而且还方便推给同事小姐姐乛◡乛)。

最终实现的脚手架功能有如下几个:

  1. 查看版本信息
  2. 查看升级信息
  3. 快速生成模板页面
  4. 模板路径配置
  5. 查看配置

下面就跟着我,一起来看看怎么实现这个脚手架工具吧。

准备工作

首先进行准备工作,新建目录,然后初始化项目。

mkdir yyds-cli && cd yyds-cli && npm init -y

然后下载需要使用的npm包

npm i --save fs-extra chalk commander figlet log-symbols@4.1.0 update-notifier minimist inquirer ejs

注意:因为最后包会发布到npm环境上,所以我们要把这些依赖安装在生产环境下。还有log-symbols我们需要带上版本号,因为最新版本的包不能在node环境下运行。

准备工作都做完以后,我们开始写第一个命令。

注册第一个命令 yyds -v

首先在package.json里面添加新的字段bin

"bin": {
    "yyds": "./bin/index.js"
},

接着执行npm link命令,将工作目录链接到全局,这样不管在哪个目录下,你都可以直接运行yyds命令了。

然后在当前目录新建一个bin目录,在里面新建一个index.js文件,里面存放所有我们要注册的指令。查看版本信息的代码如下:

#! /usr/bin/env node
const program = require('commander')

program
  .version(require('../package.json').version, '-v, --version') // 读取package.json里面的version
  .usage('<command> [options]')


program.parse(process.argv)  // 解析控制台敲入的命令

注意第一行#! /usr/bin/env node是必须的,否则node解析不到对应的指令 这样,我们就完成了第一个命令,大家可以在控制台,敲入yyds -v查看一下效果。发现跳出如下的信息,很鸡冻有木有?

此处插入图片

好了,平复一下心情,让我们接着实现第二个小功能:提示升级

提示升级

用过其他脚手架或者安装一些依赖的时候,都遇到过提示我们去升级版本的提示信息,那么他们是如何实现的呢?其实也不难,借助update-notifier这个包,我们也能轻松实现提示升级的功能,下面就来来看看具体实现吧。

为了方便维护,我们在工作目录下,再新建一个lib文件夹,并在此目录下新建update.js文件,编写提示升级的相关代码。

const updateNotifier = require('update-notifier');
const chalk = require('chalk')
const pkg = require('../package.json');

const notifier = updateNotifier({
    pkg,
    updateCheckInterval: 1000 // 检查更新的频率,官方默认是一天,这里设置成一秒
});
async function update() {
  if (notifier.update) {
    console.log(`Update available: ${chalk.cyan(notifier.update.latest)}`);
    notifier.notify()
  } else {
    console.log('No new version available')
  }
}


module.exports = update

写完update的逻辑以后,别忘了去bin/index.js里面去注册一下。

program
  .command('upgrade')
  .description('Check the yyds-cli version')
  .action(() => {
    require(`../lib/update`)()
})

为了测试,我们临时去package.json文件,将name改成vue-cli,然后我们去控制台敲下yyds upgrade,可以看到,他会提示我们去升级新的版本。

此处插入图片

name改回原来的以后,再敲击yyds upgrade,可以看到控制台打印出来No new version available

好了,上面两个指令算是开胃小菜,下面我们要完成主要功能,也就是我想做的快速生成页面

快速生成页面

实现目标:

  1. yyds g <路径> 可以快速生成页面
  2. 如果指定路径下有重名的文件,需要提示是否覆盖,支持用户手动选择覆盖or不覆盖
  3. 带上--force参数可以强行覆盖文件
  4. 通过-t 参数可以指定模板(为了支持多个模板)
  5. 传参校验

“宏图”已经给出了,下面看看具体实现吧

目标一:快速生成页面

在此之前,分别新建两个文件夹srctemplatesrc用来存放生成文件的目录,template目录用来存放好对应的页面模板文件:table.vue.ejstable.service.js.ejsreport.vue.ejsreport.model.js.ejs(相关的模板代码放在我的github仓库中)。接着开始编写相关的代码,如下所示:

const chalk = require('chalk')
const fs = require('fs-extra')
const ejs = require('ejs')
const symbols = require('log-symbols')
const path = require('path')

// 支持的模板信息
const map = {
  table: {
    'vue': {
      temp: 'table.vue.ejs', // 模板
      ext: 'vue', // 后缀
    },
    'js': {
      temp: 'table.service.js.ejs',
      ext: 'service.js',
    }
  },
  report: {
    'vue': {
      temp: 'report.vue.ejs',
      ext: 'vue',
    },
    'js': {
      temp: 'report.model.js.ejs',
      ext: 'model.js',
    }
  }
}

/** 
 * @params {*} path 路径
 * @params {*} options 参数
*/
async function generate(url, options) {
  let template, tempName
  const { temp = 'table' } = options // 获取要生成的模板,因为现在还没有支持模板选择的功能,所以先给一个默认的值
  
  if (temp in map) {  // 判断是否有对应模板
    tempName = temp
    template = map[temp]
    try {
      for (const [key, value] of Object.entries(template)) {
        const {temp, ext} = value
        const baseUrl = 'src'
        const filepath = `${baseUrl}/${url}`
        const fileName = url.split('/').map(str => str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()).join('') // 填入ejs模板中的name信息
        // 编译模板生成文件
        await compile(filepath, temp, {
          name: fileName,
          ext,
          ...options,
        })
      }
      console.log(symbols.success, chalk.cyan('🚀 generate success'))
    } catch (err) {
      console.log(symbols.error, chalk.red(`Generate failed. ${err}`))
    }
  } else { 
    console.log(symbols.error, chalk.red(`Sorry, don't support this template`)) // 没有则进行提示
  }
}

/**
 * 编译模板生成结果
 * @params {*} filepath 生成的文件路径
 * @params {*} templatepath 模板路径
 * @params {*} options 配置
*/
const compile = async (filepath, templatepath, options) => {
  const cwd = process.cwd(); // 当前的工作目录
  const targetDir = path.resolve(cwd,`${filepath}.${options.ext}`); // 生成的文件
  const tempDir = path.resolve(__dirname, `../template/${templatepath}`); // 模板存放路径
  if (fs.existsSync(tempDir)) { // 判断该模板路径是否存在
    const content = fs.readFileSync(tempDir).toString()
    const result = ejs.compile(content)(options)
    fs.writeFileSync(`${targetDir}`, result)
  } else {
    throw Error("Don't find target template in  directory")
  }
}

module.exports = generate

这里将.vue文件和.js文件分开维护的原因是,我的项目.vue文件和.js文件在不同的目录下,将他们分开维护的话,更方便管理。

同样地要去bin/index.js中去注册指令

// generate page
program
  .command('g <name>')
  .description('Generate template')
  .action((name, options) => {
    require('../lib/generate')(name, options)
  })

接下来,我们在命令行输入yyds g hello回车,可以看到在src文件夹下成功生成了两份文件hello.service.jshello.vue,同时控制台给出了生成成功的提示。

虽然功能是完成了,但其实还有很多问题存在。比如:现在只能输入一个具体的文件名,不能带上类似hello/hello的路径。另外假如对应路径下存在同名的文件,脚手架并没有给出提示,直接进行覆盖了,这肯定不符合我们的要求。

因此,接下来我们需要对yyds g指令进行优化。

支持输入路径

我们需要编写一个函数,替换原有的fs.writeFileSync

/**
 *
 * @param {*} paths 路径
 * @param {*} data 写入的数据
 * @param {*} ext 文件后缀
 */
 function writeFileEnsure(paths, data, ext) {
  const cwd = process.cwd();
  const pathArr = paths.split('/')
  pathArr.reduce((prev, cur, index) => {
    const baseUrl = path.resolve(cwd, `${prev}/${cur}`)
    if (index === pathArr.length - 1) {
      fs.writeFileSync(path.resolve(cwd, `${baseUrl}.${ext}`), data)
    } else {
      if (!fs.existsSync(baseUrl)) {
        fs.mkdirSync(path.resolve(cwd, baseUrl))
      }
    }
    return prev + '/' + cur
  }, '.')
}

compile函数中将原有的fs.writeFileSync替换成新写的函数:

// ...
// fs.writeFileSync(`${targetDir}`, result)
await writeFileEnsure(filepath, result, options.ext)
// ...

改写完以后,我们尝试是否支持yyds g hello/hello的形式,可以看到完美实现了功能🚀!

再接再厉,我们继续解决文件覆盖的问题💪。这里为了更好的用户体验,我们需要借助inquirer,帮助我们在命令行通过方向键、回车等的交互形式,选择对应的命令。这也是我们需要完成的目标二。

目标二:文件覆盖交互功能

首先在generate.js中引入inquirer

const inquirer = require('inquirer')

然后改写compile函数,在写入文件之前,先判断当前目录下有没有重名文件存在。

// ...
  if (fs.existsSync(tempDir)) { // 判断该模板路径是否存在
    if (fs.existsSync(targetDir)) {
      const { action } = await inquirer.prompt([
        {
          name: 'action',
          type: 'list',
          message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
          choices: [
            { name: 'Overwrite', value: 'overwrite' },
            { name: 'Cancel', value: false }
          ]
        }
      ])
      if (!action) {
        throw Error('Cancel overite')
      } else if (action === 'overwrite') {
        console.log(`👻Removing ${chalk.cyan(targetDir)}`)
        await fs.remove(targetDir)
      }
    }
    // ...
  }
// ...

现在,我们尝试继续在控制台敲下yyds g hello命令,就会发现出现如下界面

选择Overwrite就会对原文件进行覆盖,选择cancel则提示✖ Generate failed. Error: Cancel overwrite

当然也有暴躁的小伙伴,觉得每次都要进行选择,很麻烦。因为某些情况下,用户完全知道自己在干吗!那有没有办法,不提示直接进行覆盖呢?有!而且也很简单。只需在注册指令的时候加一行配置,并通过该配置传入的参数在compile函数中,再加一层判断即可。

目标三:强行覆写文件

添加-f --force参数

program
  .command('g <name>')
  .description('Generate template')
  .option('-f --force', 'Overwrite target directory if it exists')
  .action((name, options) => {
    require('../lib/generate')(name, options)
  })

修改compile函数

// ...
if (fs.existsSync(targetDir)) {
+  if (!options.force) { // 只有force为false的情况下,才会走入交互提示的逻辑
    const { action } = await inquirer.prompt([
      // ...
    ])
    // ...
+  }
}
// ...

好了,现在只需输入yyds g hello -f或者yyds g hello --force就能直接覆盖重名文件。

到这里,我们已经完成了三个目标了,但是细心的小伙伴可能已经发现了,generate.jsmap对象中还支持另一种模板report页面的快速生成,但是现在我们的脚手架明显还不支持生成对应的模板页面。莫慌,让我们先喝口茶缓一缓╭(°A° `)~~

好了,现在让我们来一起来完成目标四吧

目标四:切换模板

聪明的小伙伴,肯定也想到了,我们需要在注册的地方再添加一个参数,让我们能够在控制台输入对应的模板名字。

// generate page
program
  .command('g <name>')
  .description('Generate template')
  .option('-t, --temp <name>', 'Auto generate <name> page. Currently support template [table, report]', 'table') // 最后的'table' 代表如果没有传 -t 参数,则使用默认模板 table
  .option('-f --force', 'Overwrite target directory if it exists')
  .action((name, options) => {
    require('../lib/generate')(name, options)
  })

而在generate.js函数中,也早有对应的逻辑支持,通过options我们便能取到对应的模板名字。

// ...
const { temp } = options // 通过该行获取对应的模板信息
if (temp in map) { 
    tempName = temp
    template = map[temp] 
// ....

最后让我们看看效果,因为最终效果不方便展示,所以就看一看输入yyds g report -t report,控制台传入的options打印出来的日志信息是啥吧。

image-20210706234458517

可以看到temp对应的是report,所以之后也会拿到report对应的ejs模板进行渲染。之后如果我们想要继续添加其他模板,只要维护map这个对象,以及对应的ejs模板即可。

目标五:传参校验

现在我们只剩下最后一个目标五需要实现,其实这也很简单,只需要在注册指令的地方加一个判断即可,这里直接贴上代码:

// 需要引入这两个包
const minimist = require('minimist')
const chalk = require('chalk') 
// ...
program
  .command('g <name>')
  .description('Generate template')
  .option('-t, --temp <name>', 'Auto generate <name> page. Currently support template [table, report]', 'table') // 最后的table 代表如果没有传 -t 参数,则使用默认模板 table
  .option('-f --force', 'Overwrite target directory if it exists')
  .action((name, options) => {
    if (minimist(process.argv.slice(3))._.length > 1) {
      console.log(chalk.yellow('\n Info: You provided more than one argument. The first one will be used as the app\'s name, the rest are ignored.'))
    }
    require('../lib/generate')(name, options)
  })
// ...

现在当用户不按规定输入传参时,比如:yyds g hello -t report xxxx, 将会给出警告Info: You provided more than one argument. The first one will be used as the template's name, the rest are ignored.

配置和查看信息

其实到这里,这个脚手架基本已经能达成我的要求了,但是其实还有个小问题可以优化,因为我需要生成页面的路径是这样的src/xxx/xxx/xxx/xxx/xxx/xxx,那么每次生成页面,岂不是每次都要带上那一长串恶心🤢的路径,想到这里,我是崩溃的。

不过我想到了可以将页面的默认路径做成配置的形式,这样在控制台只需输入最终的路径,然后由配置项中的路径和输入的路径拼接在一起,就是我们最后需要生成页面的最终路径。并且默认路径可以支持在控制台,通过指令的形式去修改。下面来看看具体实现吧。

首先在lib下新建config.js

const fse = require('fs-extra');
const path = require('path');
const symbols = require('log-symbols')
const chalk = require('chalk')
const config = {
  'tablevue': 'src/renderer/page/manage/desk',
  'tablejs': 'src/renderer/page/manage/desk',
  'reportvue': 'src/renderer/page/manage/report',
  'reportjs': 'src/renderer/models/report',
}

const configPath = path.resolve(__dirname,'../config.json')

// 输出json文件
async function defConfig() {
  try {
    await fse.outputJson(configPath, config)
  } catch (err) {
    console.error(err)
    process.exit()
  }
}

// 获取配置信息
async function getJson () {
  try {
    const config = await fse.readJson(configPath)
    console.log(chalk.cyan('current config:'), config)
  } catch (err) {
    console.log(chalk.red(err))
  }
}

// 设置json
async function setUrl(name, link) {
  const exists = await fse.pathExists(configPath)
  if (exists){
    mirrorAction(name, link)
  }else{
    await defConfig()
    mirrorAction(name, link)
  }
}

async function mirrorAction(name, link){
  try {
    const config = await fse.readJson(configPath)
    config[name] = link
    await fse.writeJson(configPath, config)
    await getJson()
    console.log(symbols.success, '🚀 Set the url successful.')
  } catch (err) {
    console.log(symbols.error, chalk.red(`👻 Set the url failed. ${err}`))
    process.exit()
  }
}

module.exports = { setUrl, getJson, config}

然后改写一下generate函数,baseUrl不写死为src而是读取json文件里面的默认路径。

// ...
const { config } = require('./config')
const configPath = path.resolve(__dirname,'../config.json')
// ...
async function generate(url, options) {
  let template, tempName
  const { temp = 'table' } = options // 获取要生成的模板
  
  if (temp in map) {  // 判断是否有对应模板
    tempName = temp
    template = map[temp]
+   let jsonConfig
+   await fs.readJson(configPath).then((data) => {
+     jsonConfig = data
+   }).catch(() => {
+     fs.writeJson(configPath, config)
+     jsonConfig = config
+   })
    try {
      for (const [key, value] of Object.entries(template)) {
        const {temp, ext} = value
        // const baseUrl = 'src'
+       const baseUrl = jsonConfig[`${tempName}${key}`]
        const filepath = `${baseUrl}/${url}`
        const fileName = url.split('/').map(str => str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()).join('')
        // 编译模板生成文件
        await compile(filepath, temp, {
          name: fileName,
          ext,
          ...options,
        })
      }
      console.log(symbols.success, chalk.cyan('🚀 generate success'))
    } catch (err) {
      console.log(symbols.error, chalk.red(`Generate failed. ${err}`))
    }
  } else { 
    console.log(symbols.error, chalk.red(`Sorry, don't support this template`)) // 没有则进行提示
  }
}
// ...

最后去注册指令yyds m <template> <url>yyds config

program
    .command('m <template> <url>')
    .description("Set the template url.")
    .action((template, url) => {
            require('../lib/config').setUrl(template, url)
    })

program
    .command('config')
    .description("see the current config.")
    .action((template, mirror) => {
            require('../lib/config').getJson(template, mirror)
    })

完成以上步骤以后,我们来看看效果。

首先重新执行yyds g hello指令,看看路径有没有变化。可以看到,生成的页面将会出现在我们配置的src/renderer/page/manage/desk目录下,同时会生成一份config.json文件,里面保存的是我们的默认路径配置。

接着我们执行yyds config命令,试试能否查看配置信息。可以看到成功地输出了结果,如下所示:

image-20210707110109852

然后我们尝试一下修改配置,修改table模板生成的vue文件的目录,yyds m tablevue src/desk

image-20210707110412450

最后验证一下结果,重新执行yyds g hello,查看生成的路径。

image-20210707110601254

完美!给自己鼓鼓掌。

优化

到这里整个脚手架功能已经完成的差不多了。不过我们还是可以进行一些小小的优化。

帮助信息

program.on('--help', () => {
  console.log()
  console.log(`  Run ${chalk.cyan(`yyds <command> --help`)} for detailed usage of given command.`)
  console.log()
})

program.commands.forEach(c => c.on('--help', () => console.log()))

当输入yyds -h以后,最底下会有Run hzzt <command> --help for detailed usage of given command.提示用户获取如何更详细的指令信息。

来点仪式感

借助figlet这个包,我们可以搞点花里胡哨的提示。 在generate.js函数成功生成页面以后,我们展示一下大大的提示语yyds

// ...
const { promisify } = require('util')
const figlet = promisify(require('figlet'))
// ...

// ...
console.log(symbols.success, chalk.cyan('🚀 generate success'))
const info = await figlet('yyds', {
  font: 'Ghost',
  width: 100,
  whitespaceBreak: true
})
console.log(chalk.green(info), '\n')
// ...

image-20210707112633198

发布到npm官网

到此,这个脚手架功能已经写完了。不过我们还差最后一步:将写好的脚手架发布到npm官网,具体步骤如下:

  1. 新建.npmignore文件
config.josn
node_modules/
src/

排除要发布到npm仓库的文件

  1. 注册登录npm账号 如果是首次发包则执行npm adduser,输入用户名、密码以及邮箱,完成注册。完成这一步后,别忘了去邮箱验证账号,否则后面发包会报错。

非首次发包则执行npm login输入用户名、密码以及邮箱,登录账号。

  1. 借还本地对应的npm镜像为npm

如果是淘宝的镜像,需要切换成npm。怎么快速查看和切换,这里推荐一个工具nrm

nrm current // 查看当前镜像源
nrm ls // 可用的镜像
nrm use npm // 切换镜像
  1. 发布

执行npm publish --access public因为默认发包是私包,所以需要带上--access public否则会报错。

  1. 解绑

执行npm unlink进行解绑,否则下载下来的npm包,无法进行验证,因为关联的是本地的yyds目录。

如果解绑不成功则执行npm unlink -g yyds-cli,我就是npm unlink解绑不了,最后执行前面的命令,才解绑成功。刚开始是npm unlink直接报错提示要带上packagename,但是我带上以后还是无法解绑。后来猜测是切换了Node版本的原因,于是尝试了切换node版本,虽然能直接执行npm unlink了,但是还是无法解绑成功。最后我带上-g参数运行npm unlink -g yyds-cli才解绑成功。原因网上没查到,望知道的大神告知。

  1. 验证

发包成功以后,全局安装脚手架,然后去工作目录下测试效果。

npm i -g yyds-cli && npm g hello

看到页面在项目下成功生成,心里不禁轻蔑一笑(哼,还想阻止我到点下班)。

说点啥

写这个工具一方面确实是为了偷懒,另一方面也是兴趣使然,就和本人是一名兴趣使然的前端程序猿一样。望各位看官看了能留下宝贵的意见,不吝赐教(菜鸟瑟瑟发抖)。

最后本文的源码也放在了我的小破github上,喜欢的也希望给个star

巨人的肩膀:

  1. 前端CLI脚手架思路解析-从0到1搭建
  2. Vue CLI 是如何实现的 -- 终端命令行工具篇