从0手撸一个自己的cli脚手架

200 阅读5分钟

一、CLI有啥用,认识CLI

前端开发过程中常见的CLI有:

  • create-react-app
  • vue-cli
  • webpack-cli
  • prettier-cli

基本复杂一点的工具都在集成CLI,为啥都要搞成CLI呢?

因为CLI可以提供更强大的功能:

  • 通过命令搭配实现不同的功能
  • 管理项目模版
  • 启动本地服务
  • 生成模版文件
  • 对代码进行格式化

二、手撸一个自己的前端脚手架

我们先从大家众所周知的vue-cli入手,先来看看他都是用了哪些npm包来实现的

1.必备模块

  • commander :参数解析 --help其实就借助了他~
  • inquirer :交互式命令行工具,有他就可以实现命令行的选择功能
  • download-git-repo :在git中下载模板
  • chalk :粉笔帮我们在控制台中画出各种各样的颜色
  • metalsmith :读取所有文件,实现模板渲染
  • consolidate :统一模板引擎

image.png

2.工程创建

开始创建项目,编写自己的脚手架~~~

需要实现哪些基本功能:

  1. 通过 wuwu-cli create <name> 命令创建项目
  2. 询问用户需要选择 需要下载的模板
  3. 远程拉取模板文件

搭建步骤拆解:

  1. 创建项目
  2. 创建脚手架启动命令(使用 commander)
  3. 询问用户问题获取创建所需信息(使用 inquirer)
  4. 下载远程模板(使用 download-git-repo)
  5. 发布项目
创建项目:
mkdir wuwu-cli
进入项目:
cd wuwu-cli
初始化,生成 package.json 文件:
npm init -y

2.1创建脚手架启动命令 (wuwu-cli命令)

创建文件夹目录:

├── bin 
│ └── www // 全局命令执行的根文件 
├── package.json

在www文件中写入:(用node去执行脚本)

#! /usr/bin/env node
console.log('hello')

在package.json中写入:

image.png

链接包到全局下使用

npm link

控制台测试下:

image.png

ok,我们已经可以成功的在命令行中使用wuwu-cli命令了

2.2划分目录结构

新建文件夹src和main.js文件

├── bin 
│ └── www // 全局命令执行的根文件 
├── package.json 
├── src 
│ ├── main.js // 入口文件

在www中引入main.js文件

#! /usr/bin/env node
require('../src/main.js')

后面将逻辑代码都写入main.js

2.3代码规范eslint配置

npm i eslint@6.3.0

初始化eslint配置文件

npx eslint --init  //初始化eslint配置文件

2.4解析命令行参数 commander

使用commander

npm install commander

在main.js入口文件中:

const program = require('commander');
// console.log(process.argv);
// 解析用户传递过来的参数
//program.parse(process.argv);
program.version('0.0.1') .parse(process.argv); // process.argv就是用户在命令行中传入的参数

执行wuwu-cli --help 就已经有提示了

image.png

这个版本号应该使用的是当前cli项目的版本号,我们需要动态获取,并且为了方便我们将常量全部放到src下的constants.js文件中

// 存放用户所需要的常量
const { name, version } = require('../package.json');

module.exports = {
  name,
  version,
};

这样我们就可以动态获取版本号

const program = require('commander');
// console.log(process.argv);
// 解析用户传递过来的参数
// program.parse(process.argv);
const { version } = require('./constants');

program.version(version).parse(process.argv);

image.png

2.5配置指令命令

根据我们想要实现的功能配置执行动作,遍历产生对应的命令

const program = require('commander');
// console.log(process.argv);
// 解析用户传递过来的参数
// program.parse(process.argv);
const { version } = require('./constants');

const actionsMap = {
  create: { // 创建模板
    description: 'create project',
    alias: 'cr',
    examples: [
      'wuwu-cli create <project-name>',
    ],
  },
  config: { // 配置配置文件
    description: 'config info',
    alias: 'c',
    examples: [
      'wuwu-cli config get <k>',
      'wuwu-cli config set <k> <v>',
    ],
  },
  '*': {
    alias: '',
    description: 'command not found',
    examples: [],
  },
};
// 循环创建命令
Object.keys(actionsMap).forEach((action) => {
  program
    .command(action) // 命令的名称
    .alias(actionsMap[action].alias) // 命令的别名
    .description(actionsMap[action].description) // 命令的描述
    .action(() => { // 动作
      if (action === '*') {
        console.log(actionsMap[action].description);
      } else {
        console.log(action);
      }
    });
});

// 监听help命令打印帮助信息
program.on('--help', () => {
  console.log('Examples');
  Object.keys(actionsMap).forEach((action) => {
    (actionsMap[action].examples || []).forEach((example) => {
      console.log(`${example}`);
    });
  });
});

program.version(version).parse(process.argv);

测试一下:

image.png

使用--help命令打印个logo

如果此时我们想给脚手架整个 Logo,工具库里的 figlet 就是干这个的

npm i figlet
const figlet = require('figlet');

program
  .on('--help', () => {
    // 使用 figlet 绘制 Logo
    console.log(`\r\n${figlet.textSync('wuwucli', {
      font: 'Ghost',
      horizontalLayout: 'default',
      verticalLayout: 'default',
      width: 80,
      whitespaceBreak: true,
    })}`);
    // 新增说明信息
    console.log(`\r\nRun ${chalk.cyan('roc <command> --help')} show details\r\n`);
  });

image.png

2.6配置create命令

在main.js中修改代码:

 if (action === '*') {
        console.log(actionsMap[action].description);
      } else {
        // console.log(action);
        require(path.resolve(__dirname, action))(...process.argv.slice(3)); //执行对应的文件
      }

在src目录下创建create.js

// 创建项目
module.exports = async (projectName) => {
  console.log('create', projectName);
};

执行wuwu-cli create project,可以打印出 project

同理其他命令也是如此。

测试:

image.png

2.7拉取项目

npm i axios@0.19.0  //高版本的会报错
const axios = require('axios');
// 1).获取仓库列表
const fetchRepoList = async () => {
  // 获取当前组织中的所有仓库信息,这个仓库中存放的都是项目模板
  // https://api.github.com/orgs/wuwu-cli/repos   orgs就是组织,如果是用户的话users。repos获取所有的仓库
  const { data } = await axios.get('https://api.github.com/users/wuchao03/repos');
  return data;
};

module.exports = async (projectName) => {
  let repos = await fetchRepoList();
  repos = repos.map((item) => item.name);
  console.log(repos);
};

GitHub上的模板:

image.png

发现在安装的时候用户体验很不好,没有任何提示,而且最终的结果我希望是可以供用户选择的,继续完善

2.8命令行的选择功能inquirer和loading优化

首先安装两个包

第一个包是ora就是loading的效果

还一个包就是inquirer

npm i inquirer@7.0.0 ora@3.4.0

create.js:

const axios = require('axios');
const ora = require('ora');
const Inquirer = require('inquirer');

// 1).获取仓库列表
const fetchRepoList = async () => {
  // 获取当前组织中的所有仓库信息,这个仓库中存放的都是项目模板
  // https://api.github.com/orgs/wuwu-cli/repos   orgs就是组织,如果是用户的话users。repos获取所有的仓库
  const { data } = await axios.get('https://api.github.com/users/wuchao03/repos');
  return data;
};

module.exports = async (projectName) => {
  const spinner = ora('fetching repo list');
  spinner.start(); // 开始loading
  let repos = await fetchRepoList();
  spinner.succeed(); // 结束loading

  // 选择模板
  repos = repos.map((item) => item.name);
  const { repo } = await Inquirer.prompt({
    name: 'repo',
    type: 'list',
    message: 'please choice repo template to create project',
    choices: repos, // 选择模式
  });
  console.log(repo);
};

我们发现每次都需要去开启loading、关闭loading,重复的代码当然不能放过啦!我们来简单的封装下:

// 封装loading效果
const wrapFetchAddLoding = (fn, message) => async (...args) => {
  const spinner = ora(message);
  spinner.start(); // 开始loading
  const result = await fn(...args);
  spinner.succeed(); // 结束loading
  return result;
};

module.exports = async (projectName) => {
  let repos = await wrapFetchAddLoding(fetchRepoList, 'fetching template ...');

  // 选择模板
  repos = repos.map((item) => item.name);
  const { repo } = await Inquirer.prompt({
    name: 'repo',
    type: 'list',
    message: 'please choice repo template to create project',
    choices: repos, // 选择模式
  });
  console.log(repo);
};

使用wuwu-cli cr命令测试一下,效果如下:

image.png

2.9获取版本信息

和获取模板一样,我们可以参考上面的方法

const fetchTagList = async (repo) => {
  // tags选择仓库的版本,contents选择仓库里的内容文件
  const { data } = await axios.get(`https://api.github.com/repos/wuchao03/${repo}/tags`);
  return data;
};


// 通过当前选择的项目 拉取对应的版本
  let tags = await wrapFetchAddLoding(fetchTagList, 'fetching tags ...')(repo);
  tags = tags.map((item) => item.name);
  const { tag } = await Inquirer.prompt({
    name: 'tag',
    type: 'list',
    message: 'please choice tag template to create project',
    choices: tags,
  });

效果如下:

image.png

3.0下载项目

我们已经成功获取到了项目模板名称和对应的版本,那我们就可以直接下载了

首先在constants.js中设置下载目录

// 存放用户所需要的常量
const { name, version } = require('../package.json');

// 存储模板的位置
// process.env环境变量,Mac:HOME,Windows:USERPROFILE
const downloadDirectory = `${process.env[process.platform === 'darwin' ? 'HOME' : 'USERPROFILE']}/.template`;

module.exports = {
  name,
  version,
  downloadDirectory,
};

在create.js中引入

// 下载目录
const { downloadDirectory } = require('./constants');
// 把模板放到一个临时目录里 存好,以备后期使用

这里需要借助一个包

npm i download-git-repo@2.0.0

很遗憾的是这个方法不是promise方法,没关系我们自己包装一下:

const { promisify } = require('util');
let downLoadGit = require('download-git-repo');
// 可以把异步的api转换成promise
downLoadGit = promisify(downLoadGit);

node中已经帮你提供了一个现成的方法,将异步的api可以快速转化成promise的形式

封装下载目录函数

// 下载目录
const download = async (repo, tag) => {
  let api = `wuchao03/${repo}`; // 下载项目
  if (tag) {
    api += `#${tag}`;
  }
  const dest = `${downloadDirectory}/${repo}`; // 将模板下载到对应的目录中
  await downLoadGit(api, dest);
  return dest; // 返回下载的最终目录
};

//使用:
const target = await wrapFetchAddLoding(download, 'download template')(repo, tag);

如果对于简单的项目可以直接把下载好的项目拷贝到当前执行命令的目录下即可。

安装ncp可以实现文件的拷贝功能:

npm i ncp

使用

let ncp = require('ncp');
ncp = promisify(ncp);

//使用
//将下载的文件拷贝到当前执行命令的目录下 
await ncp(target, path.join(path.resolve(), projectName));

当然这里可以做的更严谨一些,比如判断一下当前目录下是否有重名文件等…,还有很多细节也需要考虑像多次创建项目是否要利用已经下载好的模板,大家可以自由发挥

对于复杂模板:

把git上的项目下载下来,如果有ask文件就是一个复杂的模板,我们需要用户选择,选择后编译模板。

3.1模板编译

安装需要用到的模块

npm i metalsmith ejs consolidate

metalsmith只要是模板编译 都需要这个模块
consolidate统一了所有的模板引擎

//使用
const fs = require('fs');
const path = require('path');

const MetalSmith = require('metalsmith'); // 遍历文件夹
let { render } = require('consolidate').ejs;
render = promisify(render); // 包装渲染方法


// 没有ask文件说明不需要编译
if (!fs.existsSync(path.join(target, 'ask.js'))) {
  await ncp(target, path.join(path.resolve(), projectName));
} else {
  await new Promise((resovle, reject) => {
    MetalSmith(__dirname)
      .source(target) // 遍历下载的目录
      .destination(path.join(path.resolve(), projectName)) // 输出渲染后的结果
      .use(async (files, metal, done) => {
        // 弹框询问用户
        const result = await Inquirer.prompt(require(path.join(target, 'ask.js')));
        const data = metal.metadata();
        Object.assign(data, result); // 将询问的结果放到metadata中保证在下一个中间件中可以获取到
        delete files['ask.js'];
        done();
      })
      .use((files, metal, done) => {
        Reflect.ownKeys(files).forEach(async (file) => {
          let content = files[file].contents.toString(); // 获取文件中的内容
          if (file.includes('.js') || file.includes('.json')) { // 如果是js或者json才有可能是模板
            if (content.includes('<%')) { // 文件中用<% 我才需要编译
              content = await render(content, metal.metadata()); // 用数据渲染模板
              files[file].contents = Buffer.from(content); // 渲染好的结果替换即可
            }
          }
        });
        done();
      })
      .build((err) => { // 执行中间件
        if (!err) {
          resovle();
        } else {
          reject();
        }
      });
  });
}

效果如下:

image.png

下载下来的项目模板如下:

image.png

这里的逻辑就是像上面描述的那样,实现了模板替换,到此安装项目的功能就完成了!我们发现这里所有用到的地址路径都写死了,但是我们希望这是一个更通用的脚手架,可以让用户自己随意配置拉取地址~

3.2配置config命令

constants.js的配置

const configFile = `${process.env[process.platform === 'darwin' ? 'HOME' : 'USERPROFILE']}/.wuwurc`; // 配置文件的存储位置
const defaultConfig = {
  repo: 'wuchao03', // 默认拉取的仓库名
};

编写config.js

const fs = require('fs');
const { defaultConfig, configFile } = require('./constants');
module.exports = (action, k, v) => {
  if (action === 'get') {
    console.log('获取');
  } else if (action === 'set') {
    console.log('设置');
  }
  // ...
};

一般rc类型的配置文件都是ini格式也就是:

repo=wuwu-cli 
register=github

下载 ini 模块解析配置文件

npm i ini

这里的代码很简单,无非就是文件操作了:

const fs = require('fs');
const { encode, decode } = require('ini');
const { defaultConfig, configFile } = require('./constants');

module.exports = (action, k, v) => {
  const flag = fs.existsSync(configFile);
  const obj = {};
  if (flag) { // 配置文件存在
    const content = fs.readFileSync(configFile, 'utf8');
    const c = decode(content); // 将文件解析成对象
    Object.assign(obj, c);
  }
  if (action === 'get') {
    console.log(obj[k] || defaultConfig[k]);
  } else if (action === 'set') {
    obj[k] = v;
    fs.writeFileSync(configFile, encode(obj)); // 将内容转化ini格式写入到字符串中
    console.log(`${k}=${v}`);
  } else if (action === 'getVal') { 
    return obj[k];
  }
};

getVal这个方法是为了在执行create命令时可以获取到配置变量

const config = require('./config'); 
const repoUrl = config('getVal', 'repo');

这样我们可以将create方法中所有的wuwu-cli全部用获取到的值替换掉了

到此基本核心的方法已经ok,剩下的大家可以自行扩展。

3.3项目发布

将项目发布到npm和GitHub上

发布npm:
nrm ls
nrm use npm //将淘宝服务器切换至npm官方服务器
npm adduser 创建账户 
npm login 登录
npm publish //发布

npm地址

github地址

好了,此文到这就结束啦~