如何制作一个自己的cli

1,158 阅读7分钟

前言

cli即command line interface,其作用是处理以文本形式传递给程序的指令。中文译名也有命令行界面字符用户界面等等。

A command-line interface (CLI) processes commands to a computer program in the form of lines of text.——Command-line interface-Wikipedia

在前端开发中,比较出名的cli主要有vue-cli、react的CRA等等。这些cli都非常棒,入门简单,配置标准,官方维护,可以快速开发一个前端应用。

但是,个人在自己项目中经常需要折腾一些东西:

  • 希望一键上传静态资源到服务器
  • 灵活地更改webpack配置(当然vue-cli支持配置扩展,CRA也有对应的解决方案)
  • 等等...

虽然某些功能(比如上传静态资源到服务器)可以写脚本解决,但是每次新建一个项目都需要复制、粘贴、安装库,总归是麻烦的,所以可以把这些功能集成到我们的cli中。

本文记录了自己写的一个轻量cli的流程,包括调试、发布至npm等。由于配置是完全暴露的,所以取名为white-box-cli,适合对象主要为个人项目、小型前端团队、webpack折腾爱好者

此cli生成的项目为ts+react技术栈,具体的功能点击链接就可以看了,就不在这占用篇幅。感觉方便可以star,感觉有新的功能点可以加入可以提issue讨论交流🎉。

接下来开始正文

前置知识:

  • 对node有初步的了解
  • 对webpack有初步的了解
  • 对异步编程有初步的了解

本文主要阐述思路,具体的代码编写看个人技术栈情况。white-box-cli是用ts写的,通过tsc编译为js即可。本文不会涉及ts,为把重心放在思路上,假设使用js写。

文章结构

  • bin设置
  • cli项目结构及核心库
  • 主程序编写
  • cli init 功能
  • cli dev 功能
  • cli build 功能
  • cli upload 功能
  • 发布至npm

先创建项目,用npm init -y初始化。这里我的项目名就叫white-box-cli了,下文也将用此名称。大家可以自行命名自己的cli,比如my-cli...

bin设置

首先在package.json中加入bin字段配置

// package.json
{
  ...
  bin:{
    "white-box-cli": "bin/index.js"
  }
}

接着创建目录bin,新建文件index.js,使得white-box-cli命令指向index.js。当然你可以起任何自己想要的名字及目录,把执行文件放在bin目录下只是比较规范的做法。

在index.js先写入以下代码,再测试下能不能跑起来

#!/usr/bin/env node

console.log(process.argv);

其中,#!/usr/bin/env node命令是必须加上去的,它用于指明这个脚本的解释器为node,否则无法执行js代码,详细可见此文:#!/usr/bin/env node 到底是什么?

下一步使用npm link,创建软链接到全局,这样我们就可以像调用全局包一样在任何地方调用此项目。如果npm包发布后不想使用全局安装调用,也可以直接使用npx

这里我们先使用npm link及全局调用,方便调试。完成link后直接在命令行中输入white-box-cli param1 param2(如果你在bin中叫my-cli就输入my-cli),可以看到如下输出,我们的参数param1与param2也在里面了。

[
  'D:\\XXXX\\XXX\\node.exe',  // 解释程序node路径
  'C:\\Users\\XXXX\\AppData\\Roaming\\npm\\node_modules\\white-box-cli\\bin\\index.js', //脚本文件路径
  'param1',
  'param2'
]

如果成功的话就没问题了,接下来就开始写cli的代码。

cli项目结构及核心库

cli项目结构

- bin   //上文提到的bin目录
- src   // cli的主要源码目录
  - cli-commands    // 存放cli的命令
    - init
    - dev
    - build
    - upload
  - constants    // 常用变量
  - util   // 常用方法
  - white-box-cli.js    // cli主程序,在bin/index.js中require此文件
- template   // init生成的模板项目。此处我直接放在cli里了,也可以像vue-cli一样去下载git仓库,就不需要template目录

核心库

  • commander.js:方便开发命令行工具的库,可以解析各种命令
  • Inquirer.js:方便命令行交互的库
  • ssh2-sftp-client:sftp客户端,用于与ftp服务器建立连接,传输文件
  • ora:各种命令行提示符
  • chalk:带颜色的文字输出

主程序编写

主程序是cli的总入口,根据不同命令调用不同的模块。主要使用了commander库。此处先拿init功能作为示例。

#!/usr/bin/env node
import { program } from "commander";
import path from "path";

const version = require("../package.json").version;

program.version(version, "-v, --version");

program
  .command("init <app-name>")
  .description("使用 white-box-cli 初始化项目")
  .action((name) => {
    console.log('app-name: ', name);
  });

//...

program.parse(process.argv); //解析输入命令

在bin/index.js中require主程序

#!/usr/bin/env node

require("../src/white-box-cli.js");

当输入white-box-cli init my-app时,commander库会解析命令,触发对应的action方法。

输入:white-box-cli init my-app 输出:app-name: my-app

其他命令也大致如此,接下来只需编写各个功能的模块,如此可具有较好的维护性。

cli init 功能

init功能用于初始化项目,我们可以在主程序解析命令行,传参项目名,接下来生成模板项目即可。

// white-box-cli.js
// init action回调
program
  .command("init <app-name>")
  .description("使用 white-box-cli 初始化项目")
  .action((name) => {
    const { init } = require('./cli-commands/init');
    init(name);
  });

获取模板项目有两种方案,一种是拉git仓库,一种是内置在项目里。本文使用了后者。

// ./cli-commands/init/index.js
module.exports = function(name){

  // process.cwd()获取工作区目录
  const projectDir = path.join(process.cwd(), name); // 项目创建路径
  const sourceDir = path.join(__dirname, "../../../template"); // 模板文件路径
  
  // 使用mkdirp避免一级一级创建目录
  mkdirp(projectDir).then(async (made) => {
    if (made === undefined) {
      tip.fail('"创建失败,存在同名目录"');
      return;
    }
    // 解下来把模板文件拷贝到项目路径,做些适当的调整就ok了(比如修改项目package.json的name字段等等)
}

cli dev功能

dev功能为通过启动webpack-dev-server进入开发模式

// white-box-cli.js
...
program
  .command("dev")
  .description("进入开发模式")
  .option("-p, --port [port]", "指定开发端口")
  .action((cmd) => {
    const optionObj = parseCmd(cmd); // 命令行option选项
    const devWebpackPath = path.join(process.cwd(), "webpack.dev.js"); // 项目webpack.dev.js路径

    const { dev } = require("./cli-commands/dev");
    dev(optionObj, devWebpackPath);
  });
...
// ./cli-commands/dev/index.js
import Webpack from "webpack";
import WebpackDevServer from "webpack-dev-server";

module.exports = function dev(cliOption, devWebpackPath) {
  ....some code
  
  const config = require(devWebpackPath); // 获取devWebpack配置
  const devServerPbj = Object.assign( // 整合各个配置,优先级为  默认 < dev.js配置 < cli输入
    DEV_SERVER_DEFAULT,
    config.devServer,
    cliOption
  );
  config.devServer = devServerPbj;

  // 端口
  const port = devServerPbj["port"];
  
  WebpackDevServer.addDevServerEntrypoints(config, devServerPbj); // 想要使用HMR必须做这一步
  
  const compiler = Webpack(config);
  compiler.hooks.done.tap("done", () => {
    log();
    tip.success(`构建成功! ${chalk.blueBright(
      "http://localhost:" + port + "/"
    )}`);
  });
  compiler.hooks.failed.tap('failed', (error) => {
    tip.fail(error.message);
  })

  const server = new WebpackDevServer(compiler, devServerPbj);// 创建webpack dev server对象

  server.listen(port, "127.0.0.1", () => {// 确定监听端口
    console.log(`Starting server on http://localhost:${port}`);
  });
}

以上是dev的核心部分,此部分需要干以下事情:

  • 整合webpack.devServer,因为cli中会有webpack.dev.js配置、cli输入、cli默认数值造成冲突(比如端口)
  • 支持HMR,即代码中的WebpackDevServer.addDevServerEntrypoints
  • 创建编译器
  • 创建webpack dev server对象,并启动服务

cli build功能

build功能用于打包代码

// white-box-cli.js
...some code

program
  .command("build")
  .description("打包应用")
  .action(() => {
    const prodWebpackPath = path.join(process.cwd(), "webpack.prod.js");

    const { build } = require("./cli-commands/build");
    build(prodWebpackPath);
  });

...some code

打包的功能只需要传入webpack.prod.js配置,调用webpack库即可

// ./cli-commands/build/index.js
import webpack from "webpack";
module.exports = function(path){
...some code
  const config = require(path);
  webpack(config, (err, stats) => {
    if (err || stats.hasErrors()) {
      console.log('打包出错')
      ...
    }
    console.log('打包完成')
    ...
  })
...some code
}

upload功能

upload功能读取用户配置的upload.js,将静态资源传输到目标主机。可以借助ssh2-sftp-client库,向FTP服务器传输文件。

在编写此功能时需要提醒使用者注意防止敏感信息(服务器用户名、密码、IP)的泄露!

// white-box-cli.js
...some code
program
  .command("upload")
  .description("上传至FTP服务器")
  .option("-b, --build", "打包后再上传")
  .action((cmd) => {
    const optionObj = parseCmd(cmd);
    const cwd = process.cwd();
    const configPath = path.join(cwd, UPLOAD_NAME); // 开发者根目录下含FTP服务器信息的文件

    const { upload } = require("./cli-commands/upload");
    upload(optionObj, configPath);
  });
...some code
./cli-commands/upload/index.js
import Client from "ssh2-sftp-client";
...

module.exports = function(optionObj, uploadConfigPath){
  const sftp = new Client();
  
  ...some code
  
  tip.loading("正在连接服务器...");
  await sftp.connect(uploadConfig.targetServer);
  tip.success("成功连接至服务器");
    
  tip.success("开始上传...");
  sftp.on("upload", (info) => {
    console.log(`  上传成功: ${chalk.blueBright(info.source)}`);
  });

  await sftp.uploadDir(sourcePath, targetPath); //ssh2-sftp-client提供了上传目录的api,非常方便

  tip.success(`${sourcePath} 内容成功上传至 ${targetPath}`);
  sftp.end();

  ...some code
}

以上是upload的核心部分,省略了其他部分内容,比如保证敏感信息不泄露的交互、upload配置的验证、环境选择等等。

结语

以上便是编写一个轻量cli的主要思路,其实难度也不是特别大,多数时间也是基本花在查文档、看文档、想交互、了解相关库等等,但是写下来后确实对异步编程、webpack、node有了进一步的理解。

自己的cli完成后可以起一个自己喜欢的名字发布到npm上,以后就可以随时用了,至于如何发布到npm网上已经有很多很好的教程了,此处也不再赘述。

以上内容有需纠正之处欢迎大佬指正,也欢迎大家评论cli中能加什么常用功能。

GitHub: white-box-cli