《开发一款简单的脚手架cli》


2020-02-20 上次更新时间:4/29/2022, 9:34:08 AM 0 工具类

# 0. 基本思路

该图引用自大佬文章,侵删

核心流程

    1. 命令行交互实现
    1. 获取命令行交互/全局配置有关内容
    1. 下载模板
    1. 根据交互进行模板渲染
    1. 项目初始化成功

本文仅讲解核心流程,没有将所有源码贴出来,各位小伙伴可以下载源码对照着看。cli-simple

# 1. 核心依赖

一个 cli 的实现必须要依赖以下几个工具,建议先了解基本用法再进行后面的阅读。

核心

  • commander: 目前命令行接口的最优解,是 cli 的核心。
  • inquirer:用于完成与用户交互。
  • ejs:用于模板渲染。

其他

  • fs-extra:fs模块的扩展,主要功能是文件管理。
  • globby: 增强版glob,主要用于模式匹配目录文件。
  • os: node 的 os 模块提,供了一些基本的系统操作函数。

# 2. 项目结构

本次 demo 只实现两个命令,一个是create、一个是config。按照项目目录建立文件后,就可以进入开发了。

# 3. 添加命令

  1. 新建 /bin/cute.js 文件
const program = require('commander');
const package = require('../package.json');

// 查看版本
program
    .version(package.version, '-v --version', '获取当前版本')
    .helpOption('-h, --help', '输出帮助文档')
    .usage('<command> [options]');

// 解析命令行参数
program.parse(process.argv);

1
2
3
4
5
6
7
8
9
10
11
12
  1. package.json 中设置 bin 字段 。key值 cute, 就是cli的关键字。
{
  "name": "cli-simple",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": {
    "cute": "bin/cute"
  }
}

1
2
3
4
5
6
7
8
9
10

package.json提供一个映射到本地本地文件名的bin字段,一旦被引入后,npm将软链接这个文件到prefix/bin里面,以便于全局引入,或者在./node_modules/.bin/目录里

  1. 在终端中输入以下命令可以看到输出
cute -v
1.0.0
1
2

# 4. 添加 create 命令

到这里我们已经可以学会了如何给 cute 添加命令。接下来进入关键流程--拉取模板渲染。create 命令是一个cli最基本的功能。

create 实现流程

    1. 使用inquirer编写跟用户的交互
    1. 获取第一步的answer
    1. 拉取准备好的模板,此时模板是已经根据预设值进行了处理
    1. 根据第二步的answer跟拉取下来的模板进行渲染
    1. 完成
  1. /bin/cute.js 中新增
// 添加create命令
program
    .command('create <app-name>')
    .description('创建一个新的项目')
    .action((name, cmd) => {
        const options = cleanArgs(cmd);
        require('../lib/create')(name, options);
    });
1
2
3
4
5
6
7
8
  1. 新增 /lib/create.js
const fs = require('fs-extra');
const path = require('path');
const inquirer = require('inquirer');
const chalk = require('chalk');
const globby = require('globby');
const ejs = require('ejs')
const util = require('./util')
const getPrompts = require('./util/prompts')

// 读取 tpl 模板信息
async function generateProject (projectName, tpl, answer) {
    await fs.copy(tpl, projectName, {
      filter: function (src) {
        // todo 偷个懒,在这里过滤交互命令里的内容,可以改成更高级的写法
        if (!answer.router) {
          return !/tpl(\\|\/)node_modules/.test(src) && !/tpl(\\|\/)router/.test(src)
        }
        if (!answer.vuex) {
          return !/tpl(\\|\/)node_modules/.test(src) && !/tpl(\\|\/)vuex/.test(src)
        }
        return !/tpl(\\|\/)node_modules/.test(src)
      }
    })
}

// 渲染 ejs 文件
async function renderEjs (files, context) {
    for(let tpl of files) {
      await render(tpl)
    }
    util.logGreen('项目初始化完成')
    async function  render(tpl) {
      let content = await ejs.renderFile(tpl, context, {async: true})
      await fs.writeFile(tpl.replace('.ejs', ''), content, 'utf8')
      await fs.remove(tpl)
    }
}

// 进入create流程
async function create(projectName, options = {}) {
    const cwd = options.cwd || process.cwd();
    const targetDir = path.resolve(cwd, projectName);
    const tplDir = path.resolve(__dirname, '../', 'tpl')

    // 目标目录是否存在
    if (fs.existsSync(targetDir)) {
        console.log(`\n 当前目录下${chalk.cyan(projectName)} 已存在,请换一个项目名...`);
        return;
    }

    // 进入命令交互
    const featurePrompt = inquirer.createPromptModule()
    let answer = await featurePrompt(getPrompts(projectName))
    console.log('answer>>>>>', answer)
    
    // 拉取 & 渲染模板
    await generateProject(projectName, tplDir, answer)
    const projectEjs = globby.sync('**/*.ejs', {cwd: projectName, absolute: true, dot: true})
    await renderEjs(projectEjs, answer)

};

// 记得export出去的时候是一个函数哦
module.exports = (...args) => {
    return create(...args).catch(e => {
        console.error(e);
    });
};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
  1. 在终端执行create命令
vue create <app-name>
1

# 4.1. 实现用户交互

前面也有说过,实现用户交互的关键是inquirer

const getPrompts = require('./util/prompts')

// 进入命令交互
const featurePrompt = inquirer.createPromptModule()
let answer = await featurePrompt(getPrompts(projectName))
console.log('answer>>>>>', answer)

1
2
3
4
5
6
7

answer打印出来的结果。

{ 
  "name": "qw",
  "description": "",
  "page": "single",
  "router": true,
  "routerMode": "hash",
  "vuex": true 
}

1
2
3
4
5
6
7
8
9

# 4.2 模板准备

demo使用的是ejs进行渲染,所有需要逻辑判断都可以改为.ejs后缀,并且可以在文件中使用 4.1 获取到的 answer

  1. 查看lib/util/prompts.js中对应的关键字page,如
    ...
    prompts.push({
        type: 'list',
        name: 'page',
        message: '请选择应用类型',
        choices: [
            { name: '单页应用', value: 'single', checked: true },
            { name: '多页应用', value: 'multi' }
        ]
    });
    ...
1
2
3
4
5
6
7
8
9
10
11
  1. tpl/index.vue.ejs是使用关键字page
<template>
  <div class="app">
    <h2>这是个ejs模板,可以使用获取到交互命令有关的内容</h2>
    <div>
      <% if (page == 'single') { %>
        <p>这是一个单页应用</p>
      <% } else { %>
        <p>这是一个多页应用</p>
      <% } %>
    </div>
    <div>
      看看package.json,也可以使用交互的参数哦。
    </div>
    
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

为了方便演示,本次demo的模板是放在当前目录下的,想要更改为远程仓库只需要调整模板下载的方式即可。

# 4.3 模板渲染

模板准备好后,就可以进入渲染。ejs.renderFile()方法会将拉取下来的模板根据 ejs 的语法进行渲染。

// 渲染 ejs 文件
async function renderEjs (files, context) {
    for(let tpl of files) {
      await render(tpl)
    }
    util.logGreen('项目初始化完成')
    async function  render(tpl) {
      let content = await ejs.renderFile(tpl, context, {async: true})
      await fs.writeFile(tpl.replace('.ejs', ''), content, 'utf8')
      await fs.remove(tpl)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 5. 添加 config 命令

config 命令是用来进行全局变量的配置。

config 实现流程

    1. 预设.cute-config.json
    1. .cute-config.json放到$HOME/
    1. 获取.cute-config.json内容
    1. .cute-config.json进行操作,set/get/remove
  1. /bin/cute.js 中新增
// 添加config命令
program
    .command('config')
    .arguments('[key] [val]', '设置或者读取全局配置如`token`、`env`、`host`等')
    .option('-r, --rm', '移除配置')
    .action((key, value, cmd) => {
        require('../lib/config')(key, value, cmd);
    });

1
2
3
4
5
6
7
8
9
  1. 新增 /lib/config.js
...

// 进入config流程
async function config (key, val, cmd) {
    let configKey = key
    let configVal = val
    let rm = cmd['rm']

    let config
    if(!configKey) {
        config = await getConfig()
        return util.log(config)
    }
    if (configKey) {
        if (rm) {
            // remove key
            await removeConfig(configKey)
            config = await getConfig()
            return util.log(config)
        } else if (!configVal) {
            config = await getConfig(configKey)
            return util.log(config)
        }
    }
    // set key
    if (configKey && configVal) {
        config = await setConfig(configKey, configVal)
        util.log(config)
    }
}

module.exports = (...args) => {
  return config(...args).catch(e => {
      console.error(e);
  });
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
  1. 在终端执行config命令

查看配置信息:

cute config

{
  "token": "",
  "registry": "https://github.com"
}

1
2
3
4
5
6
7

设置env变量

cute config env 123

{
  "token": "",
  "registry": "https://github.com",
  "env": "123"
}

1
2
3
4
5
6
7
8

移除token变量

cute config -r token

{
  "registry": "https://github.com",
  "env": "123"
}

1
2
3
4
5
6
7

# 5.1 将配置文件添加到环境变量

查看/lib/config.js

const fs = require('fs-extra');
const os = require('os');
const path = require('path');

let CONFIG_FILE = path.resolve(os.homedir(), '.cute-config.json')
let OPTIONS = ['env', 'token', 'registry']

// 读取 config
async function readConfig () {
  let config
  if (!config) {
    try {
      config = await fs.readJson(CONFIG_FILE)
    } catch (e) {
      // todo 偷个懒,配置文件放着,可以将它提取出来,如存放在.rc文件
      config = {token: '', registry: 'https://github.com'}
      // 查看$HOME/.cute-config.json 可以看到配置文件已存在
      await fs.writeJson(CONFIG_FILE, config)
    }
  }
  return config
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

$HOME/下查看,发现已存在.cute-config.json文件

# 6. 本地调试

cli-simple下载安装后,在项目下执行npm link 就可以在本地调试了。

# 最后

这只是一个简单的demo,欢迎提出意见和建议。

上次更新时间: 4/29/2022, 9:34:08 AM