前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从零撸一个CLI命令行脚手架工具

从零撸一个CLI命令行脚手架工具

作者头像
前端森林
发布2021-02-03 12:39:10
1K0
发布2021-02-03 12:39:10
举报
文章被收录于专栏:前端森林

前言

开始本篇文章前,我们先来思考几个问题:

  • 平时自己创建新项目的流程是怎么样的?
  • 团队为了落地规范化(git 提交规范、代码规范、文档规范等),做了哪些事情?

我想大部分同学肯定都是这样回答的:现在社区都有开箱即用的脚手架,像vue-clicreate-react-app这种,我们直接用脚手架来创建项目就可以了啊。

上面这种方式也是我所在的团队最开始的基操,但是随着团队成员的快速增加和业务的飞速迭代,有很多问题逐渐暴露出来:

大部分业务场景是相似的,那么对于基础框架结构的诉求(这里包括工具类、接口封装、环境变量配置、eslint 配置、git-hook 等)都是一样的。如果每次大家都从零开始,那么只会徒增很多毫无意义的重复性工作。

这里你可能会说:那我们简单的复制粘贴就可以了啊~

那你有没有感觉这种方式不太优雅呢?暂且不去评估这种方式的优缺点,如果后续基础框架结构发生调整,那么你是不是要继续坚持cv大法呢?

上面说了这么多,其实就是两个重点:

  • 效率
  • 复用性

我们团队内部也是发现了上述问题,结合自己的具体业务场景,自研了一套cli,主要也是基于Vue Cli打造而来,功能包含:

  • 支持基于VueReact的不同模板
  • 统一的项目目录结构
  • 丰富的工具类库
  • 初始化配置文件
  • 预定义的共用组件
  • 丰富的命令行提示

这里关于Vue-Cli的具体操作我就不演示了,直接进入正题。

需要做哪些准备

其实,也就是来看下主要借助了哪些第三方库的能力:

  • commander.js[1],可以自动的解析命令和参数,用于处理用户输入的命令。
  • download-git-repo[2],下载并提取 git 仓库,用于下载项目模板。
  • Inquirer.js[3],通用的命令行用户界面集合,用于和用户进行交互。
  • ora[4],下载过程久的话,可以用于显示下载中的动画效果。
  • chalk[5],可以给终端的字体加上颜色。
  • log-symbols[6],可以在终端上显示出 √ 或 × 等的图标。

这些第三方库的链接我都有在文中标出,对应的api也都相对简单,大家可以自行前往查看具体的使用,这里就不展开说明了。

初始化项目

首先创建一个空项目,命名为 cosen-cli,然后新建一个 index.js 文件,并写入:

代码语言:javascript
复制
#!/usr/bin/env node
// 使用Node开发命令行工具所执行的JavaScript脚本必须在顶部加入 #!/usr/bin/env node 声明

console.log('senlin-cli初始化...')

再执行 npm init 生成一个 package.json 文件。最后安装上面需要用到的依赖。

代码语言:javascript
复制
npm install commander download-git-repo inquirer ora chalk log-symbols

然后现在的目录结构就是:

脚本映射为命令

初始化项目后,接下来有一步很重要的操作:把脚本映射为命令。

具体操作就是在package.json文件中添加:

代码语言:javascript
复制
  "bin": {
    "senlin": "./index.js"
  },

有了脚本后,怎么把脚本链接到全局呢(其实就是像你执行vue命令一样)?

这里只用在当前项目目录下执行npm link就可以了:

执行完npm link,这时你在命令行输入senlin便可以得到如下输出:

准备模版

针对我们的业务场景,我准备了两套模板:

代码语言:javascript
复制
const templates = {
  "ts-vue": {
    url: "https://github.com/easy-wheel/ts-vue",
    downloadUrl: "https://github.com:easy-wheel/ts-vue#master",
    description:
      "ts-vue是一个中后台前端解决方案,它基于 vue, typescript 和 element-ui实现。",
  },
  "umi-hooks": {
    url: "https://github.com/easy-wheel/Umi-hooks",
    downloadUrl: "https://github.com:easy-wheel/Umi-hooks#master",
    description:
      "Umi-Hooks是一个中后台前端解决方案,它基于 umi, react, typescript 和 ant-design实现。",
  },
};

“这里关于downloadUrl有一点需要说明的是,url的格式须为:<host>:<userName>/<repo> <projectName> #branchName,否则会报'git clone' failed with status 128,具体可参考issue[7]

这里顺便贴下模板地址:

  • ts-vue[8]
  • umi-hooks[9]

也欢迎大家多多 star 啊!

commander 解析命令行参数

我们知道vue-cli给我们提供了很多便捷的指令:

这对于我们创建项目是很友好的,我这里也提供了几条指令:

  • -i:初始化项目
  • -V:查看版本号信息
  • -l:查看可用模版列表
  • -h:查看帮助信息

对应代码:

代码语言:javascript
复制
const program = require("commander");

program
  .version(packageData.version)
  .option("-i, --init", "初始化项目")
  .option("-V, --version", "查看版本号信息")
  .option("-l, --list", "查看可用模版列表")
program.parse(process.argv);

这里,我针对上面用到的相关api依次做下说明:

version

作用

用于定义命令程序的版本号

option

作用

定义命令的选项

参数说明

它接受四个参数,在第一个参数中,它可输入短名字 -i和长名字–-init,使用 | 或者,分隔,在命令行里使用时,这两个是等价的,区别是后者可以在程序里通过回调获取到;第二个为描述, 会在 help 信息里展示出来;第三个参数为回调函数,他接收的参数为一个string,有时候我们需要一个命令行创建多个模块,就需要一个回调来处理;第四个参数为默认值

parse

作用

用于解析process.argv

ok,到这里我们的前序工作就基本完成了。我这里梳理了一张cosen-cli的整体流程图:

下面我将按照流程图从左到右一次进行解析。

senlin -V

“注意我们最开始在package.json文件的bin里面写入的脚本为"senlin": "./index.js",也就是我们全局的指令为senlin

这个没什么好说的,就是用来输出当前cli的版本号:

senlin -l

这个是查看可用模版列表,目前我们有两套模板。这里针对senlin -l的处理是直接输出所有可用的模版信息:

代码语言:javascript
复制
if (program.opts() && program.opts().list) {
  // 查看可用模版列表
  for (let key in templates) {
    console.log(`${key} : ${templates[key].description}`);
  }
}

命令行输入senlin -l可看到:

senlin -h

也就是帮助信息,是根据commander已知的信息自动生成的:

senlin -i

这条指令是用来初始化模板的,也是目前cosen-cli中比较重要且复杂的一条了。

我们结合上文的流程图来梳理下这块的逻辑:

首先,利用inquirer提供给用户输入自定义信息(包含项目名称、项目简介、作者名称、选择项目模版)。对应代码就是:

代码语言:javascript
复制
inquirer
    .prompt([
      {
        type: "input",
        name: "projectName",
        message: "请输入项目名称",
      },
      {
        type: "input",
        name: "description",
        message: "请输入项目简介",
      },
      {
        type: "input",
        name: "author",
        message: "请输入作者名称",
      },
      {
        type: "list",
        name: "template",
        message: "选择其中一个作为项目模版",
        choices: ["ts-vue (vue+ts项目模版)", "umi-hooks (react+ts项目模版)"],
      },
    ])
    .then((answers) => {
      // 把采集到的用户输入的数据解析替换到 package.json 文件中
      console.log("选择", answers.template.split(" ")[0]);

通过answers可以获取到用户输入的信息,接下来我们要做的就是检查用户输入的项目名称是否已存在,防止已有项目被覆盖。这里对应是checkName

checkName

代码语言:javascript
复制
// 创建项目前校验是否已存在
function checkName(projectName) {
  return new Promise((resolve, reject) => {
    fs.readdir(process.cwd(), (err, data) => {
      if (err) {
        return reject(err);
      }
      if (data.includes(projectName)) {
        return reject(new Error(`${projectName} already exists!`));
      }
      resolve();
    });
  });
}

校验完项目名称,接下来就是下载对应代码模板了,对应downloadTemplate

downloadTemplate

代码语言:javascript
复制
function downloadTemplate(gitUrl, projectName) {
  const spinner = ora("download template......").start();

  return new Promise((resolve, reject) => {
    download(
      gitUrl,
      path.resolve(process.cwd(), projectName),
      { clone: true },
      function (err) {
        if (err) {
          return reject(err);
          spinner.fail(); // 下载失败提示
        }
        spinner.succeed(); // 下载成功提示
        resolve();
      }
    );
  });
}

可以看到在下载代码的过程中,我们使用了spinner来营造loading的效果,这也是为了避免拉取代码时间过久,用户得不到及时的反馈。

无论代码拉取成功或者失败,最终都会通过spinner.succeed()或者spinner.fail()来结束spinner

到这里,模板也拉取了。但还有一步没有做:用户通过交互式的命令行输入的项目名、作者、项目简介等信息我们并没有写入到本地的模板代码中。

下面,我们来完成这部分的工作,对应changeTemplate

changeTemplate

代码语言:javascript
复制
async function changeTemplate(customContent) {
  // name description author
  const { projectName = "", description = "", author = "" } = customContent;
  return new Promise((resolve, reject) => {
    fs.readFile(
      path.resolve(process.cwd(), projectName, "package.json"),
      "utf8",
      (err, data) => {
        if (err) {
          return reject(err);
        }
        let packageContent = JSON.parse(data);
        packageContent.name = projectName;
        packageContent.author = author;
        packageContent.description = description;
        fs.writeFile(
          path.resolve(process.cwd(), projectName, "package.json"),
          JSON.stringify(packageContent, null, 2),
          "utf8",
          (err, data) => {
            if (err) {
              return reject(err);
            }
            resolve();
          }
        );
      }
    );
  });
}

ok,到这里,我们整个cosen-cli的功能就介绍和解析完成了。

下面让我们来看下最终的效果。我们在命令行执行senlin -i

执行完成,本地就会生成一个senlin-cli-template的文件夹,对应就是我们采用umi-hooks生成的模板。这时我们打开文件夹的package.json文件:

代码语言:javascript
复制
{
  "name": "senlin-cli-template",
  "author": "fengshuan",
  "description": "cli模板"
  "private": true,
  "scripts": {
    "start": "umi dev",
    "build": "umi build",
    "test": "umi test",
     // ...
  },
  "dependencies": {
   // ...
  },
  "devDependencies": {
    // ...
  },
}

可以发现对应字段已经是用户自定义的字段了。

完整代码

最后贴下完整的代码,今天介绍的这些只是cosen-cli中的比较基础的一部分,我们针对业务在cli上做了很多事情。本文只是简单的向大家介绍一下如何基于业务开发自己的脚手架。

下面是完整代码:

代码语言:javascript
复制
#!/usr/bin/env node
// 使用Node开发命令行工具所执行的JavaScript脚本必须在顶部加入 #!/usr/bin/env node 声明

const program = require("commander");
const download = require("download-git-repo");
const inquirer = require("inquirer");
const ora = require("ora");
const chalk = require("chalk");
const packageData = require("./package.json");
const handlebars = require("handlebars");
const logSymbols = require("log-symbols");
const fs = require("fs");
const path = require("path");

const templates = {
  "ts-vue": {
    url: "https://github.com/easy-wheel/ts-vue",
    downloadUrl: "https://github.com:easy-wheel/ts-vue#master",
    description:
      "ts-vue是一个中后台前端解决方案,它基于 vue, typescript 和 element-ui实现。",
  },
  "umi-hooks": {
    url: "https://github.com/easy-wheel/Umi-hooks",
    downloadUrl: "https://github.com:easy-wheel/Umi-hooks#master",
    description:
      "Umi-Hooks是一个中后台前端解决方案,它基于 umi, react, typescript 和 ant-design实现。",
  },
};

program
  .version(packageData.version)
  .option("-i, --init", "初始化项目")
  .option("-V, --version", "查看版本号信息")
  .option("-l, --list", "查看可用模版列表");
program.parse(process.argv);
if (program.opts() && program.opts().init) {
  // 初始化项目
  inquirer
    .prompt([
      {
        type: "input",
        name: "projectName",
        message: "请输入项目名称",
      },
      {
        type: "input",
        name: "description",
        message: "请输入项目简介",
      },
      {
        type: "input",
        name: "author",
        message: "请输入作者名称",
      },
      {
        type: "list",
        name: "template",
        message: "选择其中一个作为项目模版",
        choices: ["ts-vue (vue+ts项目模版)", "umi-hooks (react+ts项目模版)"],
      },
    ])
    .then((answers) => {
      // 把采集到的用户输入的数据解析替换到 package.json 文件中
      console.log("选择", answers.template.split(" ")[0]);
      let url = templates[answers.template.split(" ")[0]].downloadUrl;
      initTemplateDefault(answers, url);
    });
}
if (program.opts() && program.opts().list) {
  // 查看可用模版列表
  for (let key in templates) {
    console.log(`${key} : ${templates[key].description}`);
  }
}

async function initTemplateDefault(customContent, gitUrl) {
  console.log(
    chalk.bold.cyan("CosenCli: ") + "will creating a new project starter"
  );
  const { projectName = "" } = customContent;

  try {
    await checkName(projectName);
    await downloadTemplate(gitUrl, projectName);
    await changeTemplate(customContent);

    console.log(chalk.green("template download completed"));
    console.log(
      chalk.bold.cyan("CosenCli: ") + "a new project starter is created"
    );
  } catch (error) {
    console.log(chalk.red(error));
  }
}

// 创建项目前校验是否已存在
function checkName(projectName) {
  return new Promise((resolve, reject) => {
    fs.readdir(process.cwd(), (err, data) => {
      if (err) {
        return reject(err);
      }
      if (data.includes(projectName)) {
        return reject(new Error(`${projectName} already exists!`));
      }
      resolve();
    });
  });
}

function downloadTemplate(gitUrl, projectName) {
  const spinner = ora("download template......").start();

  return new Promise((resolve, reject) => {
    download(
      gitUrl,
      path.resolve(process.cwd(), projectName),
      { clone: true },
      function (err) {
        if (err) {
          return reject(err);
          spinner.fail(); // 下载失败提示
        }
        spinner.succeed(); // 下载成功提示
        resolve();
      }
    );
  });
}

async function changeTemplate(customContent) {
  // name description author
  const { projectName = "", description = "", author = "" } = customContent;
  return new Promise((resolve, reject) => {
    fs.readFile(
      path.resolve(process.cwd(), projectName, "package.json"),
      "utf8",
      (err, data) => {
        if (err) {
          return reject(err);
        }
        let packageContent = JSON.parse(data);
        packageContent.name = projectName;
        packageContent.author = author;
        packageContent.description = description;
        fs.writeFile(
          path.resolve(process.cwd(), projectName, "package.json"),
          JSON.stringify(packageContent, null, 2),
          "utf8",
          (err, data) => {
            if (err) {
              return reject(err);
            }
            resolve();
          }
        );
      }
    );
  });
}

参考资料

[1]

commander.js: https://github.com/tj/commander.js

[2]

download-git-repo: https://www.npmjs.com/package/download-git-repo

[3]

Inquirer.js: https://github.com/SBoudrias/Inquirer.js

[4]

ora: https://github.com/sindresorhus/ora

[5]

chalk: https://github.com/chalk/chalk

[6]

log-symbols: https://github.com/sindresorhus/log-symbols

[7]

issue: https://github.com/wuqiong7/Note/issues/17

[8]

ts-vue: https://github.com/easy-wheel/ts-vue

[9]

umi-hooks: https://github.com/easy-wheel/Umi-hooks

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-01-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端森林 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 需要做哪些准备
  • 初始化项目
  • 脚本映射为命令
  • 准备模版
  • commander 解析命令行参数
    • version
      • 作用
    • option
      • 作用
      • 参数说明
    • parse
      • 作用
  • senlin -V
  • senlin -l
  • senlin -h
  • senlin -i
    • checkName
      • downloadTemplate
        • changeTemplate
        • 完整代码
          • 参考资料
          相关产品与服务
          命令行工具
          腾讯云命令行工具 TCCLI 是管理腾讯云资源的统一工具。使用腾讯云命令行工具,您可以快速调用腾讯云 API 来管理您的腾讯云资源。此外,您还可以基于腾讯云的命令行工具来做自动化和脚本处理,以更多样的方式进行组合和重用。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档