最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 从零撸一个CLI命令行脚手架工具

    正文概述 掘金(前端森林)   2021-01-21   762

    前言

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

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

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

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

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

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

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

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

    • 效率
    • 复用性

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

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

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

    需要做哪些准备

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

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

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

    初始化项目

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

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

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

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

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

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

    脚本映射为命令

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

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

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

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

    这里只用在当前项目目录下执行npm link就可以了: 从零撸一个CLI命令行脚手架工具

    执行完npm link,这时你在命令行输入senlin便可以得到如下输出: 从零撸一个CLI命令行脚手架工具

    准备模版

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

    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实现。",
      },
    };
    

    这里顺便贴下模板地址:

    • ts-vue
    • umi-hooks

    也欢迎大家多多 star 啊!

    commander 解析命令行参数

    我们知道vue-cli给我们提供了很多便捷的指令: 从零撸一个CLI命令行脚手架工具

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

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

    对应代码:

    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的整体流程图:

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

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

    senlin -V

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

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

    senlin -l

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

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

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

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

    senlin -h

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

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

    senlin -i

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

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

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

    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

    // 创建项目前校验是否已存在
    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

    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

    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从零撸一个CLI命令行脚手架工具

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

    {
      "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上做了很多事情。本文只是简单的向大家介绍一下如何基于业务开发自己的脚手架。

    下面是完整代码:

    #!/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();
              }
            );
          }
        );
      });
    }
    

    起源地下载网 » 从零撸一个CLI命令行脚手架工具

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元