飞道的博客

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

396人阅读  评论(0)

前言

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

  • 平时自己创建新项目的流程是怎么样的?

  • 团队为了落地规范化(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 文件,并写入:


   
  1. #!/usr/bin/env node
  2. // 使用Node开发命令行工具所执行的JavaScript脚本必须在顶部加入 #!/usr/bin/env node 声明
  3. console.log( 'senlin-cli初始化...')

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

npm install commander download-git-repo inquirer ora chalk log-symbols

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

脚本映射为命令

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

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


   
  1.    "bin": {
  2.      "senlin""./index.js"
  3.   },

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

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

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

准备模版

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


   
  1. const templates = {
  2.    "ts-vue": {
  3.     url:  "https://github.com/easy-wheel/ts-vue",
  4.     downloadUrl:  "https://github.com:easy-wheel/ts-vue#master",
  5.     description:
  6.        "ts-vue是一个中后台前端解决方案,它基于 vue, typescript 和 element-ui实现。",
  7.   },
  8.    "umi-hooks": {
  9.     url:  "https://github.com/easy-wheel/Umi-hooks",
  10.     downloadUrl:  "https://github.com:easy-wheel/Umi-hooks#master",
  11.     description:
  12.        "Umi-Hooks是一个中后台前端解决方案,它基于 umi, react, typescript 和 ant-design实现。",
  13.   },
  14. };

这里关于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:查看帮助信息

对应代码:


   
  1. const program = require( "commander");
  2. program
  3.   .version(packageData.version)
  4.   .option( "-i, --init""初始化项目")
  5.   .option( "-V, --version""查看版本号信息")
  6.   .option( "-l, --list""查看可用模版列表")
  7. 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的处理是直接输出所有可用的模版信息:


   
  1. if (program.opts() && program.opts().list) {
  2.    // 查看可用模版列表
  3.    for (let key in templates) {
  4.     console.log( `${key} : ${templates[key].description}`);
  5.   }
  6. }

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

senlin -h

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

senlin -i

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

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

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


   
  1. inquirer
  2.     .prompt([
  3.       {
  4.          type"input",
  5.         name:  "projectName",
  6.         message:  "请输入项目名称",
  7.       },
  8.       {
  9.          type"input",
  10.         name:  "description",
  11.         message:  "请输入项目简介",
  12.       },
  13.       {
  14.          type"input",
  15.         name:  "author",
  16.         message:  "请输入作者名称",
  17.       },
  18.       {
  19.          type"list",
  20.         name:  "template",
  21.         message:  "选择其中一个作为项目模版",
  22.         choices: [ "ts-vue (vue+ts项目模版)""umi-hooks (react+ts项目模版)"],
  23.       },
  24.     ])
  25.     .then((answers) => {
  26.        // 把采集到的用户输入的数据解析替换到 package.json 文件中
  27.       console.log( "选择", answers.template.split( " ")[ 0]);

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

checkName


   
  1. // 创建项目前校验是否已存在
  2. function checkName(projectName) {
  3.    return  new Promise((resolve, reject) => {
  4.     fs.readdir(process.cwd(), (err, data) => {
  5.        if (err) {
  6.          return reject(err);
  7.       }
  8.        if (data.includes(projectName)) {
  9.          return reject( new Error( `${projectName} already exists!`));
  10.       }
  11.       resolve();
  12.     });
  13.   });
  14. }

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

downloadTemplate


   
  1. function downloadTemplate(gitUrl, projectName) {
  2.    const spinner = ora( "download template......").start();
  3.    return  new Promise((resolve, reject) => {
  4.     download(
  5.       gitUrl,
  6.       path.resolve(process.cwd(), projectName),
  7.       { clone:  true },
  8.       function (err) {
  9.          if (err) {
  10.            return reject(err);
  11.           spinner.fail();  // 下载失败提示
  12.         }
  13.         spinner.succeed();  // 下载成功提示
  14.         resolve();
  15.       }
  16.     );
  17.   });
  18. }

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

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

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

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

changeTemplate


   
  1. async function changeTemplate(customContent) {
  2.    // name description author
  3.    const { projectName =  "", description =  "", author =  "" } = customContent;
  4.    return  new Promise((resolve, reject) => {
  5.     fs.readFile(
  6.       path.resolve(process.cwd(), projectName,  "package.json"),
  7.        "utf8",
  8.       (err, data) => {
  9.          if (err) {
  10.            return reject(err);
  11.         }
  12.         let packageContent = JSON.parse(data);
  13.         packageContent.name = projectName;
  14.         packageContent.author = author;
  15.         packageContent.description = description;
  16.         fs.writeFile(
  17.           path.resolve(process.cwd(), projectName,  "package.json"),
  18.           JSON.stringify(packageContent, null,  2),
  19.            "utf8",
  20.           (err, data) => {
  21.              if (err) {
  22.                return reject(err);
  23.             }
  24.             resolve();
  25.           }
  26.         );
  27.       }
  28.     );
  29.   });
  30. }

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

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

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


   
  1. {
  2.    "name""senlin-cli-template",
  3.    "author""fengshuan",
  4.    "description""cli模板"
  5.    "private"true,
  6.    "scripts": {
  7.      "start""umi dev",
  8.      "build""umi build",
  9.      "test""umi test",
  10.       // ...
  11.   },
  12.    "dependencies": {
  13.     // ...
  14.   },
  15.    "devDependencies": {
  16.      // ...
  17.   },
  18. }

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

完整代码

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

下面是完整代码:


   
  1. #!/usr/bin/env node
  2. // 使用Node开发命令行工具所执行的JavaScript脚本必须在顶部加入 #!/usr/bin/env node 声明
  3. const program = require( "commander");
  4. const download = require( "download-git-repo");
  5. const inquirer = require( "inquirer");
  6. const ora = require( "ora");
  7. const chalk = require( "chalk");
  8. const packageData = require( "./package.json");
  9. const handlebars = require( "handlebars");
  10. const logSymbols = require( "log-symbols");
  11. const fs = require( "fs");
  12. const path = require( "path");
  13. const templates = {
  14.    "ts-vue": {
  15.     url:  "https://github.com/easy-wheel/ts-vue",
  16.     downloadUrl:  "https://github.com:easy-wheel/ts-vue#master",
  17.     description:
  18.        "ts-vue是一个中后台前端解决方案,它基于 vue, typescript 和 element-ui实现。",
  19.   },
  20.    "umi-hooks": {
  21.     url:  "https://github.com/easy-wheel/Umi-hooks",
  22.     downloadUrl:  "https://github.com:easy-wheel/Umi-hooks#master",
  23.     description:
  24.        "Umi-Hooks是一个中后台前端解决方案,它基于 umi, react, typescript 和 ant-design实现。",
  25.   },
  26. };
  27. program
  28.   .version(packageData.version)
  29.   .option( "-i, --init""初始化项目")
  30.   .option( "-V, --version""查看版本号信息")
  31.   .option( "-l, --list""查看可用模版列表");
  32. program.parse(process.argv);
  33. if (program.opts() && program.opts().init) {
  34.    // 初始化项目
  35.   inquirer
  36.     .prompt([
  37.       {
  38.          type"input",
  39.         name:  "projectName",
  40.         message:  "请输入项目名称",
  41.       },
  42.       {
  43.          type"input",
  44.         name:  "description",
  45.         message:  "请输入项目简介",
  46.       },
  47.       {
  48.          type"input",
  49.         name:  "author",
  50.         message:  "请输入作者名称",
  51.       },
  52.       {
  53.          type"list",
  54.         name:  "template",
  55.         message:  "选择其中一个作为项目模版",
  56.         choices: [ "ts-vue (vue+ts项目模版)""umi-hooks (react+ts项目模版)"],
  57.       },
  58.     ])
  59.     .then((answers) => {
  60.        // 把采集到的用户输入的数据解析替换到 package.json 文件中
  61.       console.log( "选择", answers.template.split( " ")[ 0]);
  62.       let url = templates[answers.template.split( " ")[ 0]].downloadUrl;
  63.       initTemplateDefault(answers, url);
  64.     });
  65. }
  66. if (program.opts() && program.opts().list) {
  67.    // 查看可用模版列表
  68.    for (let key in templates) {
  69.     console.log( `${key} : ${templates[key].description}`);
  70.   }
  71. }
  72. async function initTemplateDefault(customContent, gitUrl) {
  73.   console.log(
  74.     chalk.bold.cyan( "CosenCli: ") +  "will creating a new project starter"
  75.   );
  76.    const { projectName =  "" } = customContent;
  77.   try {
  78.     await checkName(projectName);
  79.     await downloadTemplate(gitUrl, projectName);
  80.     await changeTemplate(customContent);
  81.     console.log(chalk.green( "template download completed"));
  82.     console.log(
  83.       chalk.bold.cyan( "CosenCli: ") +  "a new project starter is created"
  84.     );
  85.   } catch (error) {
  86.     console.log(chalk.red(error));
  87.   }
  88. }
  89. // 创建项目前校验是否已存在
  90. function checkName(projectName) {
  91.    return  new Promise((resolve, reject) => {
  92.     fs.readdir(process.cwd(), (err, data) => {
  93.        if (err) {
  94.          return reject(err);
  95.       }
  96.        if (data.includes(projectName)) {
  97.          return reject( new Error( `${projectName} already exists!`));
  98.       }
  99.       resolve();
  100.     });
  101.   });
  102. }
  103. function downloadTemplate(gitUrl, projectName) {
  104.    const spinner = ora( "download template......").start();
  105.    return  new Promise((resolve, reject) => {
  106.     download(
  107.       gitUrl,
  108.       path.resolve(process.cwd(), projectName),
  109.       { clone:  true },
  110.       function (err) {
  111.          if (err) {
  112.            return reject(err);
  113.           spinner.fail();  // 下载失败提示
  114.         }
  115.         spinner.succeed();  // 下载成功提示
  116.         resolve();
  117.       }
  118.     );
  119.   });
  120. }
  121. async function changeTemplate(customContent) {
  122.    // name description author
  123.    const { projectName =  "", description =  "", author =  "" } = customContent;
  124.    return  new Promise((resolve, reject) => {
  125.     fs.readFile(
  126.       path.resolve(process.cwd(), projectName,  "package.json"),
  127.        "utf8",
  128.       (err, data) => {
  129.          if (err) {
  130.            return reject(err);
  131.         }
  132.         let packageContent = JSON.parse(data);
  133.         packageContent.name = projectName;
  134.         packageContent.author = author;
  135.         packageContent.description = description;
  136.         fs.writeFile(
  137.           path.resolve(process.cwd(), projectName,  "package.json"),
  138.           JSON.stringify(packageContent, null,  2),
  139.            "utf8",
  140.           (err, data) => {
  141.              if (err) {
  142.                return reject(err);
  143.             }
  144.             resolve();
  145.           }
  146.         );
  147.       }
  148.     );
  149.   });
  150. }

参考资料

[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


转载:https://blog.csdn.net/MrWeb/article/details/112975871
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场